Bloque D — uniform lifecycle for declarable services

Audit P2: every service the app declares in `createActiveApp({
services })` should respect the same dispose contract: idempotent,
post-dispose mutators are inert, no late side effects on torn-down
subscriptions.

storage:
- New `STORAGE_ERR_DISPOSED` + `StorageDisposedError` (with
  `isStorageDisposedError` guard).
- `entry()`, `clear()` and `entries()` throw `StorageDisposedError`
  after `dispose()` instead of silently mutating refcounted
  registries with the bus already torn down.
- Re-exports added to the index barrel.

frontend:
- `ActiveFrontend.disposed` getter on the public type.
- Every mutating setter (`setLocale`, `setDir`, `clearDir`,
  `setTheme`, `setMode`, `clearMode`, `setReducedMotion`,
  `clearReducedMotion`, `setReducedSound`, `setDensity`) now
  short-circuits when disposed, so a late media-query event or a
  locale-source emit during teardown can't rewrite the DOM through
  a torn-down `applyDom()`. Read-only getters keep returning the
  last applied value.
- `onPreferenceChange` returns a no-op detacher post-dispose.
- `dispose()` is idempotent (was already, now also guarded against
  resurrected mutations).

format:
- `ActiveFormat.disposed` getter on the public type.
- `dispose()` is now idempotent at the root and walks each
  sub-engine in stable order.
- `setLocale()` is a no-op post-dispose.

Tests: +3 regression tests (one per art) covering the new dispose
semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
dev 5 months ago
parent f1055842dd
commit 5efec94367

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

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

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

@ -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();
});
});

@ -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<StorageErrorContext, 'adapter'>, 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<T> = {}
): StorageEntry<T> {
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,

@ -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;
}

@ -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,

@ -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');
}
});
});

Loading…
Cancel
Save

Powered by TurnKey Linux.