168 lines
6.2 KiB
TypeScript
168 lines
6.2 KiB
TypeScript
|
|
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);
|
|||
|
|
});
|
|||
|
|
});
|