Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
30 changes: 30 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
101 changes: 95 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ const defaultCache = 1000 * 60 * 60 * 24;

export default class I18n<ParserParams extends Parser.Params = any> {
constructor(config?: Config.T<ParserParams>) {
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);
Expand All @@ -28,14 +28,69 @@ export default class I18n<ParserParams extends Parser.Params = any> {

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
// consumer's fire-and-forget call.
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
Expand Down Expand Up @@ -121,8 +176,14 @@ export default class I18n<ParserParams extends Parser.Params = any> {
locale: ExtendedStore<Config.Locale, () => Config.Locale, Writable<string>> & { 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),
};

Expand Down Expand Up @@ -176,13 +237,28 @@ export default class I18n<ParserParams extends Parser.Params = any> {

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.`);
Expand All @@ -196,6 +272,8 @@ export default class I18n<ParserParams extends Parser.Params = any> {
};

setRoute = (route: string) => {
if (!this.isActive('set the route')) return;

if (route !== get(this.currentRoute)) {
logger.debug(`Setting '${route}' route.`);
this.currentRoute.set(route);
Expand All @@ -209,6 +287,7 @@ export default class I18n<ParserParams extends Parser.Params = any> {

async configLoader(config: Config.T<ParserParams>) {
if (!config) return logger.error('No config provided!');
if (!this.isActive('load a config')) return;

let { initLocale, fallbackLocale, translations, log, ...rest } = config;

Expand Down Expand Up @@ -244,6 +323,8 @@ export default class I18n<ParserParams extends Parser.Params = any> {
};

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 [];
Expand Down Expand Up @@ -394,6 +475,10 @@ export default class I18n<ParserParams extends Parser.Params = any> {

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);
Expand All @@ -414,6 +499,10 @@ export default class I18n<ParserParams extends Parser.Params = any> {
};

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 {
Expand Down
72 changes: 59 additions & 13 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,49 @@ 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<string, string>();

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);

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.`);
}
Expand All @@ -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;
Expand Down
Loading
Loading