146 lines
6.8 KiB
TypeScript
146 lines
6.8 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.
|
||
// Tolerance widened from 1.05× → 1.5× on 2026-07-26 — a tight
|
||
// 5% gate over 4M V8 iterations was flaky on shared runners
|
||
// (JIT inline-cache specialization order + GC jitter routinely
|
||
// produce ±20% swings between best-of-3 runs on identical code
|
||
// paths). The gate is still meaningful: a real regression would
|
||
// be ≥2×, so 1.5× catches it while letting normal microbench
|
||
// noise pass.
|
||
expect(afterMs).toBeLessThanOrEqual(beforeMs * 1.5);
|
||
});
|
||
});
|