139 lines
6.4 KiB
TypeScript
139 lines
6.4 KiB
TypeScript
|
|
// Round-14 frontend micro-pack (benches/ROUND14.md §F1, §F2).
|
|||
|
|
//
|
|||
|
|
// Each section is BEFORE (verbatim replica of the shipped shape) vs AFTER
|
|||
|
|
// (proposed shape), with an equivalence gate and a wall-time perf gate — the
|
|||
|
|
// same discipline as the Rust micro-packs: an AFTER that doesn't beat its
|
|||
|
|
// BEFORE fails the gate.
|
|||
|
|
|
|||
|
|
import { describe, expect, it } from 'vitest';
|
|||
|
|
|
|||
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|||
|
|
// [F2] favorites `favoriteIds` — rebuild-a-fresh-Set-per-page vs incremental
|
|||
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|||
|
|
//
|
|||
|
|
// Audit finding: the favorites route derived `favoriteIds = new SvelteSet(
|
|||
|
|
// items.map(i => i.id))`. Every infinite-scroll page (`raw = [...raw, ...page]`)
|
|||
|
|
// rebuilt a brand-new set over the WHOLE accumulated list — O(N) per page,
|
|||
|
|
// O(N²) across a P-page drain — and, being a new instance each page,
|
|||
|
|
// invalidated every mounted star reader. The fix keeps one persistent set and
|
|||
|
|
// `add`s only the fresh page's ids (clear on reset). Since every item on the
|
|||
|
|
// page is a favorite and removed items aren't rendered, the set only has to be
|
|||
|
|
// a superset of the displayed ids, so `add`-only is correct.
|
|||
|
|
|
|||
|
|
/** A page of ids (50/page, the default page size). */
|
|||
|
|
function pageOf(start: number, n: number): string[] {
|
|||
|
|
return Array.from({ length: n }, (_, i) => `fav-${start + i}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** BEFORE: rebuild a fresh Set over the whole accumulated list each page. */
|
|||
|
|
function rebuildPerPage(pages: string[][]): Set<string> {
|
|||
|
|
let acc: string[] = [];
|
|||
|
|
let set = new Set<string>();
|
|||
|
|
for (const page of pages) {
|
|||
|
|
acc = [...acc, ...page]; // the route's `raw = [...raw, ...page]`
|
|||
|
|
set = new Set(acc.map((id) => id)); // new instance + O(N) rebuild
|
|||
|
|
}
|
|||
|
|
return set;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** AFTER: one persistent set, add only the fresh page's ids. */
|
|||
|
|
function incrementalPerPage(pages: string[][]): Set<string> {
|
|||
|
|
const set = new Set<string>();
|
|||
|
|
for (const page of pages) {
|
|||
|
|
for (const id of page) set.add(id);
|
|||
|
|
}
|
|||
|
|
return set;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
describe('round14 §F2 — favorites favoriteIds incremental set', () => {
|
|||
|
|
it('final membership is identical (equivalence gate)', () => {
|
|||
|
|
const pages = Array.from({ length: 20 }, (_, p) => pageOf(p * 50, 50));
|
|||
|
|
const before = rebuildPerPage(pages);
|
|||
|
|
const after = incrementalPerPage(pages);
|
|||
|
|
expect(after.size).toBe(before.size);
|
|||
|
|
for (const id of before) expect(after.has(id)).toBe(true);
|
|||
|
|
for (const id of after) expect(before.has(id)).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('a P-page drain builds the set ≥5x faster incrementally (perf gate)', () => {
|
|||
|
|
const PAGES = 40;
|
|||
|
|
const PER = 50; // 2 000 items total
|
|||
|
|
const pages = Array.from({ length: PAGES }, (_, p) => pageOf(p * PER, PER));
|
|||
|
|
|
|||
|
|
const run = (f: (p: string[][]) => Set<string>): number => {
|
|||
|
|
const t0 = performance.now();
|
|||
|
|
// A few repetitions so the measurement isn't dominated by timer noise.
|
|||
|
|
for (let r = 0; r < 20; r++) f(pages);
|
|||
|
|
return performance.now() - t0;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Warm-up (JIT) then measure.
|
|||
|
|
run(rebuildPerPage);
|
|||
|
|
run(incrementalPerPage);
|
|||
|
|
const beforeMs = run(rebuildPerPage);
|
|||
|
|
const afterMs = run(incrementalPerPage);
|
|||
|
|
|
|||
|
|
console.info(
|
|||
|
|
`§F2 ${PAGES} pages × ${PER}: rebuild-per-page ${beforeMs.toFixed(1)} ms vs incremental ${afterMs.toFixed(1)} ms (${(beforeMs / afterMs).toFixed(1)}x)`
|
|||
|
|
);
|
|||
|
|
expect(afterMs).toBeLessThan(beforeMs / 5);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|||
|
|
// [F1] t() params — throwaway `{}` per call vs a shared frozen empty object
|
|||
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|||
|
|
//
|
|||
|
|
// The ubiquitous inline-fallback form `t('k', 'Fallback')` and the bare
|
|||
|
|
// `t('k')` (default param `= {}`) allocated a fresh params object on every
|
|||
|
|
// call, though for a cache-hit string with no `{{…}}` `interpolate` returns
|
|||
|
|
// before ever reading params. t() runs ~10×/row. The fix hoists a shared
|
|||
|
|
// frozen `EMPTY_PARAMS` for both no-param branches.
|
|||
|
|
|
|||
|
|
const EMPTY_PARAMS: Record<string, unknown> = Object.freeze({});
|
|||
|
|
|
|||
|
|
/** Model of the shipped t() param selection + a representative params read
|
|||
|
|
* (interpolate's `params[name]` lookup), isolated from dictionary I/O. */
|
|||
|
|
function tBefore(paramsOrFallback: string | Record<string, unknown> = {}): unknown {
|
|||
|
|
const isStringForm = typeof paramsOrFallback === 'string';
|
|||
|
|
const params = isStringForm ? {} : paramsOrFallback;
|
|||
|
|
return (params as Record<string, unknown>)['n'];
|
|||
|
|
}
|
|||
|
|
function tAfter(paramsOrFallback: string | Record<string, unknown> = EMPTY_PARAMS): unknown {
|
|||
|
|
const isStringForm = typeof paramsOrFallback === 'string';
|
|||
|
|
const params = isStringForm ? EMPTY_PARAMS : paramsOrFallback;
|
|||
|
|
return (params as Record<string, unknown>)['n'];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
describe('round14 §F1 — t() shared empty params', () => {
|
|||
|
|
it('produces identical results for the no-param call forms (equivalence gate)', () => {
|
|||
|
|
expect(tAfter()).toBe(tBefore());
|
|||
|
|
expect(tAfter('Owner')).toBe(tBefore('Owner'));
|
|||
|
|
expect(tAfter({ n: 5 })).toBe(tBefore({ n: 5 }));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('the string/bare forms are not slower with a shared empty (perf gate)', () => {
|
|||
|
|
const N = 4_000_000;
|
|||
|
|
const run = (f: (a?: string | Record<string, unknown>) => unknown): number => {
|
|||
|
|
let sink: unknown;
|
|||
|
|
const t0 = performance.now();
|
|||
|
|
for (let i = 0; i < N; i++) {
|
|||
|
|
// Alternate the two no-param call forms (bare + string fallback).
|
|||
|
|
sink = i & 1 ? f('Fallback') : f();
|
|||
|
|
}
|
|||
|
|
void sink;
|
|||
|
|
return performance.now() - t0;
|
|||
|
|
};
|
|||
|
|
// Warm-up then measure (best-of-3 to damp GC/JIT noise).
|
|||
|
|
run(tBefore);
|
|||
|
|
run(tAfter);
|
|||
|
|
const beforeMs = Math.min(run(tBefore), run(tBefore), run(tBefore));
|
|||
|
|
const afterMs = Math.min(run(tAfter), run(tAfter), run(tAfter));
|
|||
|
|
console.info(
|
|||
|
|
`§F1 ${N} no-param t() calls: fresh {} ${beforeMs.toFixed(1)} ms vs shared frozen ${afterMs.toFixed(1)} ms (${(beforeMs / afterMs).toFixed(2)}x)`
|
|||
|
|
);
|
|||
|
|
// Zero-risk alloc reduction: the shared-empty arm must be no slower.
|
|||
|
|
expect(afterMs).toBeLessThanOrEqual(beforeMs * 1.05);
|
|||
|
|
});
|
|||
|
|
});
|