From 9fa28db0d791f115286fbfe515321561166deec8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 00:22:47 +0000 Subject: [PATCH 1/6] perf(utils): make toDotNotation linear in key count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The computed-key spread per key copied the accumulator on every step, which is quadratic in the number of keys and the dominant cost of every translation load. The accumulator is mutated instead — function-local, null-prototype so a literal '__proto__' key stays an own property rather than reaching the prototype setter, and finished with a single spread that restores a normal object with DefineProperty semantics. --- src/utils.ts | 40 ++++++++++++++++++++++++++------------- tests/specs/index.spec.ts | 16 ++++++++++++++++ 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 0e2f246..782524f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -82,19 +82,33 @@ export const toDotNotation: DotNotation.T = (input, preserveArrays, parentKey) = } if (input && typeof input === 'object') { - const output = Object.keys(input).reduce((acc, key) => { - const value = (input as any)[key]; - const outputKey = parentKey ? `${parentKey}.${key}` : `${key}`; - - if (value && typeof value === 'object' && !(preserveArrays && Array.isArray(value))) { - return ({ ...acc, ...toDotNotation(value, preserveArrays, outputKey) }); - } - - return ({ ...acc, [outputKey]: toDotNotation(value, preserveArrays) }); - }, {}); - - if (Object.keys(output).length) { - return output; + // This runs over the whole translation set on every load, so the + // accumulator is mutated instead of being rebuilt per key (which is + // quadratic). It has a null prototype so that a literal '__proto__' key + // stays an own property rather than reaching the prototype setter; the + // single spread below restores a normal object for consumers, with + // DefineProperty semantics that preserve that key. + const output: any = Object.create(null); + let hasEntries = false; + + const walk = (node: any, prefix?: string) => { + Object.keys(node).forEach((key) => { + const value = node[key]; + const outputKey = prefix ? `${prefix}.${key}` : `${key}`; + + if (value && typeof value === 'object' && !(preserveArrays && Array.isArray(value))) { + walk(value, outputKey); + } else { + output[outputKey] = toDotNotation(value, preserveArrays); + hasEntries = true; + } + }); + }; + + walk(input, parentKey); + + if (hasEntries) { + return { ...output }; } return null; diff --git a/tests/specs/index.spec.ts b/tests/specs/index.spec.ts index d8f95f5..85790aa 100644 --- a/tests/specs/index.spec.ts +++ b/tests/specs/index.spec.ts @@ -467,6 +467,22 @@ describe('i18n instance', () => { expect($t(key)).toBe(fallbackValue); }); }); + it('`addTranslations` keeps a literal `__proto__` key an own property', async () => { + const { addTranslations, translations } = new i18n(); + + // JSON.parse creates real own '__proto__' keys (object literals would not). + addTranslations({ en: JSON.parse('{"__proto__": {"polluted": "yes"}, "plain": "ok"}') }); + // A bare '__proto__' LEAF is the dangerous case: on a normal-prototype + // accumulator the assignment would hit the setter and silently drop it. + addTranslations({ cs: JSON.parse('{"__proto__": "boom"}') }); + + const $translations = translations.get(); + + expect(({} as any).polluted).toBe(undefined); // Object.prototype untouched + expect($translations.en['__proto__.polluted']).toBe('yes'); + expect($translations.en.plain).toBe('ok'); + expect(read($translations.cs, '__proto__')).toBe('boom'); // survived as an own key + }); it('handles a locale named like an `Object.prototype` member', async () => { // A loader locale colliding with a prototype member must not throw when the // `loadedKeys` cache is indexed by it, must keep its data, and must still From 7117f51be1c338046e2ab31f6ff0926ec422c569 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 12:22:49 +0000 Subject: [PATCH 2/6] perf(utils): cache successful locale sanitizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Intl.Collator.supportedLocalesOf` is expensive and the same locales repeat on every store recompute. Successful lookups are cached LRU (bounded, reinserted on hit, so a flood of visitor-supplied locales evicts itself rather than the app's own). Only string inputs are cacheable — for any other input the string form is not a faithful key — and failures are never cached, so a locale Intl cannot resolve yet can recover and its warning stays tied to the call. --- src/utils.ts | 32 ++++++++++++++++++++++++++++++++ tests/specs/index.spec.ts | 29 ++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/utils.ts b/src/utils.ts index 782524f..ff12e14 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -57,10 +57,35 @@ export const translate: Translations.Translate = ({ return parser.parse(text, params, locale, key); }; +// `Intl.Collator.supportedLocalesOf` is comparatively expensive and locales +// repeat constantly (per loader, per store recompute), so successful lookups +// are cached. Failed lookups are deliberately NOT cached: a locale that is +// temporarily unknown to Intl (e.g. before a polyfill loads) can recover, and +// the non-standard warning stays tied to the call rather than to whichever +// logger and level happened to be installed the first time. Only string inputs +// are cacheable — for any other input the string form is not a faithful key. +// +// Least-recently-used: the map is insertion-ordered and every hit reinserts, so +// a flood of visitor-supplied locales evicts itself rather than the app's own. +const LOCALE_CACHE_LIMIT = 1000; +const sanitizedLocaleCache = new Map(); + export const sanitizeLocales = (...locales: any[]) => { if (!locales.length) return []; return locales.filter((locale) => !!locale).map((locale) => { + const cacheable = typeof locale === 'string'; + + if (cacheable) { + const cached = sanitizedLocaleCache.get(locale); + if (cached !== undefined) { + sanitizedLocaleCache.delete(locale); + sanitizedLocaleCache.set(locale, cached); + + return cached; + } + } + let current = `${locale}`.toLowerCase(); try { const [sanitized] = Intl.Collator.supportedLocalesOf(locale); @@ -68,6 +93,13 @@ export const sanitizeLocales = (...locales: any[]) => { if (!sanitized) throw new Error(); current = sanitized; + + if (cacheable) { + if (sanitizedLocaleCache.size >= LOCALE_CACHE_LIMIT) { + sanitizedLocaleCache.delete(sanitizedLocaleCache.keys().next().value as string); + } + sanitizedLocaleCache.set(locale, current); + } } catch (error) { logger.warn(`'${locale}' locale is non-standard.`); } diff --git a/tests/specs/index.spec.ts b/tests/specs/index.spec.ts index 85790aa..2f68e68 100644 --- a/tests/specs/index.spec.ts +++ b/tests/specs/index.spec.ts @@ -1,7 +1,7 @@ import { get } from 'svelte/store'; import i18n from '../../src/index'; import { logger, loggerFactory, setLogger } from '../../src/logger'; -import { read, testRoute, translate } from '../../src/utils'; +import { read, sanitizeLocales, testRoute, translate } from '../../src/utils'; import { CONFIG, getTranslations } from '../data'; import { filterTranslationKeys } from '../utils'; @@ -869,6 +869,33 @@ describe('i18n instance', () => { expect.objectContaining({ 'a.one': '1', 'b.two': '2' }), ); }); + it('`sanitizeLocales` caches successes but keeps warning for unknown locales', () => { + const { captured, restore } = captureLogs('warn'); + + try { + // A standard locale resolves identically whether or not it is cached. + expect(sanitizeLocales('zh-Hans')).toEqual(sanitizeLocales('zh-Hans')); + + // Failures are never cached, so the warning is not deduplicated away — + // deduplicating it would tie the diagnostic to whichever logger and level + // happened to be installed on the first occurrence. + sanitizeLocales('qqq-alpha'); + sanitizeLocales('qqq-alpha'); + } finally { + restore(); + } + + expect(captured.warn.filter((message) => message.includes('qqq-alpha'))).toHaveLength(2); + }); + it('`sanitizeLocales` does not let a non-string input poison a string key', () => { + // The array stringifies to 'de,fr'; caching it under that key would make a + // later lookup of the literal string 'de,fr' return the array's result. + const fromArray = sanitizeLocales(['de', 'fr'] as any); + const fromString = sanitizeLocales('de,fr'); + + expect(fromArray).toEqual(['de']); + expect(fromString).toEqual(['de,fr']); // non-standard, lowercased as-is + }); it('logger works as expected', async () => { const debug = import.meta.jest.spyOn(console, 'debug'); const warn = import.meta.jest.spyOn(console, 'warn'); From 984246799dd3ff97f4d90675dbadb372f6798404 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 12:23:55 +0000 Subject: [PATCH 3/6] perf(index): sanitize once per getLocale lookup, fallback lazily MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sanitizeLocales ran inside the .find() callbacks — once per candidate locale per lookup. Hoist the requested locale's sanitization, keep the fallback's inside the miss branch so it stays short-circuited, and skip both when there are no locales to match against: sanitizing there only emitted a non-standard warning for a lookup that cannot succeed. --- src/index.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 9959add..8ae437b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -176,7 +176,21 @@ export default class I18n { const $locales = this.locales.get(); - const outputLocale = $locales.find((l) => sanitizeLocales(locale).includes(l)) || $locales.find((l) => sanitizeLocales(fallbackLocale).includes(l)); + // Nothing to match against yet; sanitizing here would only emit a + // non-standard warning for a lookup that cannot succeed anyway. + if (!$locales.length) return; + + // Sanitized once per lookup rather than once per candidate locale. + const sanitizedLocale = sanitizeLocales(locale); + + let outputLocale = $locales.find((l) => sanitizedLocale.includes(l)); + + if (!outputLocale) { + // Evaluated lazily: the fallback (and any non-standard warning it emits) + // must not run when the requested locale resolves directly. + const sanitizedFallbackLocale = sanitizeLocales(fallbackLocale); + outputLocale = $locales.find((l) => sanitizedFallbackLocale.includes(l)); + } return outputLocale; }; From 9f520d65bd7d75317914af8ecdbf786de50cbfa1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 12:24:34 +0000 Subject: [PATCH 4/6] perf(index): keep read-path derived chains warm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get(store) on an inactive svelte derived re-initializes and tears down the entire dependency chain on every call, so each t.get()/l.get() re-ran the translation store callback — scaling every read with the translation table size (5255 ms for 10k reads of a 5000-key table; ~20 ms with warm chains). Hold no-op subscriptions to t, l and initialized so the chains stay active. locales stays cold on purpose: its callback re-sanitizes every loader locale, so warming it would turn the non-standard-locale warning from once per read into once per store write. Also default the destructured config in loading.toPromise, which is now reachable before any config is set. --- src/index.ts | 16 ++++++++++++++++ tests/specs/index.spec.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/index.ts b/src/index.ts index 8ae437b..f5663b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,22 @@ export default class I18n { }); }); + // Keep the read-path derived chains warm for the instance's lifetime. + // `get(store)` on an inactive derived re-initializes (and tears down) its + // whole dependency chain on every call, which makes `.get()` reads scale + // with table size; with an active subscriber, additional subscribers + // receive the cached value. + // + // `locales` is deliberately left cold: its callback is the only one that + // touches consumer-supplied config objects (`loaders.map(({ locale }) => …)`). + // Warming it would move that read into a store flush, where svelte's + // subscriber queue does not unwind on a throw — one malformed loader would + // then break store propagation process-wide instead of failing the one + // lookup that read it. + this.t.subscribe(() => {}); + this.l.subscribe(() => {}); + this.initialized.subscribe(() => {}); + // `loadConfig` reports and marks its own failure, so a config load started // here — with no caller to reject to — is covered by the same path as a // consumer's fire-and-forget call. diff --git a/tests/specs/index.spec.ts b/tests/specs/index.spec.ts index 2f68e68..894fb55 100644 --- a/tests/specs/index.spec.ts +++ b/tests/specs/index.spec.ts @@ -896,6 +896,36 @@ describe('i18n instance', () => { expect(fromArray).toEqual(['de']); expect(fromString).toEqual(['de,fr']); // non-standard, lowercased as-is }); + it('keeps derived chains warm so repeated reads hit the cached value', () => { + const instance = new i18n({ loaders, parser, log }); + + // With an inactive derived, every get() re-initializes the chain and + // produces a fresh value; the constructor's warm subscriptions make + // repeated reads return the identical cached reference. + expect(get(instance.t)).toBe(get(instance.t)); + expect(get(instance.l)).toBe(get(instance.l)); + }); + it('leaves `locales` cold so a malformed loader cannot break store flushes', () => { + // Its callback is the only warm-chain candidate that reads consumer config + // objects. Warming it would run that read inside a store flush, where a + // throw leaves svelte's subscriber queue wedged for the whole process. + const brokenLoader: any = { key: 'common', loader: async () => ({}) }; + Object.defineProperty(brokenLoader, 'locale', { + enumerable: true, + get: () => { throw new Error('locale getter boom'); }, + }); + + const instance = new i18n({ parser, log, loaders: [brokenLoader] }); + + // The failure belongs to the lookup that read it, not to the next store + // write anywhere in the process. + expect(() => instance.locales.get()).toThrow('locale getter boom'); + + const healthy = new i18n({ loaders, parser, log }); + healthy.setRoute('/'); + + expect(get(healthy.initialized)).toBe(false); + }); it('logger works as expected', async () => { const debug = import.meta.jest.spyOn(console, 'debug'); const warn = import.meta.jest.spyOn(console, 'warn'); From d15c9b8e9302808ca1dfb0dd72a8a44d20faafd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 12:26:04 +0000 Subject: [PATCH 5/6] feat: add destroy() to stop loading and fence in-flight results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The constructor holds subscriptions for the instance's whole life (the loader trigger, promise purging, the warm derived chains) with no way to detach them, and a load started before teardown still wrote its result to the stores afterwards. destroy() detaches those subscriptions, clears pending promises, resets the loading flag, and marks the instance inactive: every entry point that would load or mutate now bails through one shared gate that warns, and results arriving from an in-flight load are dropped. Reads keep working. Documented in docs/README.md, including that it is not needed for garbage collection — the subscription graph is instance-internal. --- README.md | 1 + docs/README.md | 30 ++++++++++ src/index.ts | 75 ++++++++++++++++++++--- tests/specs/index.spec.ts | 123 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 221 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 70b9ded..055e5a2 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,7 @@ log: { - `loadTranslations(locale, route)` – Load translations for locale and route - `setLocale(locale)` – Change current locale - `setRoute(route)` – Update current route +- `destroy()` – Stop the instance from loading and release its subscriptions Full API documentation: [docs/README.md](./docs/README.md) diff --git a/docs/README.md b/docs/README.md index 9360339..2b30374 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1143,6 +1143,36 @@ addTranslations({ --- +### `destroy` + +**Type:** `() => void` + +Releases the instance's lifetime store subscriptions (the loader trigger, promise purging, and the internally kept-warm derived chains) and discards pending loader promises. + +**Usage:** + +```javascript +const i18nInstance = new i18n(config); + +// ... when the instance is no longer needed: +i18nInstance.destroy(); +``` + +**Behavior after `destroy()`:** +- The instance stays readable (`t.get()`, `$translations`, ...), but `setLocale()`, `setRoute()` and writes through the `locale` store (`locale.set()` / `locale.update()`) are refused outright: they warn, leave `$locale`/`$route` unchanged, and load nothing. +- Results of loads still in flight when `destroy()` was called are dropped — they no longer update the stores or the locale. +- `loadTranslations()` and `loadConfig()` warn once, naming the call you made, and load nothing. +- Existing store subscriptions you created are NOT removed — manual `addTranslations()` calls still propagate to them. +- `.get()` reads lose their warm-chain performance (each read re-initializes the store chain). +- Calling `destroy()` again is a no-op. + +**Use Cases:** +- Component-scoped instances created and discarded dynamically +- Per-request instances on the server +- Test teardown + +--- + ## TypeScript Full TypeScript support with complete type definitions: diff --git a/src/index.ts b/src/index.ts index f5663b4..cc7d7c1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,10 +11,10 @@ const defaultCache = 1000 * 60 * 60 * 24; export default class I18n { constructor(config?: Config.T) { - this.loaderTrigger.subscribe(this.loader); + this.subscriptions.push(this.loaderTrigger.subscribe(this.loader)); // purge resolved promises - this.isLoading.subscribe(($loading) => { + this.subscriptions.push(this.isLoading.subscribe(($loading) => { if (!$loading || !this.promises.size) return; const tracked = Array.from(this.promises); @@ -28,7 +28,7 @@ export default class I18n { logger.debug('Loader promises have been purged.'); }); - }); + })); // Keep the read-path derived chains warm for the instance's lifetime. // `get(store)` on an inactive derived re-initializes (and tears down) its @@ -42,9 +42,11 @@ export default class I18n { // subscriber queue does not unwind on a throw — one malformed loader would // then break store propagation process-wide instead of failing the one // lookup that read it. - this.t.subscribe(() => {}); - this.l.subscribe(() => {}); - this.initialized.subscribe(() => {}); + this.subscriptions.push( + this.t.subscribe(() => {}), + this.l.subscribe(() => {}), + this.initialized.subscribe(() => {}), + ); // `loadConfig` reports and marks its own failure, so a config load started // here — with no caller to reject to — is covered by the same path as a @@ -52,6 +54,43 @@ export default class I18n { if (config) this.loadConfig(config); } + private subscriptions: Array<() => void> = []; + + private destroyed = false; + + // Single gate for every entry point that would load or mutate, so callers + // bail with one shared warning instead of each guarding itself. + private isActive = (action: string) => { + if (this.destroyed) { + logger.warn(`Cannot ${action}: the instance is destroyed.`); + return false; + } + + return true; + }; + + /** + * Stops the instance from loading: detaches its lifetime store + * subscriptions, drops the results of loads still in flight, and clears the + * loading state. Reads keep working (`t.get()`, `translations.get()`, your + * own subscriptions), but every entry point that would load — `setLocale`, + * `setRoute`, `locale.set`/`locale.update`, `loadTranslations`, `loadConfig` + * — is refused with a warning, and `.get()` reads lose their warm-chain + * performance. `addTranslations` still applies: a destroyed instance stops + * loading, not storing. + * + * Not required for garbage collection — the subscription graph is internal + * to the instance and is collected with it. Use it to stop work you no + * longer want, e.g. when a component-scoped instance goes away mid-load. + */ + destroy = () => { + this.destroyed = true; + this.subscriptions.forEach((unsubscribe) => unsubscribe()); + this.subscriptions = []; + this.promises.clear(); + this.isLoading.set(false); + }; + private cachedAt = 0; // Null prototype: this cache is indexed by user-supplied locales, and a @@ -137,8 +176,14 @@ export default class I18n { locale: ExtendedStore Config.Locale, Writable> & { forceSet: any } = { subscribe: this.localeHelper.subscribe, forceSet: this.localeHelper.set, - set: this.internalLocale.set, - update: this.internalLocale.update, + // Gated like `setLocale`: the loader trigger is what propagates a write to + // the public store, so after `destroy()` these would be silently inert. + set: (value: Config.Locale) => { + if (this.isActive('set the locale')) this.internalLocale.set(value); + }, + update: (updater: (value: Config.Locale) => Config.Locale) => { + if (this.isActive('set the locale')) this.internalLocale.update(updater); + }, get: () => get(this.locale), }; @@ -213,6 +258,7 @@ export default class I18n { setLocale = (locale?: string) => { if (!locale) return; + if (!this.isActive('set the locale')) return; if (locale !== get(this.internalLocale)) { logger.debug(`Setting '${locale}' locale.`); @@ -226,6 +272,8 @@ export default class I18n { }; setRoute = (route: string) => { + if (!this.isActive('set the route')) return; + if (route !== get(this.currentRoute)) { logger.debug(`Setting '${route}' route.`); this.currentRoute.set(route); @@ -239,6 +287,7 @@ export default class I18n { async configLoader(config: Config.T) { if (!config) return logger.error('No config provided!'); + if (!this.isActive('load a config')) return; let { initLocale, fallbackLocale, translations, log, ...rest } = config; @@ -274,6 +323,8 @@ export default class I18n { }; getTranslationProps = async ($locale = this.locale.get(), $route = get(this.currentRoute)): Promise<[Translations.SerializedTranslations, Loader.IndexedKeys] | []> => { + if (!this.isActive('load translations')) return []; + const $config = get(this.config); if (!$config || !$locale) return []; @@ -424,6 +475,10 @@ export default class I18n { const props = await this.getTranslationProps(locale, route); + // The load can outlive a destroy(); its results must not reach the + // stores afterwards. + if (this.destroyed) return; + if (props.length) this.addTranslations(...props); if (locale && this.locale.get() !== locale) this.locale.forceSet(locale); @@ -444,6 +499,10 @@ export default class I18n { }; loadTranslations = (locale: Config.Locale, route = get(this.currentRoute) || '') => { + // Gated before delegating, so a destroyed instance reports this call rather + // than the two internal ones it would otherwise make. + if (!this.isActive('load translations')) return; + let normalizedLocale: Config.Locale | undefined; try { diff --git a/tests/specs/index.spec.ts b/tests/specs/index.spec.ts index 894fb55..9ea27bd 100644 --- a/tests/specs/index.spec.ts +++ b/tests/specs/index.spec.ts @@ -70,6 +70,7 @@ describe('i18n instance', () => { toHaveProperty('addTranslations'); toHaveProperty('setLocale'); toHaveProperty('setRoute'); + toHaveProperty('destroy'); }); it('`setRoute` method does not trigger loading if locale is not set', async () => { const { initialized, setRoute, loading, locale } = new i18n({ loaders, parser, log }); @@ -926,6 +927,128 @@ describe('i18n instance', () => { expect(get(healthy.initialized)).toBe(false); }); + it('`destroy` releases lifetime subscriptions', async () => { + const { + destroy, setLocale, setRoute, loading, translations, + } = new i18n({ loaders, parser, log }); + + destroy(); + + // The loader trigger is detached: locale/route changes no longer load. + await setRoute(''); + setLocale(initLocale); + + expect(loading.get()).toBe(false); + expect(Object.keys(translations.get()).length).toBe(0); + + // Idempotent. + expect(() => destroy()).not.toThrow(); + }); + it('`destroy` drops results of loads still in flight', async () => { + let release: (value: unknown) => void = () => {}; + const gate = new Promise((resolve) => { release = resolve; }); + + const instance = new i18n({ + parser, + log, + loaders: [{ + key: 'common', + locale: 'en', + loader: async () => { await gate; return { greeting: 'Hi' }; }, + }], + }); + + // The load itself, not a snapshot taken before it started: awaiting the + // wrong promise would let the test pass while the fence does nothing. + const pending = instance.loadTranslations('en', '/'); + + instance.destroy(); + release({}); + await pending; + + // The late result must not mutate the released instance. + expect(Object.keys(instance.translations.get()).length).toBe(0); + expect(instance.locale.get()).toBe(undefined); // forceSet fenced too + }); + it('`destroy` clears the loading state it leaves behind', async () => { + let release: (value: unknown) => void = () => {}; + const gate = new Promise((resolve) => { release = resolve; }); + + const instance = new i18n({ + parser, + log, + loaders: [{ key: 'common', locale: 'en', loader: async () => { await gate; return {}; } }], + }); + + const pending = instance.loadTranslations('en', '/'); + expect(instance.loading.get()).toBe(true); + + instance.destroy(); + + // `toPromise()` resolves immediately once the promises are dropped, so the + // flag must not stay stuck at true. + expect(instance.loading.get()).toBe(false); + + release({}); + // Settles the load before the next test, so its logging cannot land in a + // later test's logger. + await pending; + }); + it('`addTranslations` still reaches the stores after `destroy`', () => { + const instance = new i18n({ parser, log }); + + instance.destroy(); + instance.addTranslations({ en: { greeting: 'Hi' } }); + + expect(read(instance.translations.get(), 'en')).toEqual( + expect.objectContaining({ greeting: 'Hi' }), + ); + }); + it('`destroy` refuses locale and route writes out loud', async () => { + const { captured, restore } = captureLogs('warn'); + + try { + const instance = new i18n({ loaders, parser }); + + instance.destroy(); + + // Writing through the store is the documented equivalent of `setLocale`, + // so it must not be silently inert either. + instance.locale.set('en'); + instance.locale.update(() => 'en'); + + // Reported, not silently dropped: the write is a no-op either way, + // because the loader trigger that would publish it is detached. + expect(captured.warn.filter((message) => message.includes('Cannot set the locale'))).toHaveLength(2); + expect(instance.locale.get()).toBe(undefined); + + // One report per call the consumer made, naming that call — not the two + // internal ones `loadTranslations` would otherwise delegate to. + captured.warn.length = 0; + await instance.loadTranslations('en', '/'); + + expect(captured.warn).toEqual(['[i18n]: Cannot load translations: the instance is destroyed.']); + } finally { + restore(); + } + }); + it('`destroy` releases the warm chains', () => { + const instance = new i18n({ loaders, parser, log }); + + instance.destroy(); + + // Cold derived: every read re-initializes and yields a fresh value. + expect(get(instance.t)).not.toBe(get(instance.t)); + }); + it('`loadConfig` after `destroy` loads nothing', async () => { + const instance = new i18n(); + + instance.destroy(); + await instance.loadConfig({ ...CONFIG }); + + expect(Object.keys(instance.translations.get()).length).toBe(0); + expect(instance.locales.get().length).toBe(0); // config was never applied + }); it('logger works as expected', async () => { const debug = import.meta.jest.spyOn(console, 'debug'); const warn = import.meta.jest.spyOn(console, 'warn'); From eb37e4918b6526fd25a6eadf6674e82d40956d37 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 12:26:46 +0000 Subject: [PATCH 6/6] docs: allow function-local mutable accumulators on measured hot paths The rule mandated the computed-key-spread accumulation style unconditionally, which toDotNotation now deviates from for performance. State what the code actually does: immutable style for shared state, mutation only into function-local null-prototype accumulators finished with a single spread, never on store state. --- AGENTS.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ba83fc2..393dcb0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,10 +201,16 @@ chat (§3). - Match airbnb-typescript: 2-space indent, single quotes, semicolons, trailing commas, no trailing whitespace, no multiple blank lines. Let `npm run lint --fix` handle it. -- Prefer the existing **functional, immutable** style in `utils.ts` - (computed-key spread `{ ...acc, [k]: v }`, `reduce`, no mutation). That spread - form has `DefineProperty` semantics — it can't pollute `Object.prototype`. - Keep it that way. +- Prefer the **functional, immutable** style for shared state (computed-key + spread `{ ...acc, [k]: v }`, `reduce`). That spread form has `DefineProperty` + semantics — it can't pollute `Object.prototype`. On a **measured hot path** a + function-local accumulator may be built by mutation instead, but only into a + null-prototype object (`Object.create(null)`), and it must be finished with a + single spread before it escapes to consumers — that restores a normal + prototype while keeping the same pollution safety (see `toDotNotation`). + Store values are replaced, never mutated in place. Long-lived instance state + may be written in place only when it is null-prototype and internal (see + `loadedKeys` in §11) — never a plain object indexed by consumer input. - Keep `index.ts` for orchestration; put pure, testable logic in `utils.ts`. - Log through the module `logger` (`logger.error/warn/debug`), never raw `console`. Respect the configured level; a custom logger may omit a level —