perf(frontend): round 6 — coalesced progressive listing, in-place SvelteSet, batch fan-out, t() value cache
Four SPA hot-path fixes, each shipping with a vitest benchmark gate
(verbatim BEFORE replica + equivalence + perf assertion) so CI
re-verifies the win on every run:
- fetchFolderListing invoked onPage after EVERY 200-row page with the
whole accumulated listing, and the files view re-sorts everything per
emission — O(N²/page) main-thread work on large folders. Page one and
the final page always emit; intermediates coalesce to one per 150 ms.
25×200 load: 30.9 → 4.0 ms (7.8x), 65 000 → 5 200 sorted elements.
- selected/favoriteIds/sharedIds (files) and favoriteIds (recent) were
$state<Set>s copied whole on every toggle. Now one SvelteSet each,
mutated in place (the useSelection pattern): 1 000 toggles @ N=5 000
771.9 → 1.9 ms (399x); one-toggle fan-out across 40 mounted rows
40 → 3 re-runs when refining a select-all.
- batchDelete/moveInto awaited one request per item serially and
probed listing.folders.find per id (O(N·M)). Now an id index built
once + mapLimit(6) fan-out, failure semantics preserved: 100-item
delete @ 5 ms RTT 525 → 89 ms (5.9x), 38 825 → 500 probes.
- t() re-split its dotted key and walked the nested dict on every call,
and interpolate regex-scanned strings without placeholders. Resolved
values now memoize per (dict, key) in a WeakMap + a {{ guard:
20k mixed calls 22.7 → 8.6 ms (2.63x).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aJu9ghvuT8WqC31ZEGTBA
This commit is contained in:
@@ -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, unknown>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<Dict, Map<string, string | null>>();
|
||||
|
||||
/** 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, unknown>): 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}}}`;
|
||||
|
||||
Reference in New Issue
Block a user