perf: open the all-recipes view on libraries with tens of thousands of recipes#3139
Open
bigsearcher wants to merge 4 commits into
Open
perf: open the all-recipes view on libraries with tens of thousands of recipes#3139bigsearcher wants to merge 4 commits into
bigsearcher wants to merge 4 commits into
Conversation
added 3 commits
May 11, 2026 06:30
Vue's ref() recursively reactivates every nested object. Loading the all-recipes view in libraries that contain tens of thousands of recipes allocates the same number of Proxies, freezes the browser for several seconds, and inflates memory usage well before any DOM work happens. The list views never mutate individual recipe fields, only swap the whole array, so shallow reactivity is sufficient. Switch the recipe arrays in AppIndex.vue and SearchResults.vue to shallowRef() and apply markRaw() to the assigned response payload. No behaviour change on small libraries; on a 30k-recipe cookbook the "opening the app" stall caused by reactive wrapping disappears. Signed-off-by: Maxim Kiselev <mk@mkisel.com>
JSON.parse(JSON.stringify(recipes)) is called once per active sort key on the entire recipe list. sort() needs a fresh array because it mutates in place, but it does not touch the recipe objects themselves, so a deep clone is wasted work — millions of string and object allocations on libraries with tens of thousands of recipes. Replace with recipes.slice(). Same outputs, same mutation safety. Signed-off-by: Maxim Kiselev <mk@mkisel.com>
For each recipe, recipeObjects called
filteredRecipes.value.map((r) => r.recipe_id).includes(rec.recipe_id)
That allocates a fresh N-element array of ids and walks it linearly,
inside the per-recipe map() — i.e. O(N^2) overall. On a cookbook with
30k recipes this is roughly a billion operations every time the
computed re-runs and is the dominant cause of the multi-second freeze
when opening the all-recipes view, before any DOM work happens.
Build the id set once at the start of the computed and use Set.has()
(O(1)) inside makeObject. Same outputs.
Signed-off-by: Maxim Kiselev <mk@mkisel.com>
The all-recipes view rendered one <RecipeCard> per recipe inside a
flat <ul><li v-for>, producing N DOM nodes regardless of viewport
size. With tens of thousands of recipes this is the dominant
remaining cause of unresponsive scroll and very slow first paint
once the JavaScript-side perf bottlenecks are out of the way.
Replace the v-for with <RecycleScroller> from vue-virtual-scroller
in page-mode + grid-items mode. Only the visible cells live in the
DOM at any time:
- page-mode — uses the document scrollbar, no explicit scroller
height needed; layout fits the existing app shell
- item-size — 130px (RecipeCard body 105px + 0.5rem top + 1rem
bottom margin)
- item-secondary-size — 332px (300px card width + 2 * 1rem margin)
- grid-items — floor((window.innerWidth - 300) / 332), recomputed
on window resize (300px ≈ NC navigation pane)
A new visibleRecipes computed feeds the scroller — it's the existing
recipeObjects pipeline with hidden entries filtered out, so the
scrollbar reflects what the user actually sees. Filters and the
existing six sort variants continue to work unchanged.
Adds vue-virtual-scroller@^1.1.2 as a runtime dependency. The 1.x
line is the Vue 2 release (the 2.x major is for Vue 3) and is
actively maintained.
Backend, REST API, JSON shape and the @nextcloud/vue layer are not
touched; mobile and third-party API consumers are unaffected.
Tested locally: vite build passes, eslint clean, app renders a 30k
recipe library with no observable freeze; visible cells, scroll,
keyword filters and all six sort variants behave as before.
Signed-off-by: Maxim Kiselev <mk@mkisel.com>
0d5ccc4 to
69bc23e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Topic and Scope
On a library with ~30 000 recipes, opening the all-recipes view (
/)freezes the browser indefinitely on the current
master. The freezehappens in pure JavaScript, before any DOM is painted, so the page
never recovers. Smaller libraries are also affected proportionally —
the cost is just less visible.
This PR is four small, independent commits — three pure perf fixes
in existing code, plus virtualization of the recipe grid. Together
they take the all-recipes view from "browser hangs forever" on a 30k
library to "renders in a few hundred ms with smooth scroll".
The four bottlenecks, in order of impact
recipeObjectsisO(N²)—src/components/List/RecipeList.vueFor every recipe it called
filteredRecipes.value.map((r) => r.recipe_id).includes(rec.recipe_id),i.e. an
O(N)scan inside a per-recipemap(). On 30k recipes thatis ~1 billion operations every time the computed re-runs.
Replaced with a single
Setbuilt once per recomputation; lookupis
O(1). Single biggest win — this alone removes the multi-secondfreeze on every render.
Recipes wrapped in reactive Proxies —
src/components/AppIndex.vue,src/components/SearchResults.vueref([])followed byrecipes.value = response.datarecursivelywraps every nested object. The list views never mutate individual
recipe fields, only swap the whole array, so
shallowRef+ a one-shotmarkRaw(response.data)is sufficient. Saves N proxy allocations onevery page load.
sortRecipesdeep-clones via JSON round-trip —src/components/List/RecipeList.vueJSON.parse(JSON.stringify(recipes))was called once per active sortkey.
sort()only needs a fresh array; it does not touch recipeobjects. Replaced with
recipes.slice(). Allocates one array insteadof millions of strings + objects.
<ul><li v-for>rendered N DOM nodes —src/components/List/RecipeList.vueReplaced with
<RecycleScroller>fromvue-virtual-scroller@^1.1.2in
page-mode+grid-itemsmode. Only the visible cells live inthe DOM.
gridItemsis recomputed onwindowresize asfloor((window.innerWidth − 300) / 332), where300is the roughwidth of the Nextcloud navigation pane and
332is one card cell(300px card + 2×1rem margin). A new
visibleRecipescomputed feedsthe scroller — it's the existing
recipeObjectspipeline with hiddenentries filtered out, so the scrollbar matches the visible content.
Commits are standalone and could be split into separate PRs if you'd
prefer; they touch the same component but are conceptually independent
(1–3 are pure correctness/perf in existing code, 4 introduces one
runtime dependency).
Concerns/issues
vue-virtual-scroller@^1.1.2. The 1.xline is the Vue 2 release (2.x is Vue 3) and is actively maintained.
Adds ~30 KB minified to the main bundle.
flex-wraplayoutreflowed continuously as the viewport changed. The new grid uses a
fixed integer column count recomputed only on
resize. Acceptable inpractice but it is a behavioural change.
scroller config. If a future RecipeCard variant grows vertically,
:item-sizemust be bumped to match.gridItemsassumes ~300px for the NCnavigation pane. A smarter implementation would observe the scroller
container directly with
ResizeObserver— happy to switch to that ifyou'd prefer it before merge.
appinfo/routes.php, the PHP controllers andthe REST API at
/apps/cookbook/api/v1/*are untouched. Mobilecookbook clients (iOS / Android) and any third-party API consumer
see an identical server.
Formal requirements