diff --git a/src/arts/format/active-formats.svelte.ts b/src/arts/format/active-formats.svelte.ts index 1be92bd..a020618 100644 --- a/src/arts/format/active-formats.svelte.ts +++ b/src/arts/format/active-formats.svelte.ts @@ -25,6 +25,13 @@ export interface ActiveFormat { readonly dates: ActiveDates; getLocale: () => string; setLocale: (locale: string) => void; + /** + * `true` once `dispose()` has been called. After dispose, + * `setLocale()` is a silent no-op so a late event from a locale + * source doesn't reach the already-disposed sub-engines. + */ + readonly disposed: boolean; + /** Idempotent. Disposes every sub-engine in stable order. */ dispose: () => void; } @@ -36,8 +43,10 @@ export function createActiveFormat(options: ActiveFormatOptions = {}): ActiveFor const currency = createActiveCurrency({ ...options.currency, localeSource, numbers }); const units = createActiveUnits({ ...options.units, localeSource, numbers }); const dates = createActiveDates({ ...options.dates, localeSource }); + let disposed = false; function setLocale(nextLocale: string): void { + if (disposed) return; localeState.setLocale(nextLocale); numbers.setLocale(nextLocale); currency.setLocale(nextLocale); @@ -52,7 +61,12 @@ export function createActiveFormat(options: ActiveFormatOptions = {}): ActiveFor dates, getLocale: () => numbers.getLocale(), setLocale, + get disposed() { + return disposed; + }, dispose() { + if (disposed) return; + disposed = true; numbers.dispose(); currency.dispose(); units.dispose(); diff --git a/src/arts/format/test/formats.test.ts b/src/arts/format/test/formats.test.ts index bfe9bf9..d671ebc 100644 --- a/src/arts/format/test/formats.test.ts +++ b/src/arts/format/test/formats.test.ts @@ -45,4 +45,20 @@ describe('createActiveFormat()', () => { formats.dispose(); }); + + it('exposes disposed flag, is idempotent, and ignores setLocale post-dispose', () => { + const formats = createActiveFormat({ locale: 'en-US' }); + + expect(formats.disposed).toBe(false); + formats.dispose(); + expect(formats.disposed).toBe(true); + + expect(() => formats.dispose()).not.toThrow(); + + // setLocale is a silent no-op after dispose; the cached values stay + // frozen at the last set value. + const lastLocale = formats.getLocale(); + formats.setLocale('fr-FR'); + expect(formats.getLocale()).toBe(lastLocale); + }); }); diff --git a/src/arts/frontend/active-frontend.svelte.ts b/src/arts/frontend/active-frontend.svelte.ts index 7f9070c..a92f7a7 100644 --- a/src/arts/frontend/active-frontend.svelte.ts +++ b/src/arts/frontend/active-frontend.svelte.ts @@ -63,6 +63,14 @@ export interface ActiveFrontend { getDensity: () => FrontendDensity; setDensity: (density: FrontendDensity) => void; onPreferenceChange: (fn: () => void) => () => void; + /** + * `true` once `dispose()` has been called. After dispose every + * mutating setter is a silent no-op (so a UI tree being torn + * down doesn't throw on a late event); read-only getters + * continue to return the last applied value. + */ + readonly disposed: boolean; + /** Idempotent. Detaches media and locale subscriptions and clears listeners. */ dispose: () => void; } @@ -127,6 +135,7 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ } function applyDom(): void { + if (disposed) return; const target = resolveTarget(options.target); if (dom === undefined || target === null) return; @@ -143,12 +152,19 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ }); } + /** + * Drives every reactive setter. Guarded against post-dispose + * activity so a late event from `localeSource` or a media query + * teardown ordering quirk doesn't throw or rewrite the DOM. + */ function notify(): void { + if (disposed) return; applyDom(); for (const fn of listeners) fn(); } function setLocale(locale: string): void { + if (disposed) return; currentLocale = locale; notify(); } @@ -171,11 +187,13 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ getDir, setDir: (dir) => { + if (disposed) return; dirOverride = dir === 'auto' ? null : dir; notify(); }, clearDir: () => { + if (disposed) return; dirOverride = null; notify(); }, @@ -184,17 +202,20 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ getTheme: () => theme, setTheme: (nextTheme) => { + if (disposed) return; theme = nextTheme; notify(); }, getMode, setMode: (mode) => { + if (disposed) return; modeOverride = mode === 'auto' ? null : mode; notify(); }, clearMode: () => { + if (disposed) return; modeOverride = null; notify(); }, @@ -203,11 +224,13 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ getReducedMotion, setReducedMotion: (reduced) => { + if (disposed) return; reducedMotionOverride = reduced === 'auto' ? null : reduced; notify(); }, clearReducedMotion: () => { + if (disposed) return; reducedMotionOverride = null; notify(); }, @@ -216,21 +239,28 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ getReducedSound: () => reducedSound, setReducedSound: (reduced) => { + if (disposed) return; reducedSound = reduced; notify(); }, getDensity: () => density, setDensity: (nextDensity) => { + if (disposed) return; density = nextDensity; notify(); }, onPreferenceChange: (fn) => { + if (disposed) return () => {}; listeners.add(fn); return () => listeners.delete(fn); }, + get disposed() { + return disposed; + }, + dispose() { if (disposed) return; disposed = true; diff --git a/src/arts/frontend/test/active-frontend.test.ts b/src/arts/frontend/test/active-frontend.test.ts index d0414b9..e01d4e1 100644 --- a/src/arts/frontend/test/active-frontend.test.ts +++ b/src/arts/frontend/test/active-frontend.test.ts @@ -60,4 +60,35 @@ describe('createActiveFrontend()', () => { frontend.dispose(); }); + + it('exposes disposed flag and silently ignores setters after dispose', () => { + const applied: StructuralChange[] = []; + const target = {} as HTMLElement; + const frontend = createActiveFrontend({ + locale: 'es-ES', + theme: 'one', + target, + dom: { apply: (c) => applied.push(c) } + }); + + expect(frontend.disposed).toBe(false); + const beforeDispose = applied.length; + frontend.dispose(); + expect(frontend.disposed).toBe(true); + + // Re-dispose is idempotent. + expect(() => frontend.dispose()).not.toThrow(); + + // Setters after dispose are silent no-ops — neither apply DOM + // nor invoke listeners. Getters still read the last value. + frontend.setTheme('two'); + frontend.setLocale('en-US'); + frontend.setMode('dark'); + expect(applied.length).toBe(beforeDispose); + expect(frontend.getTheme()).toBe('one'); + + // onPreferenceChange after dispose returns a no-op detacher. + const off = frontend.onPreferenceChange(() => {}); + expect(() => off()).not.toThrow(); + }); }); diff --git a/src/arts/storage/engine-storage.ts b/src/arts/storage/engine-storage.ts index bf4cd7c..701127a 100644 --- a/src/arts/storage/engine-storage.ts +++ b/src/arts/storage/engine-storage.ts @@ -10,6 +10,7 @@ import { import { createStorageDiagnostics, emitStorageDiagnostic } from './diagnostics'; import { createEntryBus, type EntryBus } from './entry-bus'; import { createStorageEntryRuntime } from './entry-runtime'; +import { StorageDisposedError } from './errors'; import type { EngineStorage, EngineStorageOptions, @@ -49,6 +50,8 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS adapter: SyncStorageAdapter; }>(); + let disposed = false; + function report(ctx: Omit, entryAdapterName: string): void { const errorContext = { ...ctx, adapter: entryAdapterName }; emitStorageDiagnostic(diagnostics, STORAGE_DIAGNOSTIC_EVENTS.ERROR, errorContext); @@ -64,6 +67,7 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS defaults: T | (() => T), entryOptions: StorageEntryOptions = {} ): StorageEntry { + if (disposed) throw new StorageDisposedError('entry'); const entryAdapter: SyncStorageAdapter = entryOptions.adapter ?? adapter; const entryNamespace = entryOptions.namespace === false ? undefined : (entryOptions.namespace ?? namespace); @@ -142,6 +146,7 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS } function entriesSnapshot(): ReadonlyArray<{ key: string; fullKey: string; adapter: string }> { + if (disposed) throw new StorageDisposedError('entries'); return [...registeredEntries].map((r) => ({ key: r.key, fullKey: r.fullKey, @@ -150,6 +155,7 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS } function clear(): void { + if (disposed) throw new StorageDisposedError('clear'); // Snapshot first — `removeItem` does not mutate the registry, but // future iterations of the design might, so this is defensive. const snapshot = [...registeredEntries]; @@ -164,7 +170,6 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS } } - let disposed = false; const handle: EngineStorage = { adapter, namespace, diff --git a/src/arts/storage/errors.ts b/src/arts/storage/errors.ts index 0f0535f..67d8d93 100644 --- a/src/arts/storage/errors.ts +++ b/src/arts/storage/errors.ts @@ -53,6 +53,14 @@ export const STORAGE_ERR_ENVELOPE_INVALID_VERSION: ErrCode = errCode( 'envelope_invalid_version' ); +/** + * `entry()`, `clear()`, `entries()` or any other public surface was + * invoked after `dispose()`. The engine's bus and adapter + * subscriptions are already torn down at that point — re-creating + * entries would silently leak. + */ +export const STORAGE_ERR_DISPOSED: ErrCode = errCode(STORAGE_ERR, 'disposed'); + /** * Human-readable messages for every error declared by `arts/stor`. * Indexed by `ErrCode` so the link to identity is direct. Each entry @@ -72,7 +80,9 @@ export const STORAGE_ERROR_MESSAGES: ErrorMessages = { `[${STORAGE_MODULE}] entry("${key}", ...) was called more than once with different defaults. The first registration wins; the second default is ignored.`, [STORAGE_ERR_COOKIE_SERVER_REQUIRED]: `[${STORAGE_MODULE}] cookieAdapter() runs in the browser only. For SSR use cookieAdapter.fromCookies(event.cookies, options).`, - [STORAGE_ERR_ENVELOPE_INVALID_VERSION]: `[${STORAGE_MODULE}] envelope.v not a number` + [STORAGE_ERR_ENVELOPE_INVALID_VERSION]: `[${STORAGE_MODULE}] envelope.v not a number`, + [STORAGE_ERR_DISPOSED]: (op: string): string => + `[${STORAGE_MODULE}] ${op}() called on disposed storage` }; /** @@ -153,6 +163,15 @@ export class StorageEnvelopeInvalidVersionError extends CodeError { } } +export class StorageDisposedError extends CodeError { + readonly op: string; + constructor(op: string) { + const message = STORAGE_ERROR_MESSAGES[STORAGE_ERR_DISPOSED] as (op: string) => string; + super(STORAGE_ERR_DISPOSED, { message: message(op) }); + this.op = op; + } +} + // ── Type guards ───────────────────────────────────────────────────────── export function isStorageInvalidTypeError(value: unknown): value is StorageInvalidTypeError { @@ -194,3 +213,7 @@ export function isStorageEnvelopeInvalidVersionError( ): value is StorageEnvelopeInvalidVersionError { return value instanceof StorageEnvelopeInvalidVersionError; } + +export function isStorageDisposedError(value: unknown): value is StorageDisposedError { + return value instanceof StorageDisposedError; +} diff --git a/src/arts/storage/index.ts b/src/arts/storage/index.ts index b6678c6..b59509f 100644 --- a/src/arts/storage/index.ts +++ b/src/arts/storage/index.ts @@ -14,6 +14,7 @@ export { STORAGE_ERR, STORAGE_ERR_ASYNC_VALIDATE_UNSUPPORTED, STORAGE_ERR_COOKIE_SERVER_REQUIRED, + STORAGE_ERR_DISPOSED, STORAGE_ERR_ENTRY_DEFAULTS_MISMATCH, STORAGE_ERR_ENVELOPE_INVALID_VERSION, STORAGE_ERR_INVALID_TYPE, @@ -22,6 +23,7 @@ export { STORAGE_ERROR_MESSAGES, StorageAsyncValidateUnsupportedError, StorageCookieServerRequiredError, + StorageDisposedError, StorageEntryDefaultsMismatchError, StorageEnvelopeInvalidVersionError, StorageInvalidTypeError, @@ -29,6 +31,7 @@ export { StorageValidationFailedError, isStorageAsyncValidateUnsupportedError, isStorageCookieServerRequiredError, + isStorageDisposedError, isStorageEntryDefaultsMismatchError, isStorageEnvelopeInvalidVersionError, isStorageInvalidTypeError, diff --git a/src/arts/storage/test/engine-storage.test.ts b/src/arts/storage/test/engine-storage.test.ts index abd5c3e..0f6c6de 100644 --- a/src/arts/storage/test/engine-storage.test.ts +++ b/src/arts/storage/test/engine-storage.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { createEngineStorage } from '../engine-storage'; import { createMemoryAdapter } from '../adapters/memory'; +import { isStorageDisposedError, StorageDisposedError } from '../errors'; import type { StorageErrorContext, SyncStorageAdapter } from '../types'; function track(): { @@ -548,4 +549,20 @@ describe('createEngineStorage — dispose', () => { s.dispose(); expect(() => s.dispose()).not.toThrow(); }); + + it('throws StorageDisposedError on entry/clear/entries after dispose', () => { + const s = createEngineStorage({ adapter: createMemoryAdapter() }); + s.dispose(); + + expect(() => s.entry('late', 'value')).toThrow(StorageDisposedError); + expect(() => s.clear()).toThrow(StorageDisposedError); + expect(() => s.entries()).toThrow(StorageDisposedError); + + try { + s.entry('late', 'value'); + } catch (err) { + expect(isStorageDisposedError(err)).toBe(true); + expect((err as StorageDisposedError).op).toBe('entry'); + } + }); });