ADR: Persisted signals state layer
ADR: Persisted signals state layer#
Status: draft
Date: 2026-07-15
Context#
@kamod-ch/signals currently adds persistence to @preact/signals signals. The reactive runtime remains upstream Preact Signals; Kamod should not introduce a separate signal engine, Redux/Zustand-style store, server-state cache, or form-state layer.
The current package supports localStorage, sessionStorage, IndexedDB, cookies, and memory. Browser-only APIs are guarded by isBrowser, and SSR cookie usage is isolated with explicit CookieContext objects.
Current public API#
| Export | Purpose | Browser/SSR | Sync/async | Stability risk |
|---|---|---|---|---|
persistedSignal<T>(key, initialValue, options?) |
Creates or reuses a globally identified persisted signal. | Browser-safe; SSR-safe; cookie SSR requires cookieContext. |
Sync for local/session/cookie/memory; async hydration for IndexedDB. | Medium: global registry identity and option conflict semantics are observable. |
usePersistedSignal<T>(key, initialValue, options?) |
Component-scoped persisted signal controller with cleanup on unmount. | Browser/preact component usage; import is SSR-safe. | Same as persistedSignal; cleanup via hook effect. |
Medium: dependency tracking must stay aligned with options. |
createCookieContext(input?) |
Request/local cookie adapter for SSR and tests. | Browser-independent; request-scoped when caller creates per request. | Sync. | Low/medium: API is central for SSR isolation. |
serializeCookie(key, value, options?) |
Formats a Set-Cookie header value. |
SSR and browser-independent. | Sync. | Low: utility behavior is simple but security-sensitive. |
PersistedSignal<T> |
Signal<T> plus clear() and reset(). |
Type only. | N/A | Medium: methods are already public. |
PersistedSignalOptions<T> |
Storage, serializer, sync, cookie, and IndexedDB configuration. | Type only. | N/A | Medium: future options must avoid collisions. |
PersistedStorage |
Built-in storage discriminator. | Type only. | N/A | Low. |
CookieOptions |
Cookie attributes. | Type only. | N/A | Low. |
CookieContext |
Cookie read/write/subscribe adapter. | Type only. | N/A | Medium: request scope contract. |
IndexedDBOptions |
IndexedDB database/store/version options. | Type only. | N/A | Low. |
Implementation-only exports in persistedSignal.ts (createPersistedSignal, __private__) are not reachable from the package root and must remain private or be replaced by intentional public APIs.
Upstream Preact Signals comparison#
Installed development version: @preact/signals@2.9.2 with @preact/signals-core@1.14.3.
Upstream exports include:
signalcomputedeffectbatchuntrackedactioncreateModeluseModelSignalReadonlySignalModelModelConstructor- Preact hooks:
useSignal,useComputed,useSignalEffect
Current Kamod root exports do not re-export upstream primitives. This avoids collisions today, but users cannot import model/action primitives from @kamod-ch/signals yet. Future additions should be direct, tree-shakable re-exports and must not alter upstream semantics.
Recommended additive re-exports:
export {
signal,
computed,
effect,
batch,
untracked,
action,
createModel,
useModel,
Signal,
type ReadonlySignal,
type Model,
type ModelConstructor,
} from "@preact/signals";
Do not create Kamod-specific functions with upstream names.
Architecture findings#
Storage abstraction#
Storage is centralized in src/drivers.ts behind StorageDriver. This is the correct extension point. It supports sync and async drivers and optional subscribe. resolveDriver() falls back to memory when the preferred driver is unavailable.
Risks:
- Driver interface is internal and tied to
PersistedSignalOptions<T>, making later public custom drivers harder. - In-memory storage is a module-level singleton. This is fine for browser fallback and tests, but server request data must not rely on global memory for isolation.
Serialization#
Default serialization is raw JSON.stringify / JSON.parse in src/shared.ts. undefined is represented by __KAMOD_SIGNALS_UNDEFINED__ when removeOnUndefined: false.
Risks:
- No version envelope exists.
- Malformed values silently fall back to
initialValue, which is safe but not observable. - Future model persistence must not serialize computed values, functions, actions, or metadata.
Error handling#
Most storage, deserialization, async hydration, and sync errors are swallowed. This protects app runtime but makes failures hard to observe.
Needed: typed lifecycle events and optional logger hooks. Defaults should continue not throwing or causing unhandled promise rejections.
Hydration#
Sync drivers read before signal creation. IndexedDB starts with initialValue, then applies stored value unless the user changed the signal during hydration. This avoids overwriting user changes.
Needed: explicit hydration status for persisted models (idle | loading | ready | error) and SSR hydration priority rules.
SSR isolation#
Browser APIs are gated by isBrowser. Cookie SSR is isolated through cookieContextRegistry: WeakMap<object, Map<...>>, so request-scoped cookie contexts do not enter the strong global registry.
Risks:
- Non-cookie
persistedSignal()uses a global registry and is unsafe as request-local state on the server. - No generic request scope/dehydrate/hydrate API exists yet.
Cleanup/dispose#
usePersistedSignal() creates a controller and disposes it on unmount. Global persistedSignal() has no public dispose, by design. Internal controllers dispose effects and subscriptions.
Risks:
- Public model persistence will need explicit
dispose()and must guarantee no timers, effects, pending writes, or transports continue afterward.
TypeScript inference#
Signal values are generic and inferred from initialValue. Custom serialization/deserialization is typed. Current package root exports source .ts files via exports, main, and types; built declarations also exist from tsup.
Risks:
- Package metadata points
main/types/exportsto./src/index.ts, while builds writedist. This is source-friendly but unusual for npm packages and pack contents includesrconly. It should be reviewed before release hardening. - Future persisted model snapshot selection must preserve model factory argument inference and typed snapshot fields.
Package exports and tree shaking#
sideEffects: false is present. tsup externalizes @preact/signals, preact, and preact/hooks. There is only one root export path.
Needed later:
- Keep new upstream re-exports direct.
- If a development logger is added, expose it through an optional subpath such as
@kamod-ch/signals/devtools. - Avoid importing browser-only transports at the root in a way that touches globals.
Target architecture#
Persisted single signals#
Keep persistedSignal() and usePersistedSignal() backward compatible. Add versioning, lifecycle events, and SSR hydration in-place through additive options only.
Potential additive options:
type PersistedSignalOptions<T> = {
// existing fields stay unchanged
version?: number;
migrate?: PersistedMigration<T> | PersistedMigrationMap<T>;
validate?: (value: unknown) => value is T;
onError?: (error: PersistedSignalError) => void;
events?: PersistedEventTarget;
};
Persisted models#
Build on upstream createModel, action, and useModel. Persist only an explicit snapshot selected by the user.
Proposed API:
type MaybePromise<T> = T | Promise<T>;
type HydrationStatus = "idle" | "loading" | "ready" | "error";
type PersistedModelOptions<TModel extends object, TSnapshot> = {
key: string;
storage?: PersistedStorage;
serialize?: (snapshot: TSnapshot) => string;
deserialize?: (raw: string) => TSnapshot;
select: (model: Model<TModel>) => TSnapshot;
apply: (model: Model<TModel>, snapshot: TSnapshot) => MaybePromise<void>;
sync?: boolean;
cookie?: CookieOptions;
cookieContext?: CookieContext;
indexedDB?: IndexedDBOptions;
};
type PersistedModelControls = {
readonly hydration: ReadonlySignal<HydrationStatus>;
readonly error: ReadonlySignal<unknown | null>;
hydrate(): Promise<void>;
flush(): Promise<void>;
reset(): Promise<void>;
dispose(): void;
};
type PersistedModel<TModel extends object> = Model<TModel & PersistedModelControls>;
declare function createPersistedModel<TModel extends object, TSnapshot, TArgs extends unknown[]>(
options: PersistedModelOptions<TModel, TSnapshot>,
factory: (...args: TArgs) => TModel,
): ModelConstructor<TModel & PersistedModelControls, TArgs>;
select and apply are intentionally explicit. The implementation must avoid uncontrolled recursive object traversal.
Versioning and migrations#
Use a compact stable envelope for new writes:
type PersistedEnvelope<TSnapshot> = {
__kamod: "signals";
v: number;
data: TSnapshot;
};
Rules:
- Legacy payloads without an envelope read as version
0. - Unknown future versions must not be overwritten by older runtimes.
- Migration/validation failure preserves the original stored value.
- Reset-on-error and throw-on-error are explicit opt-ins only.
SSR dehydrate/hydrate#
Add request-local scopes instead of relying on global registries:
type PersistedScope = {
dehydrate(): PersistedDehydratedState;
dispose(): void;
};
declare function createPersistedScope(options?: { cookieContext?: CookieContext }): PersistedScope;
declare function dehydratePersisted(scope: PersistedScope): PersistedDehydratedState;
declare function hydratePersisted(state: PersistedDehydratedState): void;
declare function serializePersistedStateForHtml(state: PersistedDehydratedState): string;
Priority on the client should be documented and tested:
- Server snapshot supplied before model/signal persistence effects start.
- Cookie/storage snapshot.
- Default state.
HTML embedding must escape script-breakout sequences and other XSS-relevant characters.
Cross-tab synchronization#
Make synchronization opt-in for models and preserve current signal sync behavior unless changed in a major version.
type PersistedSyncTransport = {
post(message: PersistedSyncMessage): void;
subscribe(listener: (message: PersistedSyncMessage) => void): () => void;
dispose(): void;
};
Use BroadcastChannel when available, with localStorage storage event fallback. Messages need key/channel, source id, revision/version, and payload or payload reference. Ignore self-originated messages and suppress re-broadcast loops. Start with deterministic last-write-wins and a stable tie-breaker.
Lifecycle events and devtools foundation#
Add a small typed event model with redacted payloads by default:
type PersistedEventType =
| "hydrate:start"
| "hydrate:success"
| "hydrate:error"
| "persist:start"
| "persist:success"
| "persist:error"
| "migrate:start"
| "migrate:success"
| "migrate:error"
| "sync:receive"
| "sync:reject"
| "reset"
| "dispose";
Listeners must be isolated from persistence failures. A dev logger should live in a tree-shakable subpath and remain inactive in production by default.
Backward compatibility risks and mitigations#
| Risk | Mitigation |
|---|---|
| Upstream names collide with Kamod names. | Only direct re-export upstream signal, computed, effect, batch, untracked, createModel, action, and useModel; never wrap them under the same names. |
| Existing unversioned persisted data becomes unreadable. | Treat no-envelope payloads as legacy version 0; keep current custom deserialize behavior. |
| Older runtime overwrites newer envelope. | Detect future v and block writes until explicit reset or supported migration. |
| Global state leaks across SSR requests. | Introduce request scopes; document avoiding global non-cookie persisted stores on the server. |
| Async hydration overwrites local changes. | Keep current pending-change guard and formalize it for models. |
| Errors become breaking if thrown. | Keep non-throwing defaults; expose errors through signals/events/callbacks. |
| Bundle grows from devtools or sync transports. | Use optional subpaths and lazy browser transport resolution. |
Implementation order#
- Directly re-export upstream Preact Signals primitives and model/action APIs; add type and runtime smoke tests.
- Add
createPersistedModelwith explicit snapshot selection, hydration status,hydrate,flush,reset, anddisposeusing existing drivers. - Add version envelopes, legacy read support, migration, validation, and future-version protection.
- Add request scopes plus XSS-safe dehydrate/hydrate helpers.
- Add optional cross-tab transport with BroadcastChannel/localStorage fallback.
- Add lifecycle event subscriptions and an optional dev logger subpath.
- Expand docs, examples, pack dry-run checks, and release notes.
Each step should be independently testable and additive.