diff --git a/frontend/src/lib/api/endpoints/folders.bench.test.ts b/frontend/src/lib/api/endpoints/folders.bench.test.ts new file mode 100644 index 00000000..9df9d79a --- /dev/null +++ b/frontend/src/lib/api/endpoints/folders.bench.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() })); + +import { apiFetch } from '$lib/api/client'; +import type { FileItem, FolderItem, ItemType } from '$lib/api/types'; +import { fetchFolderListing, invalidateFolderCache, type FolderListing } from './folders'; + +/** + * Benchmark gate for the coalesced progressive-render emissions in + * {@link fetchFolderListing}. + * + * Audit finding: the loader invoked `onPage` after EVERY 200-item page with a + * fresh copy of the whole accumulated listing, and the files view re-derives + * its filtered + sorted view (two `localeCompare` sorts + entry rebuild) from + * each emission. For a folder of N items that is Σ page sizes ≈ O(N²/200) + * elements re-sorted on the main thread during a single load — hundreds of ms + * of jank on exactly the large folders progressive rendering was meant to + * help. The fix emits page one (first paint) and the final page always, and + * intermediate pages at most once per PAGE_EMIT_MIN_INTERVAL_MS. + * + * Gates: + * 1. Equivalence — final listing identical to the emit-every-page reference, + * first emission still after page one (first paint preserved), last + * emission still `done === true` with the complete listing. + * 2. Perf — on a fast connection (pages resolve in ≪150 ms) the consumer-side + * derive work collapses from 25 full re-sorts to ≤3; wall time of the + * load+derive cycle must drop accordingly (≥3x on the derive term). + */ + +type ResourceItem = { resource_type: ItemType; resource: { id: string; name: string } }; +type ResourcePage = { items?: ResourceItem[]; next_cursor?: string }; + +const PAGE_SIZE = 200; +const PAGES = 25; // 5 000-item folder + +/** Deterministic shuffled names so the consumer sort actually works. */ +function pageBody(page: number): ResourcePage { + const items: ResourceItem[] = []; + for (let i = 0; i < PAGE_SIZE; i++) { + const n = page * PAGE_SIZE + i; + const id = `f-${n.toString().padStart(5, '0')}`; + // Mix folders into the first page like a real listing (folders first). + const isFolder = page === 0 && i < 20; + items.push({ + resource_type: isFolder ? 'folder' : 'file', + resource: { id, name: `item ${((n * 7919) % 100000).toString().padStart(5, '0')}.txt` } + }); + } + return { items, next_cursor: page + 1 < PAGES ? `c${page + 1}` : undefined }; +} + +function fakeRes(body: ResourcePage): Response { + return { + status: 200, + ok: true, + json: async () => body, + headers: { get: () => null } + } as unknown as Response; +} + +function mockPagedFetch(): void { + let call = 0; + vi.mocked(apiFetch).mockImplementation(async () => fakeRes(pageBody(call++))); +} + +/** + * The pre-fix loader, verbatim shape: accumulate pages and emit a fresh copy + * of the whole accumulated listing after every page. + */ +async function referenceFetchFolderListing( + folderId: string, + onPage: (partial: FolderListing, done: boolean) => void +): Promise { + const folders: FolderItem[] = []; + const files: FileItem[] = []; + let cursor: string | undefined; + do { + const params = new URLSearchParams({ order_by: 'name', limit: '200' }); + if (cursor) params.set('cursor', cursor); + const res = await apiFetch(`/api/folders/${folderId}/resources?${params.toString()}`, { + credentials: 'same-origin', + cache: 'no-store' + }); + if (!res.ok) throw new Error(`listing failed: ${res.status}`); + const page = (await res.json()) as ResourcePage; + for (const it of page.items ?? []) { + if (it.resource_type === 'folder') folders.push(it.resource as FolderItem); + else files.push(it.resource as FileItem); + } + cursor = page.next_cursor; + onPage({ folders: [...folders], files: [...files], favoriteIds: [], sharedIds: [] }, !cursor); + } while (cursor); + return { folders, files, favoriteIds: [], sharedIds: [] }; +} + +/** + * The files view's per-emission derive chain, reduced to its dominant costs: + * dotfile filter pass + two localeCompare sorts + ordered-entry rebuild + * (`sortedFolders`/`sortedFiles`/`entries`/`orderedIds` in +page.svelte). + * Returns the number of elements that went through the sort — the O(N²) term. + */ +function consumerDerive(partial: FolderListing): number { + const visF = partial.folders.filter((f) => !f.name.startsWith('.')); + const visX = partial.files.filter((f) => !f.name.startsWith('.')); + const sortedF = [...visF].sort((a, b) => a.name.localeCompare(b.name)); + const sortedX = [...visX].sort((a, b) => a.name.localeCompare(b.name)); + const orderedIds = [...sortedF.map((f) => f.id), ...sortedX.map((f) => f.id)]; + return orderedIds.length; +} + +beforeEach(() => { + vi.clearAllMocks(); + invalidateFolderCache(); +}); + +describe('coalesced progressive listing emissions (benchmark gate)', () => { + it('final listing, first-paint page and done-flag match the emit-every-page reference', async () => { + mockPagedFetch(); + const refEmits: Array<{ n: number; done: boolean }> = []; + const refFinal = await referenceFetchFolderListing('bench', (p, done) => + refEmits.push({ n: p.folders.length + p.files.length, done }) + ); + + mockPagedFetch(); + const emits: Array<{ n: number; done: boolean; partial: FolderListing }> = []; + const r = await fetchFolderListing('bench', { + onPage: (partial, done) => + emits.push({ n: partial.folders.length + partial.files.length, done, partial }) + }); + + // Identical complete listing. + expect(r.listing).toEqual(refFinal); + // First paint unchanged: the first emission is still page one. + expect(emits[0].n).toBe(refEmits[0].n); + expect(emits[0].n).toBe(PAGE_SIZE); + // Exactly one done emission, last, carrying the full listing — as before. + expect(emits.filter((e) => e.done).length).toBe(1); + expect(emits[emits.length - 1].done).toBe(true); + expect(emits[emits.length - 1].n).toBe(PAGES * PAGE_SIZE); + expect(refEmits[refEmits.length - 1].done).toBe(true); + // Emissions are a subset of what the reference produced (never more). + expect(emits.length).toBeLessThanOrEqual(refEmits.length); + // Every emitted partial is a prefix-accumulation (monotone growth). + for (let i = 1; i < emits.length; i++) expect(emits[i].n).toBeGreaterThan(emits[i - 1].n); + }); + + it('single-page folders still emit exactly once, done=true (fast path untouched)', async () => { + vi.mocked(apiFetch).mockResolvedValue( + fakeRes({ items: pageBody(PAGES - 1).items }) // no next_cursor + ); + const emits: boolean[] = []; + await fetchFolderListing('one', { onPage: (_p, done) => emits.push(done) }); + expect(emits).toEqual([true]); + }); + + it( + `collapses the O(N²) consumer re-derive on a fast ${PAGES}-page load (perf gate)`, + { timeout: 30_000 }, + async () => { + // Warm-up both paths (JIT tiering outside the measured windows). + mockPagedFetch(); + await referenceFetchFolderListing('warm', (p) => consumerDerive(p)); + mockPagedFetch(); + await fetchFolderListing('warm', { onPage: (p) => consumerDerive(p) }); + + mockPagedFetch(); + let refSorted = 0; + let refEmits = 0; + const t0 = performance.now(); + await referenceFetchFolderListing('bench', (p) => { + refEmits++; + refSorted += consumerDerive(p); + }); + const refMs = performance.now() - t0; + + mockPagedFetch(); + let sorted = 0; + let emitsN = 0; + const t1 = performance.now(); + await fetchFolderListing('bench', { + onPage: (p) => { + emitsN++; + sorted += consumerDerive(p); + } + }); + const ms = performance.now() - t1; + + console.info( + `progressive load ${PAGES}×${PAGE_SIZE}: before ${refEmits} emissions / ${refSorted} sorted elements / ${refMs.toFixed(1)} ms — after ${emitsN} emissions / ${sorted} sorted elements / ${ms.toFixed(1)} ms (${(refMs / ms).toFixed(1)}x wall, ${(refSorted / sorted).toFixed(1)}x fewer sorted elements)` + ); + + // The reference re-derived every page: Σ = P(P+1)/2 pages of elements. + expect(refEmits).toBe(PAGES); + expect(refSorted).toBe((PAGES * (PAGES + 1) * PAGE_SIZE) / 2); + // Coalesced: page 1 + final (+ occasionally one mid emission if the + // stubbed pages ever take >150 ms — they don't on any healthy runner). + expect(emitsN).toBeLessThanOrEqual(3); + // ≥5x less consumer sort work is the point of the change. + expect(sorted).toBeLessThan(refSorted / 5); + // And it must show up as wall time on the combined load+derive cycle. + expect(ms).toBeLessThan(refMs / 3); + } + ); +}); diff --git a/frontend/src/lib/api/endpoints/folders.ts b/frontend/src/lib/api/endpoints/folders.ts index b88af15c..965e272c 100644 --- a/frontend/src/lib/api/endpoints/folders.ts +++ b/frontend/src/lib/api/endpoints/folders.ts @@ -94,6 +94,17 @@ export async function getFolder(id: string): Promise { return folder; } +/** + * Minimum spacing between intermediate progressive-render emissions of + * {@link fetchFolderListing}. Each emission hands the consumer the WHOLE + * accumulated listing, and the files view re-derives its filtered + sorted + * view from it (O(accumulated · log) with `localeCompare`), so emitting every + * page made a large-folder load Σ O(N²/page) of main-thread sort work. Page + * one and the final page always emit; pages in between only emit after this + * much time has passed since the previous emission. + */ +export const PAGE_EMIT_MIN_INTERVAL_MS = 150; + /** * Fetch a folder's complete listing (sub-folders + files), rebuilt from the * cursor-paginated `/api/folders/{id}/resources` feed — the old combined @@ -112,12 +123,15 @@ export async function fetchFolderListing( etag?: string; forceRefresh?: boolean; /** - * Progressive render hook: invoked after EVERY page with the - * accumulated listing so far (the arrays are fresh copies — safe to - * hand to reactive state). Without it, a 2,000-item folder waited - * for all ⌈N/200⌉ sequential round-trips before the first row - * painted; with it the view paints after page one (~200 items) and - * fills in as the tail pages land. + * Progressive render hook: invoked with the accumulated listing so + * far (the arrays are fresh copies — safe to hand to reactive + * state). Without it, a 2,000-item folder waited for all ⌈N/200⌉ + * sequential round-trips before the first row painted; with it the + * view paints after page one (~200 items) and fills in as the tail + * pages land. Emissions are coalesced to at most one per + * {@link PAGE_EMIT_MIN_INTERVAL_MS} between the first and the final + * page — the hook is always called for page one and always called + * once more with `done === true` and the complete listing. */ onPage?: (partial: FolderListing, done: boolean) => void; } = {} @@ -125,6 +139,8 @@ export async function fetchFolderListing( const folders: FolderItem[] = []; const files: FileItem[] = []; let cursor: string | undefined; + let firstPage = true; + let lastEmit = 0; do { const params = new URLSearchParams({ order_by: 'name', limit: '200' }); if (opts.forceRefresh) params.set('force_refresh', 'true'); @@ -144,10 +160,18 @@ export async function fetchFolderListing( else files.push(it.resource as FileItem); } cursor = page.next_cursor; - opts.onPage?.( - { folders: [...folders], files: [...files], favoriteIds: [], sharedIds: [] }, - !cursor - ); + const done = !cursor; + if ( + opts.onPage && + (done || firstPage || performance.now() - lastEmit >= PAGE_EMIT_MIN_INTERVAL_MS) + ) { + lastEmit = performance.now(); + opts.onPage( + { folders: [...folders], files: [...files], favoriteIds: [], sharedIds: [] }, + done + ); + } + firstPage = false; } while (cursor); return { status: 200, listing: { folders, files, favoriteIds: [], sharedIds: [] } }; diff --git a/frontend/src/lib/composables/selectionBench.svelte.ts b/frontend/src/lib/composables/selectionBench.svelte.ts new file mode 100644 index 00000000..29f60d83 --- /dev/null +++ b/frontend/src/lib/composables/selectionBench.svelte.ts @@ -0,0 +1,85 @@ +/** + * Bench harness for the selection/badge-set reactivity patterns compared in + * `selectionPatterns.bench.test.ts` (runes only compile in `.svelte.ts` + * modules, so the models live here; the app never imports this file — it is + * test-only and tree-shaken from the bundle). + * + * `copyReassignModel` is the pre-fix files-view pattern, verbatim: a + * `$state` where every toggle copies the whole set into a fresh + * `SvelteSet` and reassigns. `inPlaceModel` is the post-fix pattern: one + * `SvelteSet` mutated in place. + */ +import { flushSync } from 'svelte'; +import { SvelteSet } from 'svelte/reactivity'; + +export interface SelectionModel { + has(id: string): boolean; + toggle(id: string): void; + seed(ids: Iterable): void; + readonly size: number; +} + +/** Pre-fix pattern (files view `toggleSelected`, verbatim copy-and-reassign). */ +export function copyReassignModel(): SelectionModel { + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- BEFORE arm replicates the pre-fix plain-Set pattern verbatim + let selected = $state>(new Set()); + return { + has: (id) => selected.has(id), + toggle(id) { + const next = new SvelteSet(selected); + if (next.has(id)) next.delete(id); + else next.add(id); + selected = next; + }, + seed(ids) { + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- BEFORE arm replicates the pre-fix plain-Set pattern verbatim + selected = new Set(ids); + }, + get size() { + return selected.size; + } + }; +} + +/** Post-fix pattern: one live `SvelteSet` mutated in place (per-key sources + * for present keys; absent-key reads track the version signal). */ +export function inPlaceModel(): SelectionModel { + const selected = new SvelteSet(); + return { + has: (id) => selected.has(id), + toggle(id) { + if (selected.has(id)) selected.delete(id); + else selected.add(id); + }, + seed(ids) { + selected.clear(); + for (const id of ids) selected.add(id); + }, + get size() { + return selected.size; + } + }; +} + +/** + * Mount one effect per row reading `model.has(rowId)` — the shape of a row's + * checkbox/star binding — run `mutate`, and report how many row effects re-ran + * (the invalidation fan-out of the mutation). + */ +export function measureFanout(model: SelectionModel, rowIds: string[], mutate: () => void): number { + let runs = 0; + const destroy = $effect.root(() => { + for (const id of rowIds) { + $effect(() => { + void model.has(id); + runs += 1; + }); + } + }); + flushSync(); // initial run of every row effect + const baseline = runs; + mutate(); + flushSync(); + destroy(); + return runs - baseline; +} diff --git a/frontend/src/lib/composables/selectionPatterns.bench.test.ts b/frontend/src/lib/composables/selectionPatterns.bench.test.ts new file mode 100644 index 00000000..7e32c038 --- /dev/null +++ b/frontend/src/lib/composables/selectionPatterns.bench.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { + copyReassignModel, + inPlaceModel, + measureFanout, + type SelectionModel +} from './selectionBench.svelte'; + +/** + * Benchmark gate for the in-place `SvelteSet` selection/badge sets in the + * files and recent views. + * + * Audit finding: `selected`, `favoriteIds` and `sharedIds` were plain + * `$state`s rebuilt from a full copy on every single-item toggle + * (`new SvelteSet(selected)` + reassign). That costs (a) an O(N) copy per + * toggle — N unbounded under "select all → refine" — and (b) reassigning the + * state reference invalidates EVERY mounted row's `.has(id)` read, so the + * whole viewport re-renders for a one-row change. The fix keeps one + * `SvelteSet` per set and mutates it in place; `SvelteSet` tracks per-key, so + * a toggle re-runs only the toggled row's readers. The composable + * `useSelection` already shipped this pattern — the views now match it. + * + * `SvelteSet` granularity (svelte/src/reactivity/set.js): present keys get a + * per-key source; `.has()` on an ABSENT key tracks the set's version signal + * ("don't create sources willy-nilly"), so miss-readers re-run on any + * mutation in both patterns. The in-place win is therefore: no O(N) copy, and + * every OTHER present-key reader is spared — copy-reassign re-runs all rows. + * + * Gates: (1) both patterns agree on membership across a deterministic toggle + * script; (2) fan-out under 40 mounted row-effects matches those exact + * semantics (misses+1 in place vs all 40 copied — 3 vs 40 when the list is + * mostly selected, the "select all → refine" case); (3) 1 000 toggles over a + * 5 000-id selection run ≥5x faster in place. + */ + +/** Deterministic PRNG so both models replay the identical script. */ +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const ids = (n: number): string[] => Array.from({ length: n }, (_, i) => `id-${i}`); + +describe('in-place SvelteSet selection (benchmark gate)', () => { + it('membership after a 500-op toggle script is identical in both patterns', () => { + const universe = ids(1_000); + const a = copyReassignModel(); + const b = inPlaceModel(); + a.seed(universe.slice(0, 100)); + b.seed(universe.slice(0, 100)); + + const rand = mulberry32(0xc0ffee); + for (let i = 0; i < 500; i++) { + const id = universe[Math.floor(rand() * universe.length)]; + a.toggle(id); + b.toggle(id); + } + expect(a.size).toBe(b.size); + for (const id of universe) { + expect(b.has(id), id).toBe(a.has(id)); + } + }); + + it('fan-out of one toggle across 40 mounted rows matches per-key semantics', () => { + const rows = ids(40); + const scenario = (seeded: number): { copy: number; inplace: number } => { + const copy = copyReassignModel(); + copy.seed(rows.slice(0, seeded)); + const copyFanout = measureFanout(copy, rows, () => copy.toggle('id-7')); + + const inplace = inPlaceModel(); + inplace.seed(rows.slice(0, seeded)); + const inplaceFanout = measureFanout(inplace, rows, () => inplace.toggle('id-7')); + return { copy: copyFanout, inplace: inplaceFanout }; + }; + + // 10/40 selected (sparse selection): misses (30) + the toggled row. + const sparse = scenario(10); + // 38/40 selected ("select all → refine"): misses (2) + the toggled row. + const dense = scenario(38); + + console.info( + `fan-out of 1 toggle across 40 row effects — 10/40 selected: copy ${sparse.copy} vs in-place ${sparse.inplace}; 38/40 selected: copy ${dense.copy} vs in-place ${dense.inplace}` + ); + // Copy-reassign invalidates every row that reads `.has` on the state. + expect(sparse.copy).toBeGreaterThanOrEqual(rows.length); + expect(dense.copy).toBeGreaterThanOrEqual(rows.length); + // In place: absent-key readers track the version signal (SvelteSet + // design), present-key readers other than the toggled row are spared. + expect(sparse.inplace).toBe(40 - 10 + 1); + expect(dense.inplace).toBe(40 - 38 + 1); + // The refine-after-select-all case is where the win is decisive. + expect(dense.inplace).toBeLessThan(dense.copy / 10); + }); + + it('1 000 toggles over a 5 000-id selection are ≥5x faster in place (perf gate)', () => { + const N = 5_000; + const TOGGLES = 1_000; + const universe = ids(N); + + const run = (model: SelectionModel): number => { + model.seed(universe); + const rand = mulberry32(0xbeef); + const t0 = performance.now(); + for (let i = 0; i < TOGGLES; i++) { + model.toggle(universe[Math.floor(rand() * N)]); + } + return performance.now() - t0; + }; + + // Warm-up (JIT) then measure. + run(copyReassignModel()); + run(inPlaceModel()); + const copyMs = run(copyReassignModel()); + const inplaceMs = run(inPlaceModel()); + + console.info( + `${TOGGLES} toggles @ N=${N}: copy-reassign ${copyMs.toFixed(1)} ms vs in-place ${inplaceMs.toFixed(1)} ms (${(copyMs / inplaceMs).toFixed(1)}x)` + ); + expect(inplaceMs).toBeLessThan(copyMs / 5); + }); +}); diff --git a/frontend/src/lib/i18n/i18n.bench.test.ts b/frontend/src/lib/i18n/i18n.bench.test.ts new file mode 100644 index 00000000..e22c3f67 --- /dev/null +++ b/frontend/src/lib/i18n/i18n.bench.test.ts @@ -0,0 +1,167 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { getNestedValue, interpolate } from './index.svelte'; + +/** + * Benchmark gate for the `t()` hot path: the split-path cache in + * `getNestedValue` and the `{{` guard in `interpolate`. + * + * Audit finding: the locale dicts are nested, so every `t('a.b.c')` call + * re-split its key into a fresh array and walked the tree, and `interpolate` + * ran its global-regex `.replace` scan even though the vast majority of UI + * strings carry no `{{placeholder}}`. A rendered list row calls `t()` ~10×, + * so a 40-row paint pays ~400 walk+split-allocs + regex scans. The fix + * caches the resolved value per (dict, key) — dicts are load-once-immutable + * and the key set is the app's finite static strings — and skips the regex + * when the string has no `{{`. + * + * Gates: byte-identical results vs the pre-fix reference implementations + * across the real shipped en.json (nested keys, flat keys, underscore + * fallback, missing keys, placeholder strings — cold AND warm, so a stale or + * poisoned cache entry fails loudly), and a ≥1.5x speedup on a mixed + * 20k-call workload. + */ + +type Dict = { [key: string]: string | Dict }; + +const enDict = JSON.parse( + readFileSync(resolve(__dirname, '../../../static/locales/en.json'), 'utf8') +) as Dict; + +/** Pre-fix `getNestedValue`, verbatim: fresh `split('.')` on every call. */ +function referenceGetNestedValue(obj: Dict | undefined, path: string): string | null { + if (obj && typeof obj === 'object' && path in obj) { + const value = obj[path]; + return typeof value === 'string' ? value : null; + } + const keys = path.split('.'); + let current: unknown = obj; + for (const key of keys) { + if (current && typeof current === 'object' && key in (current as Dict)) { + current = (current as Dict)[key]; + } else { + if (path.includes('_') && !path.includes('.')) { + const [prefix, ...parts] = path.split('_'); + const suffix = parts.join('_'); + const branch = obj?.[prefix]; + if (branch && typeof branch === 'object' && suffix in (branch as Dict)) { + const v = (branch as Dict)[suffix]; + return typeof v === 'string' ? v : null; + } + } + return null; + } + } + return typeof current === 'string' ? current : null; +} + +/** Pre-fix `interpolate`, verbatim: unconditional regex `.replace`. */ +function referenceInterpolate(text: string, params: Record): string { + return text.replace(/{{\s*([^}]+)\s*}}/g, (_, key: string) => { + const k = key.trim(); + return params[k] !== undefined ? String(params[k]) : `{{${key}}}`; + }); +} + +/** Every dotted leaf path in the dict (the app's real key population). */ +function collectKeys(obj: Dict, prefix = '', out: string[] = []): string[] { + for (const [k, v] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${k}` : k; + if (typeof v === 'string') out.push(path); + else collectKeys(v, path, out); + } + return out; +} + +const allKeys = collectKeys(enDict); +// A workload mix mirroring real renders: mostly present nested keys, plus +// underscore-fallback forms, flat keys, and misses. +const workload: string[] = [ + ...allKeys, + 'errors_loadFailed', // underscore fallback form + 'groupby_modifiedAt', + 'nav.files', + 'this.key.does.not.exist', + 'nokey', + 'files.deeply.missing.leaf' +]; + +const PARAMS = { n: 42, count: 7, email: 'x@y.z', name: 'Ada' }; + +describe('t() hot path: split cache + interpolate guard (benchmark gate)', () => { + it('getNestedValue is byte-identical to the split-per-call reference on every real key', () => { + expect(allKeys.length).toBeGreaterThan(300); + for (const key of workload) { + expect(getNestedValue(enDict, key), key).toBe(referenceGetNestedValue(enDict, key)); + } + // Repeat with the cache warm — a poisoned/shared split array would show here. + for (const key of workload) { + expect(getNestedValue(enDict, key), `warm:${key}`).toBe(referenceGetNestedValue(enDict, key)); + } + }); + + it('interpolate is byte-identical to the unguarded reference', () => { + const texts = [ + // Keys whose segments contain literal dots aren't resolvable via a + // dotted path — drop the nulls (both implementations agree on them, + // covered by the lookup-equivalence test above). + ...allKeys + .map((k) => referenceGetNestedValue(enDict, k)) + .filter((v): v is string => v !== null), + 'Move {{n}} items to trash?', + '{{ n }} spaced', // padded placeholder + '{{unknown}} stays intact', + 'no placeholders at all', + 'brace but not double { x }', + '{{n}}{{count}}back-to-back', + '' + ]; + let withPlaceholders = 0; + for (const text of texts) { + if (text.includes('{{')) withPlaceholders++; + expect(interpolate(text, PARAMS), JSON.stringify(text)).toBe( + referenceInterpolate(text, PARAMS) + ); + expect(interpolate(text, {}), `noparams:${JSON.stringify(text)}`).toBe( + referenceInterpolate(text, {}) + ); + } + // The workload genuinely exercises both branches of the guard. + expect(withPlaceholders).toBeGreaterThan(50); + expect(withPlaceholders).toBeLessThan(texts.length / 2); + }); + + it('20k mixed lookups+interpolations run ≥1.5x faster (perf gate)', { timeout: 30_000 }, () => { + const N = 20_000; + // The t() body for a hit: nested lookup then interpolate the result. + const after = (key: string): string => { + const v = getNestedValue(enDict, key); + return v === null ? key : interpolate(v, PARAMS); + }; + const before = (key: string): string => { + const v = referenceGetNestedValue(enDict, key); + return v === null ? key : referenceInterpolate(v, PARAMS); + }; + + let sink = 0; + for (let i = 0; i < 2_000; i++) { + sink += after(workload[i % workload.length]).length; + sink += before(workload[i % workload.length]).length; + } + + const t0 = performance.now(); + for (let i = 0; i < N; i++) sink += after(workload[i % workload.length]).length; + const afterMs = performance.now() - t0; + + const t1 = performance.now(); + for (let i = 0; i < N; i++) sink += before(workload[i % workload.length]).length; + const beforeMs = performance.now() - t1; + + expect(sink).toBeGreaterThan(0); + console.info( + `t() hot path x ${N}: cached+guarded ${afterMs.toFixed(1)} ms vs split+regex-per-call ${beforeMs.toFixed(1)} ms (${(beforeMs / afterMs).toFixed(2)}x)` + ); + expect(afterMs).toBeLessThan(beforeMs / 1.5); + }); +}); diff --git a/frontend/src/lib/i18n/index.svelte.ts b/frontend/src/lib/i18n/index.svelte.ts index f386cb19..52fd2e75 100644 --- a/frontend/src/lib/i18n/index.svelte.ts +++ b/frontend/src/lib/i18n/index.svelte.ts @@ -116,8 +116,33 @@ export function resolveBrowserLocale( return 'en'; } +// Resolved-value cache, one map per dict object: `t()` runs ~10× per rendered +// list row over the app's finite static key set, so the nested split + tree +// walk runs once per (locale, key) instead of on every call. Dicts are +// assigned once in `loadDict` and never mutated, so entries can't go stale; +// the cap only guards against a pathological dynamic-key caller. +const RESOLVED_CACHE_MAX = 4000; +const resolvedCache = new WeakMap>(); + /** Resolve a dot-notation key with a prefix_suffix underscore fallback. */ export function getNestedValue(obj: Dict | undefined, path: string): string | null { + if (!obj || typeof obj !== 'object') return resolveNestedValue(obj, path); + let cache = resolvedCache.get(obj); + if (cache === undefined) { + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- deliberately non-reactive: a memo written during render must not create/notify signals + cache = new Map(); + resolvedCache.set(obj, cache); + } + const hit = cache.get(path); + if (hit !== undefined) return hit; + const value = resolveNestedValue(obj, path); + if (cache.size >= RESOLVED_CACHE_MAX) cache.clear(); + cache.set(path, value); + return value; +} + +/** The uncached lookup: flat-key fast path, dotted walk, underscore fallback. */ +function resolveNestedValue(obj: Dict | undefined, path: string): string | null { if (obj && typeof obj === 'object' && path in obj) { const value = obj[path]; return typeof value === 'string' ? value : null; @@ -146,6 +171,9 @@ export function getNestedValue(obj: Dict | undefined, path: string): string | nu /** Replace `{{param}}` placeholders; leaves unknown placeholders intact. */ export function interpolate(text: string, params: Record): string { + // The vast majority of UI strings carry no placeholder — skip the regex + // scan (and its per-call machinery) for them. + if (!text.includes('{{')) return text; return text.replace(/{{\s*([^}]+)\s*}}/g, (_, key: string) => { const k = key.trim(); return params[k] !== undefined ? String(params[k]) : `{{${key}}}`; diff --git a/frontend/src/lib/utils/sets.ts b/frontend/src/lib/utils/sets.ts new file mode 100644 index 00000000..590a0368 --- /dev/null +++ b/frontend/src/lib/utils/sets.ts @@ -0,0 +1,9 @@ +/** + * Replace a live `Set`'s contents in place. For a reactive `SvelteSet` this + * keeps the same instance (per-key reactivity intact) instead of allocating a + * fresh copy and invalidating every `.has()` reader at once. + */ +export function replaceSet(set: Set, values: Iterable): void { + set.clear(); + for (const v of values) set.add(v); +} diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 51ce1d5a..444384dd 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -62,6 +62,7 @@ typeLabel } from '$lib/stores/files.svelte'; import { formatBytes } from '$lib/utils/format'; + import { replaceSet } from '$lib/utils/sets'; import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display'; import { gridColumns } from '$lib/utils/grid'; import { @@ -166,8 +167,11 @@ // Favorite + shared badge sets for the current folder, seeded directly from // the listing response (server-computed, scoped to these items — no extra // per-navigation fetch) and updated optimistically on mutation. - let favoriteIds = $state>(new Set()); - let sharedIds = $state>(new Set()); + // `SvelteSet` mutated in place: a toggle costs O(1) instead of copying + // the whole set, and every other present-key `.has()` reader is spared + // (measured in selectionPatterns.bench.test.ts). + const favoriteIds = new SvelteSet(); + const sharedIds = new SvelteSet(); function openMove(kind: ItemType, id: string, name: string) { actionTarget = { id, name, kind }; @@ -189,19 +193,15 @@ async function toggleFavorite(kind: ItemType, id: string) { const isFav = favoriteIds.has(id); // Optimistic toggle, reverted on failure. - const next = new SvelteSet(favoriteIds); - if (isFav) next.delete(id); - else next.add(id); - favoriteIds = next; + if (isFav) favoriteIds.delete(id); + else favoriteIds.add(id); try { if (isFav) await removeFavorite(kind, id); else await addFavorite(kind, id); } catch (e) { errorToast(e); - const reverted = new SvelteSet(favoriteIds); - if (isFav) reverted.add(id); - else reverted.delete(id); - favoriteIds = reverted; + if (isFav) favoriteIds.add(id); + else favoriteIds.delete(id); } } @@ -229,8 +229,8 @@ function applyListing(data: FolderListing) { listing = data; - favoriteIds = new Set(data.favoriteIds); - sharedIds = new Set(data.sharedIds); + replaceSet(favoriteIds, data.favoriteIds); + replaceSet(sharedIds, data.sharedIds); } async function load() { @@ -881,19 +881,20 @@ } // ── Multi-select + batch ──────────────────────────────────────────────── - let selected = $state>(new Set()); + // In-place `SvelteSet`: a toggle is O(1) (no full-set copy) and spares + // the other selected rows' `has()` readers — decisive when refining a + // select-all (selectionPatterns.bench.test.ts). + const selected = new SvelteSet(); // Anchor row id for shift-click range selection. let selectionAnchor = $state(null); function toggleSelected(id: string) { - const next = new SvelteSet(selected); - if (next.has(id)) next.delete(id); - else next.add(id); - selected = next; + if (selected.has(id)) selected.delete(id); + else selected.add(id); selectionAnchor = id; } function clearSelection() { - selected = new Set(); + selected.clear(); selectionAnchor = null; } @@ -911,7 +912,7 @@ const b = orderedIds.indexOf(id); if (a !== -1 && b !== -1) { const [lo, hi] = a < b ? [a, b] : [b, a]; - selected = new Set([...selected, ...orderedIds.slice(lo, hi + 1)]); + for (let i = lo; i <= hi; i++) selected.add(orderedIds[i]); } return true; } @@ -927,11 +928,16 @@ const totalCount = $derived(visibleFolders.length + visibleFiles.length); function toggleSelectAll() { - if (selected.size === totalCount) clearSelection(); - // Select-all only picks what the user can see — dotfiles hidden - // by the current filter are excluded so "select all → delete" - // can't accidentally sweep up hidden files the user never saw. - else selected = new Set([...visibleFolders, ...visibleFiles].map((i) => i.id)); + if (selected.size === totalCount) { + clearSelection(); + } else { + // Select-all only picks what the user can see — dotfiles hidden + // by the current filter are excluded so "select all → delete" + // can't accidentally sweep up hidden files the user never saw. + selected.clear(); + for (const i of visibleFolders) selected.add(i.id); + for (const i of visibleFiles) selected.add(i.id); + } } /** @@ -948,9 +954,12 @@ async function batchDownload() { const fileIds: string[] = []; const folderIds: string[] = []; + // One O(M) pass over the listing instead of an O(N·M) `some` per id. + const folderIdSet = new Set(listing.folders.map((f) => f.id)); + const fileIdSet = new Set(listing.files.map((f) => f.id)); for (const id of selected) { - if (listing.folders.some((f) => f.id === id)) folderIds.push(id); - else if (listing.files.some((f) => f.id === id)) fileIds.push(id); + if (folderIdSet.has(id)) folderIds.push(id); + else if (fileIdSet.has(id)) fileIds.push(id); } if (fileIds.length === 0 && folderIds.length === 0) return; @@ -1009,7 +1018,7 @@ }) }); if (!res.ok) throw new Error(`Server returned ${res.status}`); - favoriteIds = new Set([...favoriteIds, ...items.map((it) => it.id)]); + for (const it of items) favoriteIds.add(it.id); ui.notify(t('files.added_favorites', 'Added to favorites'), 'success'); clearSelection(); } catch (e) { @@ -1018,13 +1027,14 @@ } function selectionTargets(): ActionTarget[] { + // One O(M) index build instead of an O(N·M) `find` per selected id. + // Folders win id collisions, matching the old folder-first probe. + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- ephemeral local index, discarded before any reactive read + const byId = new Map(); + for (const f of listing.files) byId.set(f.id, { id: f.id, name: f.name, kind: 'file' }); + for (const f of listing.folders) byId.set(f.id, { id: f.id, name: f.name, kind: 'folder' }); return [...selected] - .map((id) => { - const folder = listing.folders.find((f) => f.id === id); - if (folder) return { id, name: folder.name, kind: 'folder' as ItemType }; - const file = listing.files.find((f) => f.id === id); - return file ? { id, name: file.name, kind: 'file' as ItemType } : null; - }) + .map((id) => byId.get(id) ?? null) .filter((x): x is ActionTarget => x !== null); } @@ -1070,15 +1080,19 @@ danger: true }); if (!ok) return; - for (const id of ids) { - const folder = listing.folders.find((f) => f.id === id); + // Bounded fan-out instead of a serial await per item: 100 deletes at + // ~30 ms RTT collapse from ~3 s of waterfall to a few round-trip + // windows. Failures toast individually and the rest still proceed, + // exactly like the old serial loop. + const folderIdSet = new Set(listing.folders.map((f) => f.id)); + await mapLimit(ids, 6, async (id) => { try { - if (folder) await deleteFolder(id); + if (folderIdSet.has(id)) await deleteFolder(id); else await deleteFile(id); } catch (e) { errorToast(e); } - } + }); clearSelection(); await reload(); void session.refresh(); @@ -1185,16 +1199,26 @@ async function moveInto(targetFolderId: string, e: DragEvent) { const items = dragPayload(e).filter((it) => it.id !== targetFolderId); if (items.length === 0) return; - try { - for (const it of items) { - if (it.kind === 'file') await moveFile(it.id, targetFolderId); - else await moveFolder(it.id, targetFolderId); - } - clearSelection(); - await reload(); - } catch (err) { - errorToast(err); + // Bounded fan-out (was a serial await per item). Every item is + // attempted; on any failure the first error is surfaced and the + // selection is kept so the drop can be retried, like the old loop. + const failures = ( + await mapLimit(items, 6, async (it) => { + try { + if (it.kind === 'file') await moveFile(it.id, targetFolderId); + else await moveFolder(it.id, targetFolderId); + return null; + } catch (err) { + return err ?? new Error('move failed'); + } + }) + ).filter((err) => err !== null); + if (failures.length > 0) { + errorToast(failures[0]); + return; } + clearSelection(); + await reload(); } function onFolderDrop(e: DragEvent, folder: FolderItem) { @@ -2244,11 +2268,7 @@ {/if} {#if shareDialog.component} {@const ShareDialog = shareDialog.component} - (sharedIds = new SvelteSet(sharedIds).add(id))} - /> + sharedIds.add(id)} /> {/if} {#if fileViewer.component} {@const FileViewer = fileViewer.component} diff --git a/frontend/src/routes/files/batchOps.bench.test.ts b/frontend/src/routes/files/batchOps.bench.test.ts new file mode 100644 index 00000000..3e80d967 --- /dev/null +++ b/frontend/src/routes/files/batchOps.bench.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; + +/** + * Benchmark gate for the files view's batch-operation rework + * (`batchDelete` / `moveInto` / `selectionTargets` / `batchDownload` in + * `[...path]/+page.svelte`). + * + * Audit finding: multi-item delete/move awaited one request per item in a + * serial loop — at ~30 ms RTT a 100-item delete is ~3 s of waterfall — and + * every per-id classification ran `listing.folders.find(...)` / + * `listing.files.some(...)`, an O(N·M) scan over the listing per selected id. + * The fix builds an id index once (O(M)) and fans the requests out through + * the view's existing `mapLimit` with 6 in flight. + * + * The functions are component-internal, so — like the Rust bench modules that + * replicate handler internals — this bench replicates BEFORE verbatim and + * AFTER (index + `mapLimit`, the exact shapes now in the component) against a + * stubbed per-item endpoint with simulated latency. + * + * Gates: (1) both arms attempt the identical (id, kind) operation set — + * folder-first classification preserved; (2) a 100-item batch at 5 ms + * simulated RTT completes ≥3x faster; (3) the classification scan count + * drops from O(N·M) to one pass. + */ + +const M = 2_000; // listing size +const N = 100; // selection size +const RTT_MS = 5; + +const listing = { + folders: Array.from({ length: M / 4 }, (_, i) => ({ id: `d-${i}`, name: `dir ${i}` })), + files: Array.from({ length: (3 * M) / 4 }, (_, i) => ({ id: `f-${i}`, name: `file ${i}` })) +}; +// Selection interleaves folders and files, like a shift-range over a mixed view. +const selectedIds = [ + ...listing.folders.slice(40, 40 + N / 4).map((f) => f.id), + ...listing.files.slice(900, 900 + (3 * N) / 4).map((f) => f.id) +]; + +/** Stubbed per-item endpoint: RTT_MS latency, records the attempted op. */ +function makeOps() { + const attempted: Array<{ id: string; kind: 'file' | 'folder' }> = []; + let comparisons = 0; + return { + attempted, + countCmp: () => comparisons++, + get comparisons() { + return comparisons; + }, + deleteFolder: async (id: string) => { + attempted.push({ id, kind: 'folder' }); + await new Promise((r) => setTimeout(r, RTT_MS)); + }, + deleteFile: async (id: string) => { + attempted.push({ id, kind: 'file' }); + await new Promise((r) => setTimeout(r, RTT_MS)); + } + }; +} +type Ops = ReturnType; + +/** BEFORE, verbatim shape: serial await + `find` per id. */ +async function batchDeleteBefore(ids: string[], ops: Ops): Promise { + for (const id of ids) { + const folder = listing.folders.find((f) => { + ops.countCmp(); + return f.id === id; + }); + if (folder) await ops.deleteFolder(id); + else await ops.deleteFile(id); + } +} + +/** The view's `mapLimit`, verbatim. */ +async function mapLimit( + items: T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const out = new Array(items.length); + let next = 0; + const worker = async () => { + while (next < items.length) { + const i = next++; + out[i] = await fn(items[i]); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return out; +} + +/** AFTER, verbatim shape: one O(M) index pass + bounded fan-out of 6. */ +async function batchDeleteAfter(ids: string[], ops: Ops): Promise { + const folderIdSet = new Set( + listing.folders.map((f) => { + ops.countCmp(); + return f.id; + }) + ); + await mapLimit(ids, 6, async (id) => { + if (folderIdSet.has(id)) await ops.deleteFolder(id); + else await ops.deleteFile(id); + }); +} + +const opKey = (o: { id: string; kind: string }) => `${o.kind}:${o.id}`; + +describe('files-view batch operations (benchmark gate)', () => { + it( + 'both arms attempt the identical operation set, ≥3x faster fanned out', + { timeout: 30_000 }, + async () => { + const before = makeOps(); + const t0 = performance.now(); + await batchDeleteBefore(selectedIds, before); + const beforeMs = performance.now() - t0; + + const after = makeOps(); + const t1 = performance.now(); + await batchDeleteAfter(selectedIds, after); + const afterMs = performance.now() - t1; + + // Equivalence: same ops, same folder/file classification. Order is + // not part of the contract (the ops are independent single-item + // endpoints); compare as sets and sizes. + expect(after.attempted.length).toBe(before.attempted.length); + expect(new Set(after.attempted.map(opKey))).toEqual(new Set(before.attempted.map(opKey))); + expect(before.attempted.filter((o) => o.kind === 'folder').length).toBe(N / 4); + + // Scan work: O(N·M) probes collapse to one O(M) pass. + expect(after.comparisons).toBe(listing.folders.length); + expect(before.comparisons).toBeGreaterThan(after.comparisons * 10); + + console.info( + `batch delete ${N} items @ ${RTT_MS} ms RTT: serial ${beforeMs.toFixed(0)} ms (${before.comparisons} id probes) vs mapLimit(6) ${afterMs.toFixed(0)} ms (${after.comparisons} probes) — ${(beforeMs / afterMs).toFixed(1)}x` + ); + expect(afterMs).toBeLessThan(beforeMs / 3); + } + ); + + it('selectionTargets index matches the per-id find, folder-first on collision', () => { + // BEFORE: folder probed first per id. AFTER: files inserted first so + // folders overwrite → folder wins collisions. Same observable result. + const shadow = { id: listing.files[0].id, name: 'shadow-folder' }; + const foldersPlus = [...listing.folders, shadow]; + const wanted = [shadow.id, listing.folders[5].id, listing.files[10].id, 'missing-id']; + + const beforeTargets = wanted + .map((id) => { + const folder = foldersPlus.find((f) => f.id === id); + if (folder) return { id, name: folder.name, kind: 'folder' as const }; + const file = listing.files.find((f) => f.id === id); + return file ? { id, name: file.name, kind: 'file' as const } : null; + }) + .filter((x): x is NonNullable => x !== null); + + const byId = new Map(); + for (const f of listing.files) byId.set(f.id, { id: f.id, name: f.name, kind: 'file' }); + for (const f of foldersPlus) byId.set(f.id, { id: f.id, name: f.name, kind: 'folder' }); + const afterTargets = wanted + .map((id) => byId.get(id) ?? null) + .filter((x): x is NonNullable => x !== null); + + expect(afterTargets).toEqual(beforeTargets); + }); +}); diff --git a/frontend/src/routes/recent/+page.svelte b/frontend/src/routes/recent/+page.svelte index 5146c130..7bff45d2 100644 --- a/frontend/src/routes/recent/+page.svelte +++ b/frontend/src/routes/recent/+page.svelte @@ -28,6 +28,7 @@ import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte'; import { preferences } from '$lib/stores/preferences.svelte'; import { filterDotfiles } from '$lib/utils/dotfileFilter'; + import { replaceSet } from '$lib/utils/sets'; import { t } from '$lib/i18n/index.svelte'; let raw = $state([]); @@ -37,7 +38,9 @@ let groupBy = $state(''); let reversed = $state(false); const owners = useOwnerCache(resolveOwnerName); - let favoriteIds = $state>(new Set()); + // In-place reactive set — a star toggle skips the full-set copy and + // spares the other favorited rows' readers. + const favoriteIds = new SvelteSet(); const byId = $derived(new Map(raw.map((it) => [it.resource.id, it]))); @@ -109,7 +112,10 @@ async function loadFavoriteIds() { try { const favs = await fetchFavoritesPage({ resourceTypes: ['file', 'folder'] }); - favoriteIds = new Set(favs.items.map((f) => f.resource.id)); + replaceSet( + favoriteIds, + favs.items.map((f) => f.resource.id) + ); } catch { // non-fatal — stars just default to off } @@ -169,18 +175,15 @@ async function toggleFavorite(entry: ResourceEntry) { const isFav = favoriteIds.has(entry.id); - const next = new SvelteSet(favoriteIds); - if (isFav) next.delete(entry.id); - else next.add(entry.id); - favoriteIds = next; + // Optimistic in-place toggle, reverted on failure. + if (isFav) favoriteIds.delete(entry.id); + else favoriteIds.add(entry.id); try { if (isFav) await removeFavorite(entry.kind, entry.id); else await addFavorite(entry.kind, entry.id); } catch (e) { - // revert on failure - favoriteIds = isFav - ? new Set([...favoriteIds, entry.id]) - : new Set([...favoriteIds].filter((id) => id !== entry.id)); + if (isFav) favoriteIds.add(entry.id); + else favoriteIds.delete(entry.id); errorToast(e); } }