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