I1 — `ActiveFrontend.snapshot(): FrontendSnapshot` returns every
observable preference resolved at call time
(`{ locale, dir, theme, mode, reducedMotion, reducedSound, density }`).
Computed fresh on each call from the live state — useful for logger
context, persistence, devtools, snapshot diffing.
I2 — `applyPersistFrontendPreferences(App, options?)` preset round-
trips the frontend preferences through `App.storage`. Replays a
persisted snapshot at attach time, writes back on every preference
change, optionally filters which keys to persist. The detacher cleanly
stops persisting and disposes the storage entry — idempotent.
Cross-tab sync rides on the storage adapter's `onChange` (the
`storage` event for `localAdapter`, `BroadcastChannel` for
`broadcastAdapter`); the preset guards against re-entrant writes when
its own change triggers an external echo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
parent
3ec92acdc3
commit
7fb071d22e
@ -0,0 +1,148 @@
|
||||
import {
|
||||
FRONTEND_PREFERENCE_KEYS,
|
||||
applyFrontendPreferenceSnapshot,
|
||||
readFrontendPreference,
|
||||
type ActiveFrontend,
|
||||
type FrontendPreferenceKey,
|
||||
type FrontendPreferenceSnapshot
|
||||
} from '$frontend';
|
||||
import type { ActiveStorage, ActiveStorageEntry } from '$storage/types';
|
||||
import type { ActiveAppCore } from '../types.ts';
|
||||
|
||||
/**
|
||||
* Shape required by `applyPersistFrontendPreferences`. The preset
|
||||
* touches only the `frontend` and `storage` slots — apps that compose
|
||||
* different services pass a narrower `Pick<>` type if they want.
|
||||
*/
|
||||
export interface PersistFrontendPreferencesApp extends ActiveAppCore {
|
||||
readonly frontend: ActiveFrontend;
|
||||
readonly storage: ActiveStorage;
|
||||
}
|
||||
|
||||
export interface PersistFrontendPreferencesOptions {
|
||||
/**
|
||||
* Storage entry key. Defaults to `frontend:preferences`.
|
||||
*
|
||||
* The default is a `:`-namespaced key so it groups with other
|
||||
* framework state under a single inspectable bucket without
|
||||
* conflicting with app-owned keys.
|
||||
*/
|
||||
readonly key?: string;
|
||||
/**
|
||||
* Subset of preferences to persist. Defaults to every preference
|
||||
* (`theme`, `mode`, `density`, `dir`, `reducedMotion`,
|
||||
* `reducedSound`). Useful when an app wants to persist only theme
|
||||
* and mode but always re-derive density from device class.
|
||||
*/
|
||||
readonly keys?: readonly FrontendPreferenceKey[];
|
||||
}
|
||||
|
||||
const DEFAULT_PREFERENCES_KEY = 'frontend:preferences';
|
||||
|
||||
/**
|
||||
* Wires `App.frontend` to `App.storage` so user preferences survive
|
||||
* reloads. Reads any persisted snapshot at attach time and replays it
|
||||
* onto the live frontend; subscribes to `onPreferenceChange` so every
|
||||
* subsequent setter writes back through the storage entry.
|
||||
*
|
||||
* Returns a detach function that stops persisting and disposes the
|
||||
* storage entry created internally. Idempotent.
|
||||
*
|
||||
* @example
|
||||
* const detach = applyPersistFrontendPreferences(App);
|
||||
* // ...later, on app teardown:
|
||||
* detach();
|
||||
*
|
||||
* @example
|
||||
* // Persist only theme and mode; density stays in-memory.
|
||||
* applyPersistFrontendPreferences(App, { keys: ['theme', 'mode'] });
|
||||
*/
|
||||
export function applyPersistFrontendPreferences(
|
||||
App: PersistFrontendPreferencesApp,
|
||||
options: PersistFrontendPreferencesOptions = {}
|
||||
): () => void {
|
||||
const key = options.key ?? DEFAULT_PREFERENCES_KEY;
|
||||
const keys = options.keys ?? FRONTEND_PREFERENCE_KEYS;
|
||||
|
||||
const entry: ActiveStorageEntry<FrontendPreferenceSnapshot> = App.storage.entry(
|
||||
key,
|
||||
{}
|
||||
);
|
||||
|
||||
// Replay the persisted snapshot onto the live frontend. We copy each
|
||||
// known key explicitly so unknown fields stuck in storage from a
|
||||
// previous schema don't bleed back into the runtime.
|
||||
const persisted = entry.get();
|
||||
if (persisted !== null && typeof persisted === 'object') {
|
||||
const replayable: FrontendPreferenceSnapshot = {};
|
||||
for (const k of keys) {
|
||||
const value = (persisted as FrontendPreferenceSnapshot)[k];
|
||||
if (value !== undefined) {
|
||||
(replayable as Record<string, unknown>)[k] = value;
|
||||
}
|
||||
}
|
||||
applySnapshotToFrontend(App.frontend, replayable);
|
||||
}
|
||||
|
||||
let writing = false;
|
||||
const detachListener = App.frontend.onPreferenceChange(() => {
|
||||
// The storage entry's onChange listener also calls
|
||||
// `applySnapshotToFrontend` (cross-tab path); guard against the
|
||||
// reentrant write that would re-enter this listener.
|
||||
if (writing) return;
|
||||
writing = true;
|
||||
try {
|
||||
const next: FrontendPreferenceSnapshot = {};
|
||||
for (const k of keys) {
|
||||
(next as Record<string, unknown>)[k] = readFrontendPreference(App.frontend, k);
|
||||
}
|
||||
entry.set(next);
|
||||
} finally {
|
||||
writing = false;
|
||||
}
|
||||
});
|
||||
|
||||
const detachStorage = entry.onChange((nextSnapshot: FrontendPreferenceSnapshot) => {
|
||||
// Re-apply external changes (other tab) onto the live frontend.
|
||||
// Skip when WE are the source of the write.
|
||||
if (writing) return;
|
||||
if (nextSnapshot === null || typeof nextSnapshot !== 'object') return;
|
||||
const replayable: FrontendPreferenceSnapshot = {};
|
||||
for (const k of keys) {
|
||||
const value = nextSnapshot[k];
|
||||
if (value !== undefined) {
|
||||
(replayable as Record<string, unknown>)[k] = value;
|
||||
}
|
||||
}
|
||||
writing = true;
|
||||
try {
|
||||
applySnapshotToFrontend(App.frontend, replayable);
|
||||
} finally {
|
||||
writing = false;
|
||||
}
|
||||
});
|
||||
|
||||
let detached = false;
|
||||
return () => {
|
||||
if (detached) return;
|
||||
detached = true;
|
||||
detachListener();
|
||||
detachStorage();
|
||||
entry.dispose();
|
||||
};
|
||||
}
|
||||
|
||||
function applySnapshotToFrontend(
|
||||
frontend: ActiveFrontend,
|
||||
snapshot: FrontendPreferenceSnapshot
|
||||
): void {
|
||||
// Bridge from declarative `FrontendPreferenceSnapshot` to imperative
|
||||
// frontend setters so `setMode('auto')` clears the override etc.
|
||||
const merged = applyFrontendPreferenceSnapshot({}, snapshot);
|
||||
if (merged.theme !== undefined) frontend.setTheme(merged.theme);
|
||||
if (merged.mode !== undefined) frontend.setMode(merged.mode);
|
||||
if (merged.density !== undefined) frontend.setDensity(merged.density);
|
||||
if (merged.dir !== undefined) frontend.setDir(merged.dir);
|
||||
if (merged.reducedMotion !== undefined) frontend.setReducedMotion(merged.reducedMotion);
|
||||
if (merged.reducedSound !== undefined) frontend.setReducedSound(merged.reducedSound);
|
||||
}
|
||||
@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Bloque I2 — `applyPersistFrontendPreferences` round-trips frontend
|
||||
* preferences through `App.storage`.
|
||||
*
|
||||
* Wires `createActiveStorage` (memory adapter) + `createActiveFrontend`
|
||||
* (no DOM) and confirms the preset:
|
||||
* - replays a persisted snapshot onto the live frontend at attach time
|
||||
* - writes back to storage on every preference change
|
||||
* - honors the `keys` filter so unselected preferences are not persisted
|
||||
* - cleanly stops persisting when the detacher fires
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createActiveFrontend } from '$frontend';
|
||||
import { createActiveStorage, createMemoryAdapter } from '$storage';
|
||||
import { applyPersistFrontendPreferences } from '../presets/persist-frontend-preferences.ts';
|
||||
|
||||
function buildHarness(initialEntries: Record<string, string> = {}) {
|
||||
const storage = createActiveStorage({ adapter: createMemoryAdapter(initialEntries) });
|
||||
const frontend = createActiveFrontend({ applyDom: false });
|
||||
return {
|
||||
storage,
|
||||
frontend,
|
||||
dispose() {
|
||||
frontend.dispose();
|
||||
storage.dispose();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('applyPersistFrontendPreferences', () => {
|
||||
it('writes every preference change back to the storage entry', () => {
|
||||
const { storage, frontend, dispose } = buildHarness();
|
||||
const detach = applyPersistFrontendPreferences({ frontend, storage });
|
||||
|
||||
frontend.setTheme('dark');
|
||||
frontend.setDensity('compact');
|
||||
|
||||
const entry = storage.entry('frontend:preferences', {});
|
||||
const persisted = entry.get();
|
||||
expect(persisted).toMatchObject({ theme: 'dark', density: 'compact' });
|
||||
entry.dispose();
|
||||
|
||||
detach();
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('replays a persisted snapshot onto the live frontend at attach time', () => {
|
||||
// Prime the adapter with a snapshot the way storage.set would write
|
||||
// it: a JSON envelope wrapping the value.
|
||||
const initial = JSON.stringify({
|
||||
v: 1,
|
||||
d: JSON.stringify({ theme: 'noir', mode: 'dark', density: 'compact' })
|
||||
});
|
||||
const { storage, frontend, dispose } = buildHarness({
|
||||
'frontend:preferences': initial
|
||||
});
|
||||
|
||||
const detach = applyPersistFrontendPreferences({ frontend, storage });
|
||||
|
||||
expect(frontend.getTheme()).toBe('noir');
|
||||
expect(frontend.getMode()).toBe('dark');
|
||||
expect(frontend.getDensity()).toBe('compact');
|
||||
|
||||
detach();
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('respects the `keys` filter and only persists selected preferences', () => {
|
||||
const { storage, frontend, dispose } = buildHarness();
|
||||
const detach = applyPersistFrontendPreferences(
|
||||
{ frontend, storage },
|
||||
{ keys: ['theme'] }
|
||||
);
|
||||
|
||||
frontend.setTheme('dark');
|
||||
frontend.setDensity('compact');
|
||||
|
||||
const entry = storage.entry('frontend:preferences', {});
|
||||
const persisted = entry.get() as Record<string, unknown>;
|
||||
expect(persisted.theme).toBe('dark');
|
||||
expect(persisted.density).toBeUndefined();
|
||||
entry.dispose();
|
||||
|
||||
detach();
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('detacher stops persisting changes after it fires', () => {
|
||||
const { storage, frontend, dispose } = buildHarness();
|
||||
const detach = applyPersistFrontendPreferences({ frontend, storage });
|
||||
|
||||
frontend.setTheme('dark');
|
||||
const entry = storage.entry('frontend:preferences', {});
|
||||
const beforeDetach = (entry.get() as { theme?: string }).theme;
|
||||
expect(beforeDetach).toBe('dark');
|
||||
|
||||
detach();
|
||||
frontend.setTheme('light');
|
||||
|
||||
// Storage entry was not updated by the detached preset.
|
||||
const afterDetach = (entry.get() as { theme?: string }).theme;
|
||||
expect(afterDetach).toBe('dark');
|
||||
entry.dispose();
|
||||
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue