Skip to content

perf: open the all-recipes view on libraries with tens of thousands of recipes#3139

Open
bigsearcher wants to merge 4 commits into
nextcloud:masterfrom
bigsearcher:upstream-perf-fix
Open

perf: open the all-recipes view on libraries with tens of thousands of recipes#3139
bigsearcher wants to merge 4 commits into
nextcloud:masterfrom
bigsearcher:upstream-perf-fix

Conversation

@bigsearcher
Copy link
Copy Markdown

Topic and Scope

On a library with ~30 000 recipes, opening the all-recipes view (/)
freezes the browser indefinitely on the current master. The freeze
happens 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

  1. recipeObjects is O(N²)src/components/List/RecipeList.vue

    For every recipe it called
    filteredRecipes.value.map((r) => r.recipe_id).includes(rec.recipe_id),
    i.e. an O(N) scan inside a per-recipe map(). On 30k recipes that
    is ~1 billion operations every time the computed re-runs.
    Replaced with a single Set built once per recomputation; lookup
    is O(1). Single biggest win — this alone removes the multi-second
    freeze on every render.

  2. Recipes wrapped in reactive Proxies
    src/components/AppIndex.vue, src/components/SearchResults.vue

    ref([]) followed by recipes.value = response.data recursively
    wraps every nested object. The list views never mutate individual
    recipe fields, only swap the whole array, so shallowRef + a one-shot
    markRaw(response.data) is sufficient. Saves N proxy allocations on
    every page load.

  3. sortRecipes deep-clones via JSON round-trip
    src/components/List/RecipeList.vue

    JSON.parse(JSON.stringify(recipes)) was called once per active sort
    key. sort() only needs a fresh array; it does not touch recipe
    objects. Replaced with recipes.slice(). Allocates one array instead
    of millions of strings + objects.

  4. <ul><li v-for> rendered N DOM nodes
    src/components/List/RecipeList.vue

    Replaced with <RecycleScroller> from vue-virtual-scroller@^1.1.2
    in page-mode + grid-items mode. Only the visible cells live in
    the DOM. gridItems is recomputed on window resize as
    floor((window.innerWidth − 300) / 332), where 300 is the rough
    width of the Nextcloud navigation pane and 332 is one card cell
    (300px card + 2×1rem margin). A new visibleRecipes computed feeds
    the scroller — it's the existing recipeObjects pipeline with hidden
    entries 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

  • New runtime dependency: vue-virtual-scroller@^1.1.2. The 1.x
    line is the Vue 2 release (2.x is Vue 3) and is actively maintained.
    Adds ~30 KB minified to the main bundle.
  • Visual regression in the grid: the previous CSS flex-wrap layout
    reflowed continuously as the viewport changed. The new grid uses a
    fixed integer column count recomputed only on resize. Acceptable in
    practice but it is a behavioural change.
  • Fixed item height (130px): card height is hard-coded in the
    scroller config. If a future RecipeCard variant grows vertically,
    :item-size must be bumped to match.
  • Sidebar width estimate: gridItems assumes ~300px for the NC
    navigation pane. A smarter implementation would observe the scroller
    container directly with ResizeObserver — happy to switch to that if
    you'd prefer it before merge.
  • No backend change: appinfo/routes.php, the PHP controllers and
    the REST API at /apps/cookbook/api/v1/* are untouched. Mobile
    cookbook clients (iOS / Android) and any third-party API consumer
    see an identical server.

Formal requirements

  • I did check that the app can still be opened and does not throw any browser logs
  • I created tests for newly added PHP code (no PHP changes were made)
  • I updated the OpenAPI specs and added an entry to the API changelog (API was not modified)
  • I notified the matrix channel if I introduced an API change (no API change)

Maxim Kiselev 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant