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 — 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 9959add..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,25 @@ 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 + // 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.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 @@ -36,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 @@ -121,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), }; @@ -176,13 +237,28 @@ 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; }; setLocale = (locale?: string) => { if (!locale) return; + if (!this.isActive('set the locale')) return; if (locale !== get(this.internalLocale)) { logger.debug(`Setting '${locale}' locale.`); @@ -196,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); @@ -209,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; @@ -244,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 []; @@ -394,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); @@ -414,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/src/utils.ts b/src/utils.ts index 0e2f246..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.`); } @@ -82,19 +114,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..9ea27bd 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'; @@ -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 }); @@ -467,6 +468,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 @@ -853,6 +870,185 @@ 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('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('`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');