Project
Angular State Hydration
A reusable Angular library that captures and restores whole-application state — signal stores, DOM form state, browser state, and Mapbox GL maps — behind a plugin API with a swappable persistence backend.
@demo/application-state
Reusable Angular library for capturing and restoring application state: signal stores, DOM form/detail state, browser state (localStorage/sessionStorage/scroll/URL), and Mapbox GL JS map state. Ships with an IndexedDB-backed store by default, but persistence is a swappable seam (see Swapping to a real database).
This document contains the full source of every file in projects/application-state/src/, in one place, followed by what a consuming app has to add on top (the demo side), and a worked example of swapping the storage backend for a real database.
Architecture at a glance
- Resources are the unit of capture/restore. Anything registered with
ResourceRegistry— a signal store, a DOM subtree, a Mapbox map — is a resource, keyed by a string you choose. - Plugins (
ResourceHydrationPlugin) know how to capture/restore one kind of resource.ResourceRegistry.register(key, target)picks the first plugin whosesupports(target)matches. HydrationRuntimeorchestrates:capture()asks every registered resource's plugin for its current state, hashes it, and persists only what changed;restore()reads a snapshot and applies it to whatever resources are currently registered, skipping (not failing) anything with no live target.SnapshotStoreis the persistence seam —HydrationRuntimeonly ever talks to this interface, never to IndexedDB directly.AutoHydrationCoordinatoris optional, config-driven automation on top of the above: restore on app init, and debounced auto-capture whenever a resource's own change signal fires. It has no knowledge of any specific resource type — it only watchesResourceRegistry.
Library source
public-api.ts
Everything importable from 'application-state' — the whole surface area of the package.
export * from './lib/core/models';
export * from './lib/core/contracts';
export * from './lib/core/tokens';
export * from './lib/core/resource-registry';
export * from './lib/core/exclusion-policy';
export * from './lib/core/hashing';
export * from './lib/core/signal-reflection';
export * from './lib/core/hydration-runtime';
export * from './lib/core/auto-hydration.coordinator';
export * from './lib/core/providers';
export * from './lib/storage/indexeddb-snapshot.store';
export * from './lib/storage/memory-snapshot.store';
export * from './lib/plugins/dom-state.plugin';
export * from './lib/plugins/state-target.plugin';
export * from './lib/plugins/signal-store.plugin';
export * from './lib/plugins/mapbox/mapbox.models';
export * from './lib/plugins/mapbox/mapbox-state-tracker';
export * from './lib/plugins/mapbox/mapbox-setup.service';
export * from './lib/plugins/mapbox/mapbox-hydration.plugin';
core/models.ts
Plain data types — ApplicationSnapshot, ResourceSnapshot (carries a contentHash), CapturedResource<T> (what a plugin returns before the runtime attaches the hash), SnapshotEnvelope, RestoreReport, RestoreOutcome, CaptureReport.
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
export interface CaptureContext { readonly applicationId: string; }
export interface RestoreContext { readonly applicationId: string; }
export interface ResourceSnapshot<TState = unknown> {
readonly resourceId: string;
readonly pluginId: string;
readonly targetKey: string;
readonly state: TState;
/** Content hash of `state`, used to detect whether this resource changed since the last capture. */
readonly contentHash: string;
}
/** What a plugin's `capture()` produces, before `HydrationRuntime` computes and attaches `contentHash`. */
export type CapturedResource<TState = unknown> = Omit<ResourceSnapshot<TState>, 'contentHash'>;
export interface BrowserStateSnapshot {
readonly localStorage: Readonly<Record<string, string>>;
readonly sessionStorage: Readonly<Record<string, string>>;
readonly scrollX: number;
readonly scrollY: number;
readonly location: { pathname: string; search: string; hash: string; historyState: unknown };
}
export interface ApplicationSnapshot {
readonly schemaVersion: 1;
readonly applicationId: string;
readonly applicationVersion: string;
readonly createdAt: string;
readonly browser: BrowserStateSnapshot;
readonly resources: readonly ResourceSnapshot[];
readonly metadata: Readonly<Record<string, JsonValue>>;
}
export interface SnapshotEnvelope { readonly id: string; readonly snapshot: ApplicationSnapshot; }
export interface RestoreReport { readonly restoredResources: readonly string[]; readonly skippedResources: readonly string[]; readonly warnings: readonly string[]; }
export interface RestoreOutcome { readonly warnings?: readonly string[]; }
export interface CaptureReport {
readonly snapshot: ApplicationSnapshot;
/** Keys of resources whose content hash differed from the previous capture (or had none) and were persisted. */
readonly changedResources: readonly string[];
/** Keys of resources whose content hash matched the previous capture; not re-persisted. */
readonly unchangedResources: readonly string[];
}
core/contracts.ts
The three interfaces everything else implements.
import { CapturedResource, CaptureContext, ResourceSnapshot, RestoreContext, RestoreOutcome, SnapshotEnvelope } from './models';
export interface ResourceHydrationPlugin<TTarget = unknown, TState = unknown> {
readonly pluginId: string;
supports(target: unknown): target is TTarget;
capture(targetKey: string, target: TTarget, context: CaptureContext): Promise<CapturedResource<TState>>;
restore(target: TTarget, snapshot: ResourceSnapshot<TState>, context: RestoreContext): Promise<void | RestoreOutcome>;
}
export interface SnapshotStore {
/**
* Persists `envelope`. `changedResourceKeys` lists which `envelope.snapshot.resources` entries
* actually changed since the last save for this id (by content hash) — a store implementation
* MAY use this to skip rewriting unchanged resource records; a naive implementation may ignore
* it and persist the whole envelope every time.
*/
save(envelope: SnapshotEnvelope, changedResourceKeys: readonly string[]): Promise<void>;
load(id: string): Promise<SnapshotEnvelope | undefined>;
delete(id: string): Promise<void>;
}
export interface StateTarget<TState = unknown> {
readonly capture: () => TState | Promise<TState>;
readonly restore: (state: TState) => void | Promise<void>;
}
export interface ExclusionRule {
readonly id: string;
readonly reason: string;
matches(value: unknown, path: readonly string[]): boolean;
}
core/tokens.ts
DI tokens and the ApplicationStateConfig/AutoHydrationConfig types.
import { InjectionToken } from '@angular/core';
import { ResourceHydrationPlugin, SnapshotStore } from './contracts';
export const RESOURCE_HYDRATION_PLUGINS = new InjectionToken<readonly ResourceHydrationPlugin[]>('RESOURCE_HYDRATION_PLUGINS');
export const SNAPSHOT_STORE = new InjectionToken<SnapshotStore>('SNAPSHOT_STORE');
export const APPLICATION_STATE_CONFIG = new InjectionToken<ApplicationStateConfig>('APPLICATION_STATE_CONFIG');
export interface ApplicationStateConfig {
readonly applicationId: string;
readonly applicationVersion: string;
readonly databaseName?: string;
/** Opt-in automatic restore-on-init and debounced auto-capture; omit to get today's fully-manual behavior. */
readonly autoHydration?: AutoHydrationConfig;
/** Which built-in plugins `provideApplicationState` activates. Omit entirely, or omit a given key, to enable it — set a key to `false` to opt out. */
readonly builtInPlugins?: BuiltInPluginsConfig;
}
export interface AutoHydrationConfig {
/** Restore the default snapshot once the app finishes its initial render (and again whenever a new resource registers), if one exists. Default: `false`. */
readonly restoreOnInit?: boolean;
/** Debounce, in ms, between a registered resource's `changeSignal` firing and an automatic capture. Default: `800`. */
readonly autoCaptureDebounceMs?: number;
}
export interface BuiltInPluginsConfig {
/** `DomStatePlugin` — form input/`<details>` DOM state. Default: enabled. */
readonly domState?: boolean;
/** `StateTargetPlugin` — manual `{ capture, restore }` wrapper targets. Default: enabled. */
readonly stateTarget?: boolean;
/** `SignalStorePlugin` — reflects over an object's own `WritableSignal` properties. Default: enabled. */
readonly signalStore?: boolean;
}
core/resource-registry.ts
ResourceRegistry (where you register resources) and PluginRegistry (where plugins are looked up).
import { Injectable, Signal, signal } from '@angular/core';
import { ResourceHydrationPlugin } from './contracts';
export interface RegisterOptions {
/**
* A cheap "something changed" pulse for this resource (e.g. a computed signal or a tracker's
* own version counter). Optional — a resource without one simply doesn't contribute to
* `AutoHydrationCoordinator`'s auto-capture trigger, but is still captured/restored normally.
*/
readonly changeSignal?: Signal<unknown>;
}
interface RegisteredResource {
readonly key: string;
readonly target: unknown;
readonly plugin: ResourceHydrationPlugin;
readonly changeSignal?: Signal<unknown>;
}
@Injectable({ providedIn: 'root' })
export class ResourceRegistry {
private readonly resources = new Map<string, RegisteredResource>();
/** Bumps whenever a resource registers or unregisters — lets consumers react to the registry's membership changing (e.g. a resource that registers asynchronously, like a map that loads after a network call). */
readonly registryVersion = signal(0);
constructor(private readonly plugins: PluginRegistry) {}
register(key: string, target: unknown, options?: RegisterOptions): () => void {
const plugin = this.plugins.find(target);
if (!plugin) throw new Error(`No hydration plugin supports resource '${key}'.`);
this.resources.set(key, { key, target, plugin, changeSignal: options?.changeSignal });
this.registryVersion.update((value) => value + 1);
return () => {
if (this.resources.delete(key)) this.registryVersion.update((value) => value + 1);
};
}
get(key: string): RegisteredResource | undefined { return this.resources.get(key); }
getAll(): readonly RegisteredResource[] { return [...this.resources.values()]; }
}
@Injectable({ providedIn: 'root' })
export class PluginRegistry {
private readonly plugins: ResourceHydrationPlugin[] = [];
register(plugin: ResourceHydrationPlugin): void {
if (!this.plugins.some(existing => existing.pluginId === plugin.pluginId)) this.plugins.push(plugin);
}
find(target: unknown): ResourceHydrationPlugin | undefined { return this.plugins.find(plugin => plugin.supports(target)); }
}
core/hashing.ts
Deterministic, synchronous, non-cryptographic content hash used to detect whether a resource's captured state actually changed.
/**
* Computes a deterministic content hash for change detection (not a security/cryptographic hash).
* Object key order never affects the result. Synchronous and fast enough to run on every
* captured resource, even for large state payloads (e.g. a full Mapbox style spec).
*/
export function computeContentHash(value: unknown): string {
return cyrb53(canonicalize(value)).toString(16);
}
function canonicalize(value: unknown): string {
return JSON.stringify(sortKeysDeep(value));
}
function sortKeysDeep(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortKeysDeep);
if (value !== null && typeof value === 'object') {
return Object.keys(value as Record<string, unknown>).sort().reduce<Record<string, unknown>>((acc, key) => {
acc[key] = sortKeysDeep((value as Record<string, unknown>)[key]);
return acc;
}, {});
}
return value;
}
/** cyrb53 (public domain) — fast 53-bit non-cryptographic string hash. */
function cyrb53(str: string, seed = 0): number {
let h1 = 0xdeadbeef ^ seed;
let h2 = 0x41c6ce57 ^ seed;
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
}
core/signal-reflection.ts
Shared by SignalStorePlugin and provideTrackedStore — the one place that knows how to find writable signals on an arbitrary object.
import { WritableSignal, isSignal } from '@angular/core';
/**
* Equivalent to `@angular/core`'s `isWritableSignal`, reimplemented locally: that function is
* `@publicApi` only from Angular 21.1, and this library supports Angular >=19.1.3. Mirrors its
* actual runtime check (`isSignal(value) && typeof value.set === 'function'`).
*/
export function isWritableSignal(value: unknown): value is WritableSignal<unknown> {
return isSignal(value) && typeof (value as Partial<WritableSignal<unknown>>).set === 'function';
}
/** Own enumerable property keys of `target` whose value is a `WritableSignal`. Used by `SignalStorePlugin` and `provideTrackedStore`. */
export function discoverWritableSignalKeys(target: object): string[] {
return Object.keys(target).filter((key) => isWritableSignal((target as Record<string, unknown>)[key]));
}
core/hydration-runtime.ts
The main service: capture, restore, restoreIfAvailable, clear, plus busy/lastMessage signals for UI binding.
import { Inject, Injectable, signal } from "@angular/core";
import { SnapshotStore } from "./contracts";
import { ApplicationSnapshot, CaptureReport, ResourceSnapshot, RestoreReport } from "./models";
import { BrowserStateService } from "./browser-state.service";
import { computeContentHash } from "./hashing";
import { ResourceRegistry } from "./resource-registry";
import {
APPLICATION_STATE_CONFIG,
ApplicationStateConfig,
SNAPSHOT_STORE,
} from "./tokens";
@Injectable({ providedIn: "root" })
export class HydrationRuntime {
readonly busy = signal(false);
readonly lastMessage = signal("Ready");
constructor(
private readonly browser: BrowserStateService,
private readonly registry: ResourceRegistry,
@Inject(SNAPSHOT_STORE) private readonly store: SnapshotStore,
@Inject(APPLICATION_STATE_CONFIG)
private readonly config: ApplicationStateConfig,
) {}
async capture(id = "default"): Promise<CaptureReport> {
this.busy.set(true);
try {
const previous = await this.store.load(id);
const previousByKey = new Map(
previous?.snapshot.resources.map((resource) => [resource.targetKey, resource]),
);
const resources: ResourceSnapshot[] = [];
const changed: string[] = [];
const unchanged: string[] = [];
for (const entry of this.registry.getAll()) {
const captured = await entry.plugin.capture(entry.key, entry.target, {
applicationId: this.config.applicationId,
});
const contentHash = computeContentHash(captured.state);
const prior = previousByKey.get(entry.key);
if (prior && prior.pluginId === captured.pluginId && prior.contentHash === contentHash) {
resources.push(prior);
unchanged.push(entry.key);
} else {
resources.push({ ...captured, contentHash });
changed.push(entry.key);
}
}
const snapshot: ApplicationSnapshot = {
schemaVersion: 1,
applicationId: this.config.applicationId,
applicationVersion: this.config.applicationVersion,
createdAt: new Date().toISOString(),
browser: this.browser.capture(),
resources,
metadata: { resourceCount: resources.length },
};
await this.store.save({ id, snapshot }, changed);
this.lastMessage.set(
changed.length === 0 && previous
? `No changes to capture (${resources.length} resource(s) already up to date).`
: `Captured ${resources.length} resource(s) (${changed.length} updated, ${unchanged.length} unchanged).`,
);
return { snapshot, changedResources: changed, unchangedResources: unchanged };
} finally {
this.busy.set(false);
}
}
async restore(id = "default"): Promise<RestoreReport> {
this.busy.set(true);
const restored: string[] = [],
skipped: string[] = [],
warnings: string[] = [];
try {
const envelope = await this.store.load(id);
if (!envelope) throw new Error(`Snapshot '${id}' was not found.`);
this.browser.restore(envelope.snapshot.browser);
for (const snap of envelope.snapshot.resources) {
const live = this.registry.get(snap.targetKey);
if (!live || live.plugin.pluginId !== snap.pluginId) {
skipped.push(snap.targetKey);
warnings.push(`No compatible live target for '${snap.targetKey}'.`);
continue;
}
try {
const outcome = await live.plugin.restore(live.target, snap, {
applicationId: this.config.applicationId,
});
restored.push(snap.targetKey);
for (const warning of outcome?.warnings ?? []) {
warnings.push(`[${snap.targetKey}] ${warning}`);
}
} catch (error) {
skipped.push(snap.targetKey);
warnings.push(
`Failed to restore '${snap.targetKey}': ${error instanceof Error ? error.message : String(error)}`,
);
}
}
this.lastMessage.set(`Restored ${restored.length} resource(s).`);
return {
restoredResources: restored,
skippedResources: skipped,
warnings,
};
} finally {
this.busy.set(false);
}
}
async clear(id = "default"): Promise<void> {
await this.store.delete(id);
this.lastMessage.set("Snapshot deleted.");
}
/** Restores the snapshot only if one exists; returns `undefined` instead of throwing when there is nothing to restore. */
async restoreIfAvailable(id = "default"): Promise<RestoreReport | undefined> {
const envelope = await this.store.load(id);
if (!envelope) return undefined;
return this.restore(id);
}
}
core/auto-hydration.coordinator.ts
Activates ApplicationStateConfig.autoHydration. Has no knowledge of any specific resource type — only watches ResourceRegistry.
import { Inject, Injectable, afterNextRender, effect, untracked } from '@angular/core';
import { HydrationRuntime } from './hydration-runtime';
import { ResourceRegistry } from './resource-registry';
import { APPLICATION_STATE_CONFIG, ApplicationStateConfig } from './tokens';
const DEFAULT_AUTO_CAPTURE_DEBOUNCE_MS = 800;
/**
* Activates `ApplicationStateConfig.autoHydration` (restore-on-init and debounced auto-capture)
* purely by watching `ResourceRegistry` — it has no knowledge of any particular resource type.
* Consuming apps opt in entirely through DI: pass `autoHydration` to `provideApplicationState()`
* and optionally a `changeSignal` when calling `ResourceRegistry.register()`; no app-level
* `afterNextRender`/`effect` wiring is needed.
*
* Eagerly instantiated by `provideApplicationState()`'s environment initializer.
*/
@Injectable({ providedIn: 'root' })
export class AutoHydrationCoordinator {
private autoCaptureHandle?: ReturnType<typeof setTimeout>;
private autoCaptureArmed = false;
private lastSnapshot = new Map<string, unknown>();
private restoreEffectArmed = false;
private restoreInFlight = false;
constructor(
private readonly runtime: HydrationRuntime,
private readonly registry: ResourceRegistry,
@Inject(APPLICATION_STATE_CONFIG) config: ApplicationStateConfig,
) {
const options = config.autoHydration;
if (!options) return;
if (options.restoreOnInit) {
// First attempt: after the initial render, so synchronously-registered resources are ready.
afterNextRender({ read: () => void this.attemptRestore() });
// Later attempts: whenever the registry's membership changes (e.g. a resource that only
// registers once an async dependency, like a map, finishes loading).
effect(() => {
this.registry.registryVersion();
if (!this.restoreEffectArmed) { this.restoreEffectArmed = true; return; }
untracked(() => void this.attemptRestore());
});
}
const debounceMs = options.autoCaptureDebounceMs ?? DEFAULT_AUTO_CAPTURE_DEBOUNCE_MS;
effect(() => {
this.registry.registryVersion();
const current = this.snapshotChangeSignals();
if (!this.autoCaptureArmed) { this.autoCaptureArmed = true; this.lastSnapshot = current; return; }
// A resource newly registering (or unregistering) isn't itself a content change — it's
// handled by the restore-retry effect above, which will pick up any persisted content for
// it. Only an *existing* resource's own changeSignal actually moving should schedule a
// capture; otherwise every late-registering resource (e.g. an async-loaded map) would cause
// a redundant capture cycle that clobbers a more informative "Restored..." status message.
if (!this.hasRealChange(current)) { this.lastSnapshot = current; return; }
clearTimeout(this.autoCaptureHandle);
this.autoCaptureHandle = setTimeout(() => {
if (untracked(() => this.runtime.busy())) return;
this.lastSnapshot = untracked(() => this.snapshotChangeSignals());
this.runtime.capture().catch((error) => console.error('Auto-capture failed.', error));
}, debounceMs);
});
}
private async attemptRestore(): Promise<void> {
if (this.restoreInFlight) return;
this.restoreInFlight = true;
try {
await this.runtime.restoreIfAvailable();
} catch (error) {
console.error('Auto-restore failed.', error);
} finally {
this.restoreInFlight = false;
}
}
private hasRealChange(current: ReadonlyMap<string, unknown>): boolean {
for (const [key, value] of current) {
if (this.lastSnapshot.has(key) && this.lastSnapshot.get(key) !== value) return true;
}
return false;
}
/** A resource without a `changeSignal` is included (so restore-retry/dedup still see it) but its value never varies, so it never counts as a "real change" on its own. */
private snapshotChangeSignals(): Map<string, unknown> {
return new Map(this.registry.getAll().map((resource) => [resource.key, resource.changeSignal?.() ?? null]));
}
}
core/browser-state.service.ts
Captures/restores localStorage, sessionStorage, scroll position, and location. Part of every snapshot automatically — not a registered resource.
import { Injectable } from '@angular/core';
import { BrowserStateSnapshot } from './models';
@Injectable({ providedIn: 'root' })
export class BrowserStateService {
capture(): BrowserStateSnapshot {
return { localStorage: this.read(localStorage), sessionStorage: this.read(sessionStorage), scrollX: scrollX, scrollY: scrollY,
location: { pathname: location.pathname, search: location.search, hash: location.hash, historyState: history.state } };
}
restore(snapshot: BrowserStateSnapshot): void {
this.write(localStorage, snapshot.localStorage); this.write(sessionStorage, snapshot.sessionStorage);
queueMicrotask(() => scrollTo(snapshot.scrollX, snapshot.scrollY));
}
private read(storage: Storage): Record<string,string> { const out: Record<string,string> = {}; for(let i=0;i<storage.length;i++){ const key=storage.key(i); if(key) out[key]=storage.getItem(key) ?? ''; } return out; }
private write(storage: Storage, values: Readonly<Record<string,string>>): void { storage.clear(); Object.entries(values).forEach(([k,v]) => storage.setItem(k,v)); }
}
core/exclusion-policy.ts
ExclusionPolicy plus excludeByPath/excludeByConstructor helper factories, for plugins that need to skip certain values/paths (used by the DOM plugin's data-ignore-state/ignoreSelectors).
import { Injectable } from '@angular/core';
import { ExclusionRule } from './contracts';
@Injectable({ providedIn: 'root' })
export class ExclusionPolicy {
private readonly rules: ExclusionRule[] = [];
register(rule: ExclusionRule): void { if (!this.rules.some(existing => existing.id === rule.id)) this.rules.push(rule); }
evaluate(value: unknown, path: readonly string[] = []): ExclusionRule | undefined { return this.rules.find(rule => rule.matches(value, path)); }
}
export function excludeByPath(id: string, expression: RegExp, reason: string): ExclusionRule {
return { id, reason, matches: (_value, path) => expression.test(path.join('.')) };
}
export function excludeByConstructor(id: string, ctor: Function, reason: string): ExclusionRule {
return { id, reason, matches: value => typeof value === 'object' && value !== null && value instanceof (ctor as new (...args: never[]) => object) };
}
core/providers.ts
provideApplicationState() wires everything up. provideTrackedStore() registers a providedIn: 'root' store with ResourceRegistry automatically at bootstrap — see Registering root-provided stores without any component below for why this exists.
import { EnvironmentProviders, Provider, Signal, Type, computed, inject, makeEnvironmentProviders, provideEnvironmentInitializer } from '@angular/core';
import { ResourceHydrationPlugin } from './contracts';
import { PluginRegistry, RegisterOptions, ResourceRegistry } from './resource-registry';
import { APPLICATION_STATE_CONFIG, ApplicationStateConfig, SNAPSHOT_STORE } from './tokens';
import { discoverWritableSignalKeys } from './signal-reflection';
import { IndexedDbSnapshotStore } from '../storage/indexeddb-snapshot.store';
import { StateTargetPlugin } from '../plugins/state-target.plugin';
import { DomStatePlugin } from '../plugins/dom-state.plugin';
import { SignalStorePlugin } from '../plugins/signal-store.plugin';
import { AutoHydrationCoordinator } from './auto-hydration.coordinator';
/** Default `SnapshotStore` provider used when `provideApplicationState()` isn't given a `storageProvider`. */
export const INDEXED_DB_SNAPSHOT_STORE_PROVIDER: Provider = { provide: SNAPSHOT_STORE, useClass: IndexedDbSnapshotStore };
/**
* @param config Plain, non-DI configuration (application id/version, auto-hydration behavior,
* which built-in plugins to activate, etc).
* @param pluginTypes Resource hydration plugins to register (e.g. `MapboxHydrationPlugin`).
* @param storageProvider Where snapshots are persisted. This is the seam for swapping storage
* backends: `SnapshotStore` (see `contracts.ts`) is the interface, `SNAPSHOT_STORE` is the DI
* token. Defaults to `INDEXED_DB_SNAPSHOT_STORE_PROVIDER`; pass e.g.
* `{ provide: SNAPSHOT_STORE, useClass: MyRestSnapshotStore }` (or `useFactory`/`useValue`) to
* point at a real backend database instead. See `MemorySnapshotStore` for a minimal reference
* implementation of the interface.
*/
export function provideApplicationState(
config: ApplicationStateConfig,
pluginTypes: readonly Type<ResourceHydrationPlugin>[] = [],
storageProvider: Provider = INDEXED_DB_SNAPSHOT_STORE_PROVIDER
): EnvironmentProviders {
const builtIns = config.builtInPlugins;
const useStateTarget = builtIns?.stateTarget !== false;
const useDomState = builtIns?.domState !== false;
const useSignalStore = builtIns?.signalStore !== false;
const builtInPluginProviders: Provider[] = [
...(useStateTarget ? [StateTargetPlugin] : []),
...(useDomState ? [DomStatePlugin] : []),
...(useSignalStore ? [SignalStorePlugin] : []),
];
const pluginProviders: Provider[] = [...pluginTypes];
return makeEnvironmentProviders([
{ provide: APPLICATION_STATE_CONFIG, useValue: config },
storageProvider,
...builtInPluginProviders,
...pluginProviders,
provideEnvironmentInitializer(() => {
const registry = inject(PluginRegistry);
if (useStateTarget) registry.register(inject(StateTargetPlugin));
if (useDomState) registry.register(inject(DomStatePlugin));
if (useSignalStore) registry.register(inject(SignalStorePlugin));
for (const pluginType of pluginTypes) registry.register(inject(pluginType));
// Eagerly instantiate so it can activate config.autoHydration, if set — otherwise this
// tree-shakable, providedIn: 'root' service would never be constructed.
inject(AutoHydrationCoordinator);
})
]);
}
export interface TrackedStoreOptions<T> {
/** `ResourceRegistry` key. Defaults to the class name (e.g. `MyStore`) — set explicitly if you register more than one instance of the same class, or want a key stable across minification. */
readonly key?: string;
/** Override the automatic "watch every writable signal" change pulse — e.g. to exclude a field from the auto-capture trigger without excluding it from capture itself. */
readonly changeSignal?: (store: T) => Signal<unknown>;
}
/**
* Registers a `providedIn: 'root'` (or any environment-provided) store with `ResourceRegistry`
* automatically at app bootstrap — no component has to inject `ResourceRegistry` or call
* `register()` for it. `ResourceRegistry.register()` still requires *some* explicit call naming
* every resource you want tracked (there's no public Angular API to enumerate "every injectable in
* my app," so full auto-discovery isn't possible) — this just moves that one call from component
* code into DI configuration, which is where a `providedIn: 'root'` store's lifetime already lives.
*
* ```ts
* providers: [
* provideApplicationState(config, plugins),
* provideTrackedStore(MyStore), // key defaults to 'MyStore'
* provideTrackedStore(AnotherStore, { key: 'another' }),
* ]
* ```
*/
export function provideTrackedStore<T extends object>(
storeType: Type<T>,
options: TrackedStoreOptions<T> = {}
): EnvironmentProviders {
return makeEnvironmentProviders([
provideEnvironmentInitializer(() => {
const registry = inject(ResourceRegistry);
const store = inject(storeType);
const changeSignal = options.changeSignal
? options.changeSignal(store)
: computed(() => JSON.stringify(discoverWritableSignalKeys(store).map((key) => (store as Record<string, () => unknown>)[key]())));
const registerOptions: RegisterOptions = { changeSignal };
registry.register(options.key ?? storeType.name, store, registerOptions);
})
]);
}
storage/indexeddb-snapshot.store.ts
Default SnapshotStore. Single object store: one manifest row plus one row per resource, so save() only rewrites what actually changed.
import { Inject, Injectable } from '@angular/core';
import { SnapshotStore } from '../core/contracts';
import { ApplicationSnapshot, ResourceSnapshot, SnapshotEnvelope } from '../core/models';
import { APPLICATION_STATE_CONFIG, ApplicationStateConfig } from '../core/tokens';
const DB_VERSION = 3;
const STORE_NAME = 'snapshot-entries';
const ID_INDEX = 'id';
/** Reserved `entryKey` for the manifest row; a resource's own `targetKey` can never collide with this. */
const MANIFEST_ENTRY_KEY = '__manifest__';
interface ManifestEntry {
readonly id: string;
readonly entryKey: typeof MANIFEST_ENTRY_KEY;
readonly schemaVersion: ApplicationSnapshot['schemaVersion'];
readonly applicationId: string;
readonly applicationVersion: string;
readonly createdAt: string;
readonly browser: ApplicationSnapshot['browser'];
readonly metadata: ApplicationSnapshot['metadata'];
readonly resourceKeys: readonly string[];
}
interface ResourceEntry extends ResourceSnapshot {
readonly id: string;
readonly entryKey: string;
}
/**
* Persists everything for a snapshot id in a single IndexedDB object store: one manifest row
* (compound key `[id, '__manifest__']`, holding browser state/metadata/the list of resource keys)
* plus one row per resource (compound key `[id, targetKey]`). `save()` only rewrites resource rows
* named in `changedResourceKeys` — unchanged resources are left untouched on disk — so capturing a
* large application with many resources doesn't rewrite the whole snapshot on every change.
*/
@Injectable()
export class IndexedDbSnapshotStore implements SnapshotStore {
constructor(@Inject(APPLICATION_STATE_CONFIG) private readonly config: ApplicationStateConfig) {}
async save(envelope: SnapshotEnvelope, changedResourceKeys: readonly string[]): Promise<void> {
const db = await this.open();
const previousManifest = await this.request<ManifestEntry | undefined>(
db.transaction(STORE_NAME).objectStore(STORE_NAME).get([envelope.id, MANIFEST_ENTRY_KEY]),
);
const manifest: ManifestEntry = {
id: envelope.id,
entryKey: MANIFEST_ENTRY_KEY,
schemaVersion: envelope.snapshot.schemaVersion,
applicationId: envelope.snapshot.applicationId,
applicationVersion: envelope.snapshot.applicationVersion,
createdAt: envelope.snapshot.createdAt,
browser: envelope.snapshot.browser,
metadata: envelope.snapshot.metadata,
resourceKeys: envelope.snapshot.resources.map((resource) => resource.targetKey),
};
const currentKeys = new Set(manifest.resourceKeys);
const staleKeys = (previousManifest?.resourceKeys ?? []).filter((key) => !currentKeys.has(key));
const changed = new Set(changedResourceKeys);
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
store.put(manifest);
for (const resource of envelope.snapshot.resources) {
if (changed.has(resource.targetKey)) {
const entry: ResourceEntry = { ...resource, id: envelope.id, entryKey: resource.targetKey };
store.put(entry);
}
}
for (const staleKey of staleKeys) store.delete([envelope.id, staleKey]);
await this.completeTransaction(tx);
}
async load(id: string): Promise<SnapshotEnvelope | undefined> {
const db = await this.open();
const store = db.transaction(STORE_NAME).objectStore(STORE_NAME);
const manifest = await this.request<ManifestEntry | undefined>(store.get([id, MANIFEST_ENTRY_KEY]));
if (!manifest) return undefined;
const entries = await Promise.all(
manifest.resourceKeys.map((key) => this.request<ResourceEntry | undefined>(store.get([id, key]))),
);
const resources: ResourceSnapshot[] = entries
.filter((entry): entry is ResourceEntry => entry !== undefined)
.map(({ id: _id, entryKey: _entryKey, ...resource }) => resource);
const snapshot: ApplicationSnapshot = {
schemaVersion: manifest.schemaVersion,
applicationId: manifest.applicationId,
applicationVersion: manifest.applicationVersion,
createdAt: manifest.createdAt,
browser: manifest.browser,
metadata: manifest.metadata,
resources,
};
return { id, snapshot };
}
async delete(id: string): Promise<void> {
const db = await this.open();
const tx = db.transaction(STORE_NAME, 'readwrite');
const index = tx.objectStore(STORE_NAME).index(ID_INDEX);
const request = index.openCursor(IDBKeyRange.only(id));
request.onsuccess = () => {
const cursor = request.result;
if (cursor) { cursor.delete(); cursor.continue(); }
};
await this.completeTransaction(tx);
}
private open(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(this.config.databaseName ?? `${this.config.applicationId}-state`, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
// Superseded by the single-table schema below; disposable snapshot data, no migration needed.
for (const legacyStore of ['snapshots', 'snapshot-manifests', 'snapshot-resources']) {
if (db.objectStoreNames.contains(legacyStore)) db.deleteObjectStore(legacyStore);
}
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: ['id', 'entryKey'] });
store.createIndex(ID_INDEX, 'id');
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
private request<T = unknown>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
private completeTransaction(tx: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error ?? new Error('IndexedDB transaction aborted.'));
});
}
}
storage/memory-snapshot.store.ts
Minimal in-process reference SnapshotStore. See Swapping to a real database.
import { Injectable } from '@angular/core';
import { SnapshotStore } from '../core/contracts';
import { SnapshotEnvelope } from '../core/models';
/**
* Minimal in-process reference implementation of `SnapshotStore` — snapshots live only in memory
* and are lost on reload/process exit. Useful for unit tests and SSR/Node environments without
* IndexedDB, and as a starting template for a real backend-database-backed implementation: swap
* this class for one that calls your API/SQL/Firebase/etc, then pass it as `provideApplicationState`'s
* `storageProvider` argument — the rest of the library only ever depends on the `SnapshotStore`
* interface, never on a concrete storage technology.
*/
@Injectable()
export class MemorySnapshotStore implements SnapshotStore {
private readonly envelopes = new Map<string, SnapshotEnvelope>();
async save(envelope: SnapshotEnvelope, _changedResourceKeys: readonly string[]): Promise<void> {
this.envelopes.set(envelope.id, structuredClone(envelope));
}
async load(id: string): Promise<SnapshotEnvelope | undefined> {
const envelope = this.envelopes.get(id);
return envelope ? structuredClone(envelope) : undefined;
}
async delete(id: string): Promise<void> {
this.envelopes.delete(id);
}
}
plugins/dom-selector.ts
Resolves a state key to a live DOM element, trying data-state-key → id → name, only trusting a strategy that matches exactly one element.
export interface ElementSelectorResult {
readonly element: HTMLElement | null;
readonly selector?: string;
readonly ambiguous?: boolean;
readonly warning?: string;
}
type SelectorStrategy = (key: string) => string;
const STRATEGIES: readonly SelectorStrategy[] = [
key => `[data-state-key="${CSS.escape(key)}"]`,
key => `#${CSS.escape(key)}`,
key => `[name="${CSS.escape(key)}"]`,
];
/**
* Resolves a state key to a live DOM element, trying each strategy in order
* of specificity. A strategy is only trusted when it matches exactly one
* element; if every strategy is either invalid or ambiguous, the first
* ambiguous match is returned with a warning rather than throwing.
*/
export function resolveElementByKey(root: ParentNode, key: string): ElementSelectorResult {
if (!key) return { element: null, warning: 'Cannot resolve an element for an empty state key.' };
let ambiguous: { element: HTMLElement; selector: string; count: number } | undefined;
for (const build of STRATEGIES) {
const selector = build(key);
let matches: NodeListOf<Element>;
try {
matches = root.querySelectorAll(selector);
} catch {
continue;
}
if (matches.length === 1) return { element: matches[0] as HTMLElement, selector };
if (matches.length > 1 && !ambiguous) ambiguous = { element: matches[0] as HTMLElement, selector, count: matches.length };
}
if (ambiguous) {
return {
element: ambiguous.element,
selector: ambiguous.selector,
ambiguous: true,
warning: `Selector '${ambiguous.selector}' matched ${ambiguous.count} elements for key '${key}'; used the first match. Add a unique 'data-state-key' attribute to disambiguate.`,
};
}
return { element: null, warning: `No element found for key '${key}'.` };
}
plugins/dom-state.plugin.ts
Captures/restores input/textarea/select values and <details> open state under a root element. One broken/ambiguous element produces a warning, not a thrown error that aborts the rest of the restore.
import { Injectable } from '@angular/core';
import { ResourceHydrationPlugin } from '../core/contracts';
import { CapturedResource, CaptureContext, ResourceSnapshot, RestoreContext, RestoreOutcome } from '../core/models';
import { resolveElementByKey } from './dom-selector';
export interface DomStateTarget { readonly root: HTMLElement; readonly ignoreSelectors?: readonly string[]; }
interface FormEntry { readonly key: string; readonly kind: string; readonly value?: string; readonly checked?: boolean; readonly selected?: readonly string[]; }
interface DomStateSnapshot { readonly forms: readonly FormEntry[]; readonly details: readonly { key: string; open: boolean }[]; readonly focusedKey?: string; }
@Injectable()
export class DomStatePlugin implements ResourceHydrationPlugin<DomStateTarget, DomStateSnapshot> {
readonly pluginId = 'dom-state-v1';
supports(target: unknown): target is DomStateTarget { return typeof target === 'object' && target !== null && (target as Partial<DomStateTarget>).root instanceof HTMLElement; }
async capture(targetKey: string, target: DomStateTarget, _context: CaptureContext): Promise<CapturedResource<DomStateSnapshot>> {
const ignored = (el: Element) => target.ignoreSelectors?.some(selector => el.matches(selector) || !!el.closest(selector)) ?? false;
const forms: FormEntry[] = [];
target.root.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>('input,textarea,select').forEach((el, index) => {
if (ignored(el)) return;
const key = el.dataset['stateKey'] ?? el.id ?? el.getAttribute('name') ?? `control-${index}`;
if (el instanceof HTMLInputElement && (el.type === 'checkbox' || el.type === 'radio')) forms.push({ key, kind: el.type, checked: el.checked, value: el.value });
else if (el instanceof HTMLSelectElement && el.multiple) forms.push({ key, kind: 'select-multiple', selected: Array.from(el.selectedOptions).map(option => option.value) });
else forms.push({ key, kind: el.tagName.toLowerCase(), value: el.value });
});
const details = Array.from(target.root.querySelectorAll<HTMLDetailsElement>('details')).filter(el => !ignored(el)).map((el, i) => ({ key: el.dataset['stateKey'] ?? el.id ?? `details-${i}`, open: el.open }));
const active = document.activeElement instanceof HTMLElement && target.root.contains(document.activeElement) ? document.activeElement : undefined;
return { resourceId: crypto.randomUUID(), pluginId: this.pluginId, targetKey, state: { forms, details, focusedKey: active?.dataset['stateKey'] ?? active?.id ?? undefined } };
}
async restore(target: DomStateTarget, snapshot: ResourceSnapshot<DomStateSnapshot>, _context: RestoreContext): Promise<RestoreOutcome> {
const warnings: string[] = [];
const byKey = (key: string): HTMLElement | null => {
const result = resolveElementByKey(target.root, key);
if (result.warning) warnings.push(result.warning);
return result.element;
};
for (const item of snapshot.state.forms) {
try {
const el = byKey(item.key);
if (el instanceof HTMLInputElement) { if (item.checked !== undefined) el.checked = item.checked; if (item.value !== undefined) el.value = item.value; }
else if (el instanceof HTMLTextAreaElement) el.value = item.value ?? '';
else if (el instanceof HTMLSelectElement) {
if (item.selected) Array.from(el.options).forEach(option => option.selected = item.selected!.includes(option.value));
else el.value = item.value ?? '';
}
} catch (error) {
warnings.push(`Failed to restore form control '${item.key}': ${error instanceof Error ? error.message : String(error)}`);
}
}
for (const item of snapshot.state.details) {
try {
const el = byKey(item.key);
if (el instanceof HTMLDetailsElement) el.open = item.open;
} catch (error) {
warnings.push(`Failed to restore details element '${item.key}': ${error instanceof Error ? error.message : String(error)}`);
}
}
if (snapshot.state.focusedKey) {
try { byKey(snapshot.state.focusedKey)?.focus(); }
catch (error) { warnings.push(`Failed to restore focus for '${snapshot.state.focusedKey}': ${error instanceof Error ? error.message : String(error)}`); }
}
return { warnings };
}
}
plugins/state-target.plugin.ts
Handles any object shaped like { capture(): T, restore(state: T): void } — the manual escape hatch for state that isn't plain writable signals.
import { Injectable } from '@angular/core';
import { ResourceHydrationPlugin, StateTarget } from '../core/contracts';
import { CapturedResource, CaptureContext, ResourceSnapshot, RestoreContext } from '../core/models';
@Injectable()
export class StateTargetPlugin implements ResourceHydrationPlugin<StateTarget, unknown> {
readonly pluginId = 'application-state-target-v1';
supports(target: unknown): target is StateTarget {
if (typeof target !== 'object' || target === null) return false;
const value = target as Partial<StateTarget>;
return typeof value.capture === 'function' && typeof value.restore === 'function';
}
async capture(targetKey: string, target: StateTarget, _context: CaptureContext): Promise<CapturedResource> {
return { resourceId: crypto.randomUUID(), pluginId: this.pluginId, targetKey, state: structuredClone(await target.capture()) };
}
async restore(target: StateTarget, snapshot: ResourceSnapshot, _context: RestoreContext): Promise<void> {
await target.restore(structuredClone(snapshot.state));
}
}
plugins/signal-store.plugin.ts
The one you usually want. Register a plain object or class instance directly; it reflects over the object's own properties (via core/signal-reflection.ts) and captures/restores whichever ones are WritableSignals.
import { Injectable } from '@angular/core';
import { ResourceHydrationPlugin } from '../core/contracts';
import { CapturedResource, CaptureContext, ResourceSnapshot, RestoreContext } from '../core/models';
import { discoverWritableSignalKeys, isWritableSignal } from '../core/signal-reflection';
type SignalStoreState = Readonly<Record<string, unknown>>;
/**
* Captures/restores any plain object or class instance (e.g. a `@Injectable` store) by reflecting
* over its own properties and picking out whichever ones are `WritableSignal`s — no `StateTarget`
* wrapper, `capture()`/`restore()` boilerplate, or library imports needed on the store itself.
* Read-only signals (`computed()`, `.asReadonly()`) are left alone, since they're derived, not
* state to restore into.
*
* Register the store instance directly: `registry.register('key', myStore)`, or use
* `provideTrackedStore()` to register a root-provided store automatically at bootstrap.
*/
@Injectable()
export class SignalStorePlugin implements ResourceHydrationPlugin<object, SignalStoreState> {
readonly pluginId = 'signal-store-v1';
supports(target: unknown): target is object {
if (typeof target !== 'object' || target === null || Array.isArray(target)) return false;
if (target instanceof HTMLElement) return false;
const candidate = target as { capture?: unknown; restore?: unknown };
if (typeof candidate.capture === 'function' && typeof candidate.restore === 'function') return false;
return discoverWritableSignalKeys(target).length > 0;
}
async capture(targetKey: string, target: object, _context: CaptureContext): Promise<CapturedResource<SignalStoreState>> {
const state: Record<string, unknown> = {};
for (const key of discoverWritableSignalKeys(target)) {
state[key] = structuredClone((target as Record<string, () => unknown>)[key]());
}
return { resourceId: crypto.randomUUID(), pluginId: this.pluginId, targetKey, state };
}
async restore(target: object, snapshot: ResourceSnapshot<SignalStoreState>, _context: RestoreContext): Promise<void> {
const record = target as Record<string, unknown>;
for (const key of discoverWritableSignalKeys(target)) {
if (!Object.prototype.hasOwnProperty.call(snapshot.state, key)) continue;
const signal = record[key];
if (isWritableSignal(signal)) signal.set(structuredClone(snapshot.state[key]));
}
}
}
plugins/mapbox/mapbox.models.ts
Snapshot shape types for Mapbox resources.
import type { AnyLayer, AnySourceData, LngLatLike, PaddingOptions, StyleSpecification } from 'mapbox-gl';
export interface MapboxCameraSnapshot {
readonly center: [number, number];
readonly zoom: number;
readonly bearing: number;
readonly pitch: number;
readonly padding: PaddingOptions;
}
export interface TrackedSource {
readonly definition: AnySourceData;
}
export interface TrackedLayer {
readonly definition: AnyLayer;
readonly beforeId?: string;
}
export interface MapboxFeatureStateSnapshot {
readonly target: { source: string; sourceLayer?: string; id: string | number };
readonly state: Record<string, unknown>;
}
export interface MapboxMarkerSnapshot {
readonly id: string;
readonly lngLat: [number, number];
readonly color?: string;
readonly draggable: boolean;
}
export interface MapboxPopupSnapshot {
readonly id: string;
readonly lngLat: LngLatLike;
readonly html: string;
readonly open: boolean;
}
export interface MapboxSnapshot {
readonly version: 1;
readonly camera: MapboxCameraSnapshot;
readonly style: StyleSpecification;
readonly sources: Readonly<Record<string, TrackedSource>>;
readonly layers: readonly TrackedLayer[];
readonly featureStates: readonly MapboxFeatureStateSnapshot[];
readonly markers: readonly MapboxMarkerSnapshot[];
readonly popups: readonly MapboxPopupSnapshot[];
}
plugins/mapbox/mapbox-state-tracker.ts
Plain class (not DI) that records sources/layers/feature-states/markers/popups as they're added, and exposes changeVersion — the "signal" mapbox-gl doesn't have natively.
import { signal } from '@angular/core';
import type { AnyLayer, AnySourceData, Map as MapboxMap, Marker, Popup } from 'mapbox-gl';
import { MapboxFeatureStateSnapshot, MapboxMarkerSnapshot, MapboxPopupSnapshot, TrackedLayer, TrackedSource } from './mapbox.models';
import type * as GeoJSON from 'geojson';
/** Native mapbox-gl events that mean "the map's captured state may have changed" but aren't covered by `MapboxSetup`'s patched methods (e.g. `map.easeTo()`/`map.jumpTo()` camera moves). */
const WATCHED_MAP_EVENTS = ['moveend', 'style.load'] as const;
export class MapboxStateTracker {
private readonly sources = new Map<string, TrackedSource>();
private readonly layers = new Map<string, TrackedLayer>();
private readonly featureStates = new Map<string, MapboxFeatureStateSnapshot>();
private readonly markers = new Map<string, { instance: Marker; snapshot: MapboxMarkerSnapshot }>();
private readonly popups = new Map<string, { instance: Popup; snapshot: MapboxPopupSnapshot }>();
/**
* Bumps on every recorded mutation (source/layer/feature-state/marker/popup) and, once `watch()`
* is called, on native camera/style changes too. Since mapbox-gl has no Angular signals of its
* own, read this from a consumer's `effect()` to know when a fresh capture may be worth taking —
* it's a cheap "something changed" pulse, not a content diff (use `computeContentHash` on the
* actual captured state for that).
*/
readonly changeVersion = signal(0);
private suppressChangeTracking = false;
recordSource(id: string, definition: AnySourceData): void {
this.sources.set(id, { definition: structuredClone(definition) });
this.bump();
}
updateGeoJson(id: string, data: GeoJSON.GeoJSON | string): void {
const current = this.sources.get(id);
if (!current || current.definition.type !== 'geojson') return;
this.sources.set(id, { definition: { ...current.definition, data: structuredClone(data) } });
this.bump();
}
removeSource(id: string): void { if (this.sources.delete(id)) this.bump(); }
recordLayer(definition: AnyLayer, beforeId?: string): void {
this.layers.set(definition.id, { definition: structuredClone(definition), beforeId });
this.bump();
}
removeLayer(id: string): void { if (this.layers.delete(id)) this.bump(); }
recordFeatureState(target: MapboxFeatureStateSnapshot['target'], state: Record<string, unknown>): void {
this.featureStates.set(JSON.stringify(target), { target: structuredClone(target), state: structuredClone(state) });
this.bump();
}
registerMarker(id: string, instance: Marker, snapshot: MapboxMarkerSnapshot): void {
this.markers.set(id, { instance, snapshot });
this.bump();
}
registerPopup(id: string, instance: Popup, snapshot: MapboxPopupSnapshot): void {
this.popups.set(id, { instance, snapshot });
this.bump();
}
clearRuntimeObjects(): void {
for (const marker of this.markers.values()) marker.instance.remove();
for (const popup of this.popups.values()) popup.instance.remove();
const hadAny = this.markers.size > 0 || this.popups.size > 0;
this.markers.clear();
this.popups.clear();
if (hadAny) this.bump();
}
snapshotSources(): Record<string, TrackedSource> { return Object.fromEntries(this.sources); }
snapshotLayers(): TrackedLayer[] { return [...this.layers.values()]; }
snapshotFeatureStates(): MapboxFeatureStateSnapshot[] { return [...this.featureStates.values()]; }
snapshotMarkers(): MapboxMarkerSnapshot[] {
return [...this.markers.values()].map(({ instance, snapshot }) => ({ ...snapshot, lngLat: [instance.getLngLat().lng, instance.getLngLat().lat] }));
}
snapshotPopups(): MapboxPopupSnapshot[] { return [...this.popups.values()].map(value => value.snapshot); }
/** Seeds the tracker with the map's own starting style — a baseline, not a capturable change, so `changeVersion` doesn't bump. */
seedFromStyle(map: MapboxMap): void {
this.withSuppression(() => {
const style = map.getStyle();
for (const [id, source] of Object.entries(style.sources ?? {})) this.recordSource(id, source as AnySourceData);
for (const layer of style.layers ?? []) this.recordLayer(layer as AnyLayer);
});
}
/**
* Bumps `changeVersion` on native camera/style events that `MapboxSetup`'s patched methods don't
* cover (e.g. `map.easeTo()`/`map.jumpTo()` called directly). Call once after the map is ready.
* Returns an unwatch function; also unwatches automatically if `watch()` is called again for the
* same map.
*/
watch(map: MapboxMap): () => void {
const handler = () => this.bump();
for (const event of WATCHED_MAP_EVENTS) map.on(event, handler);
return () => { for (const event of WATCHED_MAP_EVENTS) map.off(event, handler); };
}
/**
* Runs `fn` (sync or async) without bumping `changeVersion` for anything it does — including
* native events like `moveend`/`style.load` fired synchronously inside `fn`. Intended for
* `MapboxHydrationPlugin.restore()`: re-applying a snapshot's own content shouldn't look like a
* fresh, uncaptured change (that content is already persisted — that's where it came from).
*/
async withoutChangeTracking<T>(fn: () => Promise<T> | T): Promise<T> {
const wasSuppressed = this.suppressChangeTracking;
this.suppressChangeTracking = true;
try {
return await fn();
} finally {
this.suppressChangeTracking = wasSuppressed;
}
}
private withSuppression<T>(fn: () => T): T {
const wasSuppressed = this.suppressChangeTracking;
this.suppressChangeTracking = true;
try {
return fn();
} finally {
this.suppressChangeTracking = wasSuppressed;
}
}
private bump(): void { if (!this.suppressChangeTracking) this.changeVersion.update((value) => value + 1); }
}
plugins/mapbox/mapbox-hydration.plugin.ts
The ResourceHydrationPlugin for { map, tracker } targets. A timeout guard around waiting for 'style.load' means a stalled event can't hang HydrationRuntime.restore() forever.
import { Injectable } from '@angular/core';
import mapboxgl, { AnyLayer, AnySourceData, GeoJSONSource, Map as MapboxMap, StyleSpecification } from 'mapbox-gl';
import { CapturedResource, CaptureContext, ResourceSnapshot, RestoreContext, RestoreOutcome } from '../../core/models';
import { ResourceHydrationPlugin } from '../../core/contracts';
import { MapboxSnapshot } from './mapbox.models';
import { MapboxStateTracker } from './mapbox-state-tracker';
const STYLE_LOAD_TIMEOUT_MS = 8000;
export interface HydratableMapboxResource {
readonly map: MapboxMap;
readonly tracker: MapboxStateTracker;
}
@Injectable()
export class MapboxHydrationPlugin implements ResourceHydrationPlugin<HydratableMapboxResource, MapboxSnapshot> {
readonly pluginId = 'mapbox-gl-js-v3';
supports(target: unknown): target is HydratableMapboxResource {
if (typeof target !== 'object' || target === null) return false;
const candidate = target as Partial<HydratableMapboxResource>;
return candidate.map instanceof mapboxgl.Map && candidate.tracker instanceof MapboxStateTracker;
}
async capture(targetKey: string, target: HydratableMapboxResource, _context: CaptureContext): Promise<CapturedResource<MapboxSnapshot>> {
await this.waitForStyle(target.map);
const center = target.map.getCenter();
return {
resourceId: crypto.randomUUID(),
pluginId: this.pluginId,
targetKey,
state: {
version: 1,
camera: {
center: [center.lng, center.lat],
zoom: target.map.getZoom(),
bearing: target.map.getBearing(),
pitch: target.map.getPitch(),
padding: target.map.getPadding()
},
style: structuredClone(target.map.getStyle() as StyleSpecification),
sources: target.tracker.snapshotSources(),
layers: target.tracker.snapshotLayers(),
featureStates: target.tracker.snapshotFeatureStates(),
markers: target.tracker.snapshotMarkers(),
popups: target.tracker.snapshotPopups()
}
};
}
async restore(target: HydratableMapboxResource, snapshot: ResourceSnapshot<MapboxSnapshot>, _context: RestoreContext): Promise<RestoreOutcome> {
const { map, tracker } = target;
// Re-applying a snapshot's own content isn't a new, uncaptured change (it's already
// persisted — that's where it came from), so don't let it bump `changeVersion` and trigger a
// redundant auto-capture cycle.
return tracker.withoutChangeTracking(() => this.applyRestore(target, snapshot));
}
private async applyRestore(target: HydratableMapboxResource, snapshot: ResourceSnapshot<MapboxSnapshot>): Promise<RestoreOutcome> {
const { map, tracker } = target;
const state = snapshot.state;
const warnings: string[] = [];
tracker.clearRuntimeObjects();
map.setStyle(structuredClone(state.style));
if (!(await this.waitForStyle(map))) {
warnings.push(`Timed out after ${STYLE_LOAD_TIMEOUT_MS}ms waiting for the map style to load; proceeding anyway, sources/layers may be incomplete.`);
}
for (const [id, source] of Object.entries(state.sources)) {
if (!map.getSource(id)) map.addSource(id, structuredClone(source.definition) as AnySourceData);
else if (source.definition.type === 'geojson' && source.definition.data !== undefined) {
const live = map.getSource(id) as GeoJSONSource;
live.setData(structuredClone(source.definition.data));
}
tracker.recordSource(id, source.definition);
}
for (const layer of state.layers) {
if (!map.getLayer(layer.definition.id)) map.addLayer(structuredClone(layer.definition) as AnyLayer, layer.beforeId);
tracker.recordLayer(layer.definition, layer.beforeId);
}
for (const feature of state.featureStates) {
map.setFeatureState(feature.target, structuredClone(feature.state));
tracker.recordFeatureState(feature.target, feature.state);
}
for (const marker of state.markers) {
const instance = new mapboxgl.Marker({ color: marker.color, draggable: marker.draggable })
.setLngLat(marker.lngLat)
.addTo(map);
tracker.registerMarker(marker.id, instance, marker);
}
for (const popup of state.popups) {
const instance = new mapboxgl.Popup().setLngLat(popup.lngLat).setHTML(popup.html);
if (popup.open) instance.addTo(map);
tracker.registerPopup(popup.id, instance, popup);
}
map.jumpTo(state.camera);
return { warnings };
}
/** Resolves `true` once the style finishes loading, or `false` if `'style.load'` never fires within the timeout. */
private waitForStyle(map: MapboxMap, timeoutMs = STYLE_LOAD_TIMEOUT_MS): Promise<boolean> {
if (map.isStyleLoaded()) return Promise.resolve(true);
return new Promise(resolve => {
let settled = false;
const finish = (loaded: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timer);
map.off('style.load', onLoad);
resolve(loaded);
};
const onLoad = () => finish(true);
const timer = setTimeout(() => finish(false), timeoutMs);
map.once('style.load', onLoad);
});
}
}
plugins/mapbox/mapbox-setup.service.ts
No component in the library, and no map creation either — just an injectable service that adds tracking to a map your app already created and already loaded. Doesn't touch accessToken or workerUrl; that's entirely your app's job, same as using mapbox-gl directly.
import { Injectable, inject } from '@angular/core';
import mapboxgl, { AnyLayer, AnySourceData, GeoJSONSource, Map as MapboxMap, Marker, Popup } from 'mapbox-gl';
import type * as GeoJSON from 'geojson';
import { ResourceRegistry } from '../../core/resource-registry';
import { MapboxStateTracker } from './mapbox-state-tracker';
const patchedSources = new WeakSet<object>();
export interface MapboxSetupOptions {
/** `ResourceRegistry` key this map is captured/restored under. Give each map a distinct key if you set up more than one. Default: `'mapbox-map'`. */
readonly resourceKey?: string;
}
export interface MapboxSetupHandle {
readonly tracker: MapboxStateTracker;
/** Mapbox has no built-in id for markers, so this stays an explicit helper (unlike addSource/addLayer, which are tracked automatically once `map` is patched — see below). */
addMarker(id: string, lngLat: [number, number], color?: string): Marker;
/** Same reasoning as `addMarker` — popups have no built-in id either. */
addPopup(id: string, lngLat: [number, number], html: string): Popup;
/** Unwatches and unregisters from `ResourceRegistry`. Call from `ngOnDestroy` (alongside your own `map.remove()`). */
destroy(): void;
}
/**
* Adds capture/restore tracking to a map your app already created and already loaded: creates a
* `MapboxStateTracker`, registers it with `ResourceRegistry`, watches native camera/style events,
* and replaces `map.addSource`/`addLayer`/`removeLayer`/`getSource(id).setData(...)` on the map
* instance with versions that also record into the tracker — call the real Mapbox GL API directly
* on your own map and get capture/restore for free.
*
* This service does not create the map, does not set `mapboxgl.accessToken`, and does not touch
* `mapboxgl.workerUrl` — all of that is your app's job, exactly like using mapbox-gl directly. Call
* `setup()` once the map has finished loading (inside `map.once('load', ...)`).
*
* Requires `MapboxHydrationPlugin` to be registered (pass it to `provideApplicationState`'s
* `pluginTypes`).
*
* ```ts
* private readonly mapboxSetup = inject(MapboxSetup);
* private handle?: MapboxSetupHandle;
*
* this.map = new mapboxgl.Map({ container, style, center, zoom });
* this.map.once('load', () => { this.handle = this.mapboxSetup.setup(this.map!); });
* // later: this.map.addSource('id', {...}); this.map.addLayer({...}); // tracked automatically
* ngOnDestroy() { this.handle?.destroy(); this.map?.remove(); }
* ```
*/
@Injectable({ providedIn: 'root' })
export class MapboxSetup {
private readonly registry = inject(ResourceRegistry);
setup(map: MapboxMap, options: MapboxSetupOptions = {}): MapboxSetupHandle {
const tracker = new MapboxStateTracker();
this.patchMapMethods(map, tracker);
tracker.seedFromStyle(map);
// AutoHydrationCoordinator watches ResourceRegistry.registryVersion and retries
// restoreIfAvailable() whenever a new resource registers, so this asynchronously-registering
// map gets its persisted state applied automatically — no explicit restore call needed here.
const unregister = this.registry.register(
options.resourceKey ?? 'mapbox-map',
{ map, tracker },
{ changeSignal: tracker.changeVersion }
);
const unwatch = tracker.watch(map);
return {
tracker,
addMarker: (id, lngLat, color = '#dc2626') => {
const marker = new mapboxgl.Marker({ color, draggable: true }).setLngLat(lngLat).addTo(map);
tracker.registerMarker(id, marker, { id, lngLat, color, draggable: true });
return marker;
},
addPopup: (id, lngLat, html) => {
const popup = new mapboxgl.Popup().setLngLat(lngLat).setHTML(html).addTo(map);
tracker.registerPopup(id, popup, { id, lngLat, html, open: true });
return popup;
},
destroy: () => {
unwatch();
unregister();
}
};
}
/** Replaces `map`'s own mutation methods with versions that also record into `tracker`, so calling the real Mapbox GL API is enough to get capture/restore support. */
private patchMapMethods(map: MapboxMap, tracker: MapboxStateTracker): void {
const originalAddSource = map.addSource.bind(map);
map.addSource = ((id: string, source: AnySourceData) => {
originalAddSource(id, source);
tracker.recordSource(id, source);
return map;
}) as typeof map.addSource;
const originalAddLayer = map.addLayer.bind(map);
map.addLayer = ((layer: AnyLayer, beforeId?: string) => {
originalAddLayer(layer, beforeId);
tracker.recordLayer(layer, beforeId);
return map;
}) as typeof map.addLayer;
const originalRemoveLayer = map.removeLayer.bind(map);
map.removeLayer = ((id: string) => {
if (map.getLayer(id)) originalRemoveLayer(id);
tracker.removeLayer(id);
return map;
}) as typeof map.removeLayer;
const originalGetSource = map.getSource.bind(map);
map.getSource = ((id: string) => {
const source = originalGetSource(id);
if (source && !patchedSources.has(source) && typeof (source as GeoJSONSource).setData === 'function') {
patchedSources.add(source);
const geoJsonSource = source as GeoJSONSource;
const originalSetData = geoJsonSource.setData.bind(geoJsonSource);
geoJsonSource.setData = (data: GeoJSON.GeoJSON | string) => {
const result = originalSetData(data);
tracker.updateGeoJson(id, data);
return result;
};
}
return source;
}) as typeof map.getSource;
}
}
Using it in an app (the demo side)
This is everything src/app/ in this workspace had to add on top of the library. None of it lives inside projects/application-state.
1. Install peer dependencies
"@angular/core": ">=19.1.3 <22.0.0",
"@angular/common": ">=19.1.3 <22.0.0",
"mapbox-gl": "^3.15.0"
(mapbox-gl is optional — only needed if you use the Mapbox plugin/component.)
2. Wire it up in app.config.ts
import { ApplicationConfig } from '@angular/core';
import { MapboxHydrationPlugin, provideApplicationState } from 'application-state';
export const appConfig: ApplicationConfig = {
providers: [
provideApplicationState(
{
applicationId: 'my-app',
applicationVersion: '1.0.0',
databaseName: 'my-app-state', // optional, defaults to `${applicationId}-state`
autoHydration: { restoreOnInit: true, autoCaptureDebounceMs: 800 }, // optional; omit for fully-manual capture/restore
builtInPlugins: { domState: true, stateTarget: true, signalStore: true } // optional; all three default to enabled — set any to `false` to opt out
},
[MapboxHydrationPlugin] // extra plugins beyond the built-in three (DOM, StateTarget, SignalStore)
)
]
};
That single call registers the default IndexedDbSnapshotStore, whichever built-in plugins builtInPlugins didn't opt out of, and (if autoHydration is set) activates AutoHydrationCoordinator. Opting a built-in plugin out means it's never even provided — e.g. if your app has no DOM-form resources, { domState: false } keeps DomStatePlugin out of the injector entirely.
provideZonelessChangeDetection() is not required. This demo uses it because it's a good modern default, not because the library needs it. Every piece of reactive state in the library (HydrationRuntime.busy/lastMessage, ResourceRegistry.registryVersion, MapboxStateTracker.changeVersion, AutoHydrationCoordinator's effects, etc.) is a signal, and signal writes notify Angular's change detection scheduler directly — independent of whether zone.js is present. The library works identically in a traditional zone.js-based app; there's nothing to add or configure differently either way.
3. Register your resources
There's no automatic discovery — Angular has no public API to enumerate "every injectable in my app," so the library can't find your stores on its own. Every resource you want captured/restored needs one explicit register() (or provideTrackedStore()) call somewhere.
Registering root-provided stores without any component
If your store is providedIn: 'root' (or otherwise environment-provided), register it once in app.config.ts with provideTrackedStore() — no component needs to inject ResourceRegistry or call register() itself:
// app.config.ts
import { provideApplicationState, provideTrackedStore } from 'application-state';
import { MyStore } from './my-store';
export const appConfig: ApplicationConfig = {
providers: [
provideApplicationState(config, plugins),
provideTrackedStore(MyStore), // key defaults to 'MyStore'; pass { key: '...' } to override
]
};
This works because SignalStorePlugin already reflects over the store's own properties to find writable signals — provideTrackedStore() just makes sure the store gets registered at all, and derives a changeSignal for you the same way (reflecting over the same writable signals). Pass { changeSignal: (store) => store.someExistingComputed } if you want to reuse a computed() you already wrote instead.
Registering component-scoped resources
For a store or resource whose lifetime is tied to a specific component (not providedIn: 'root', or you want it un-registered on ngOnDestroy), call register() directly:
// some.component.ts
private readonly registry = inject(ResourceRegistry);
private readonly store = inject(MyStore);
private unregister?: () => void;
constructor() {
this.unregister = this.registry.register('my-store', this.store);
}
ngOnDestroy(): void { this.unregister?.(); }
A DOM subtree (form inputs, <details>) is always registered this way, since it's inherently tied to a component's own template — register the root element:
this.registry.register('my-form', { root: this.rootEl.nativeElement, ignoreSelectors: ['[data-ignore-state]'] });
Anything else — wrap it as a StateTarget:
this.registry.register('my-thing', { capture: () => myThing.serialize(), restore: (s) => myThing.load(s) });
4. Trigger capture/restore
With autoHydration on, this happens automatically. For manual control (or in addition to it), inject HydrationRuntime:
readonly runtime = inject(HydrationRuntime);
capture() { this.runtime.capture(); }
restore() { this.runtime.restore(); } // throws if no snapshot exists
clear() { this.runtime.clear(); }
Bind [disabled]="runtime.busy()" and display runtime.lastMessage() for free UI feedback.
5. Mapbox — create the map yourself, then hand it to MapboxSetup
There's no library component and no map-creation helper — you create the mapboxgl.Map exactly as you would without this library (your own container element, your own accessToken, your own workerUrl if you need the CSP worker workaround for bundlers that break mapbox-gl's default self-hosted worker). One build-config addition is unavoidable if you do use the CSP worker (it's about how your bundler treats a CJS dependency, not something a library can configure on your behalf):
angular.json (build.options):
"styles": ["src/styles.css", "mapbox-gl/dist/mapbox-gl.css"],
"allowedCommonJsDependencies": ["mapbox-gl"]
Then, once your map has loaded, hand it to MapboxSetup — this is the one call that adds capture/restore support:
private readonly mapboxSetup = inject(MapboxSetup);
private map?: MapboxMap;
private handle?: MapboxSetupHandle;
initialize(): void {
mapboxgl.accessToken = this.token; // your app's job — MapboxSetup never touches this
this.map = new mapboxgl.Map({ container: this.mapContainer.nativeElement, style: '...', center: [...], zoom: 10 });
this.map.once('load', () => {
this.handle = this.mapboxSetup.setup(this.map!); // resourceKey defaults to 'mapbox-map'
});
}
addLayer(): void {
// Real Mapbox GL API, called directly — MapboxSetup patched these on the map instance to also
// record into the tracker, so no facade call is needed.
this.map!.addSource('id', { type: 'geojson', data });
this.map!.addLayer({ id: 'layer', type: 'circle', source: 'id', paint: {...} });
}
addMarker(): void {
// Markers/popups have no built-in id in the Mapbox API, so these stay explicit helpers on the handle.
this.handle!.addMarker('m1', this.map!.getCenter().toArray());
}
ngOnDestroy(): void {
this.handle?.destroy();
this.map?.remove();
}
Give each map a distinct resourceKey (mapboxSetup.setup(map, { resourceKey: '...' })) if you have more than one on a page.
Swapping to a real database
Everything above persists through the SnapshotStore interface (contracts.ts) via the SNAPSHOT_STORE DI token — HydrationRuntime never imports IndexedDB directly. To use a real backend database instead, implement the interface and pass it as provideApplicationState's third argument.
export interface SnapshotStore {
save(envelope: SnapshotEnvelope, changedResourceKeys: readonly string[]): Promise<void>;
load(id: string): Promise<SnapshotEnvelope | undefined>;
delete(id: string): Promise<void>;
}
Example: a store backed by a REST API in front of a real database.
// rest-snapshot.store.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { SnapshotStore, SnapshotEnvelope } from 'application-state';
import { firstValueFrom } from 'rxjs';
@Injectable()
export class RestSnapshotStore implements SnapshotStore {
constructor(private readonly http: HttpClient) {}
async save(envelope: SnapshotEnvelope, changedResourceKeys: readonly string[]): Promise<void> {
// Send the whole envelope; a smarter backend could accept `changedResourceKeys` too and
// only upsert those rows, mirroring what IndexedDbSnapshotStore does locally.
await firstValueFrom(this.http.put(`/api/snapshots/${envelope.id}`, { envelope, changedResourceKeys }));
}
async load(id: string): Promise<SnapshotEnvelope | undefined> {
try {
return await firstValueFrom(this.http.get<SnapshotEnvelope>(`/api/snapshots/${id}`));
} catch (error: any) {
if (error?.status === 404) return undefined; // HydrationRuntime treats this as "no snapshot yet"
throw error;
}
}
async delete(id: string): Promise<void> {
await firstValueFrom(this.http.delete(`/api/snapshots/${id}`));
}
}
Wire it in app.config.ts in place of the default:
import { provideHttpClient } from '@angular/common/http';
import { SNAPSHOT_STORE, provideApplicationState } from 'application-state';
import { RestSnapshotStore } from './rest-snapshot.store';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
provideApplicationState(
{ applicationId: 'my-app', applicationVersion: '1.0.0' },
[MapboxHydrationPlugin],
{ provide: SNAPSHOT_STORE, useClass: RestSnapshotStore } // <-- the swap
)
]
};
Nothing else changes — HydrationRuntime, AutoHydrationCoordinator, and every plugin are completely unaware of where the bytes end up. MemorySnapshotStore (documented above) is a smaller worked example of the same pattern, useful as a starting template or for unit tests.
Example backend: the API endpoint RestSnapshotStore calls
RestSnapshotStore above expects PUT/GET/DELETE /api/snapshots/:id. Here's a worked example of that endpoint — Express + PostgreSQL — using the same single-table, manifest-plus-resource-rows layout as IndexedDbSnapshotStore (contracts.ts), so it gets the same benefit: a PUT only upserts the resource rows named in changedResourceKeys, not the whole snapshot.
-- One row per manifest and one row per resource, exactly like the client-side IndexedDB schema.
CREATE TABLE snapshot_entries (
id TEXT NOT NULL,
entry_key TEXT NOT NULL,
payload JSONB NOT NULL,
PRIMARY KEY (id, entry_key)
);
CREATE INDEX snapshot_entries_id_idx ON snapshot_entries (id);
// server/snapshots.routes.ts
import { Router } from 'express';
import { Pool } from 'pg';
const pool = new Pool(); // configured via PG* env vars
const MANIFEST_ENTRY_KEY = '__manifest__';
export const snapshotsRouter = Router();
// PUT /api/snapshots/:id — body: { envelope: SnapshotEnvelope, changedResourceKeys: string[] }
snapshotsRouter.put('/api/snapshots/:id', async (req, res) => {
const { id } = req.params;
const { envelope, changedResourceKeys } = req.body;
if (envelope.id !== id) return res.status(400).json({ error: 'id mismatch' });
const client = await pool.connect();
try {
await client.query('BEGIN');
// Manifest row: everything except the resources' own state, plus the list of resource keys —
// mirrors IndexedDbSnapshotStore's ManifestEntry.
const { resources, ...snapshotWithoutResources } = envelope.snapshot;
const manifest = { ...snapshotWithoutResources, resourceKeys: resources.map((r: any) => r.targetKey) };
await client.query(
`INSERT INTO snapshot_entries (id, entry_key, payload) VALUES ($1, $2, $3)
ON CONFLICT (id, entry_key) DO UPDATE SET payload = EXCLUDED.payload`,
[id, MANIFEST_ENTRY_KEY, manifest]
);
// Only upsert resources whose content hash actually changed.
const changed = new Set<string>(changedResourceKeys);
for (const resource of resources) {
if (!changed.has(resource.targetKey)) continue;
await client.query(
`INSERT INTO snapshot_entries (id, entry_key, payload) VALUES ($1, $2, $3)
ON CONFLICT (id, entry_key) DO UPDATE SET payload = EXCLUDED.payload`,
[id, resource.targetKey, resource]
);
}
// Drop resource rows that no longer exist in this snapshot (a resource that stopped registering).
const currentKeys = resources.map((r: any) => r.targetKey);
await client.query(
`DELETE FROM snapshot_entries WHERE id = $1 AND entry_key <> $2 AND NOT (entry_key = ANY($3::text[]))`,
[id, MANIFEST_ENTRY_KEY, currentKeys]
);
await client.query('COMMIT');
res.status(204).end();
} catch (error) {
await client.query('ROLLBACK');
res.status(500).json({ error: 'Failed to save snapshot.' });
} finally {
client.release();
}
});
// GET /api/snapshots/:id
snapshotsRouter.get('/api/snapshots/:id', async (req, res) => {
const { id } = req.params;
const manifestResult = await pool.query(
`SELECT payload FROM snapshot_entries WHERE id = $1 AND entry_key = $2`,
[id, MANIFEST_ENTRY_KEY]
);
if (manifestResult.rowCount === 0) return res.status(404).end(); // RestSnapshotStore.load() treats a 404 as "no snapshot yet"
const { resourceKeys, ...snapshotWithoutResources } = manifestResult.rows[0].payload;
const resourcesResult = await pool.query(
`SELECT payload FROM snapshot_entries WHERE id = $1 AND entry_key = ANY($2::text[])`,
[id, resourceKeys]
);
res.json({
id,
snapshot: { ...snapshotWithoutResources, resources: resourcesResult.rows.map((row) => row.payload) }
});
});
// DELETE /api/snapshots/:id
snapshotsRouter.delete('/api/snapshots/:id', async (req, res) => {
await pool.query(`DELETE FROM snapshot_entries WHERE id = $1`, [req.params.id]);
res.status(204).end();
});
Same endpoint in C# (ASP.NET Core Web API — Controller + Service + PostgreSQL)
Same table, same three endpoints, same contract with RestSnapshotStore — just a different runtime, laid out the way a typical ASP.NET Core Web API project is structured: a thin [ApiController] that only handles HTTP concerns (routing, model binding, status codes) delegating to an injected ISnapshotService that owns the actual persistence logic. Uses Npgsql/Npgsql.DependencyInjection for the connection and Dapper for the raw SQL (package refs: dotnet add package Npgsql.DependencyInjection, dotnet add package Dapper).
The models below are concrete classes mirroring core/models.ts (SnapshotEnvelope, ApplicationSnapshot, BrowserStateSnapshot, ResourceSnapshot) field-for-field. ASP.NET Core's default System.Text.Json options already use camelCase, so these PascalCase properties bind directly to/from the camelCase JSON RestSnapshotStore sends — no attributes needed. The one exception is ResourceSnapshot.State (and Metadata/HistoryState): those stay JsonElement because they're genuinely polymorphic — each plugin (DomStatePlugin, SignalStorePlugin, MapboxHydrationPlugin, …) puts a different shape there, exactly like unknown/JsonValue on the TypeScript side. A backend that only ever talks to one specific plugin could tighten State to that plugin's concrete type instead.
// SnapshotModels.cs
using System.Text.Json;
public class SnapshotEnvelope
{
public string Id { get; set; } = default!;
public ApplicationSnapshot Snapshot { get; set; } = default!;
}
public class ApplicationSnapshot
{
public int SchemaVersion { get; set; }
public string ApplicationId { get; set; } = default!;
public string ApplicationVersion { get; set; } = default!;
public string CreatedAt { get; set; } = default!;
public BrowserStateSnapshot Browser { get; set; } = default!;
public List<ResourceSnapshot> Resources { get; set; } = new();
public Dictionary<string, JsonElement> Metadata { get; set; } = new();
}
public class BrowserStateSnapshot
{
public Dictionary<string, string> LocalStorage { get; set; } = new();
public Dictionary<string, string> SessionStorage { get; set; } = new();
public double ScrollX { get; set; }
public double ScrollY { get; set; }
public LocationSnapshot Location { get; set; } = default!;
}
public class LocationSnapshot
{
public string Pathname { get; set; } = default!;
public string Search { get; set; } = default!;
public string Hash { get; set; } = default!;
public JsonElement HistoryState { get; set; }
}
public class ResourceSnapshot
{
public string ResourceId { get; set; } = default!;
public string PluginId { get; set; } = default!;
public string TargetKey { get; set; } = default!;
public JsonElement State { get; set; } // plugin-defined payload — polymorphic, same as TS `unknown`
public string ContentHash { get; set; } = default!;
}
// The stored manifest row: ApplicationSnapshot minus Resources, plus the list of resource keys —
// mirrors IndexedDbSnapshotStore's ManifestEntry.
public class SnapshotManifest
{
public int SchemaVersion { get; set; }
public string ApplicationId { get; set; } = default!;
public string ApplicationVersion { get; set; } = default!;
public string CreatedAt { get; set; } = default!;
public BrowserStateSnapshot Browser { get; set; } = default!;
public Dictionary<string, JsonElement> Metadata { get; set; } = new();
public List<string> ResourceKeys { get; set; } = new();
}
// ISnapshotService.cs — owns all persistence logic; the controller never touches SQL directly.
public interface ISnapshotService
{
Task SaveAsync(SnapshotEnvelope envelope, IReadOnlyCollection<string> changedResourceKeys);
Task<ApplicationSnapshot?> LoadAsync(string id);
Task DeleteAsync(string id);
}
// SnapshotService.cs
using System.Text.Json;
using Dapper;
using Npgsql;
public class SnapshotService : ISnapshotService
{
private const string ManifestEntryKey = "__manifest__";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly NpgsqlDataSource _dataSource;
public SnapshotService(NpgsqlDataSource dataSource)
{
_dataSource = dataSource;
}
public async Task SaveAsync(SnapshotEnvelope envelope, IReadOnlyCollection<string> changedResourceKeys)
{
var resourceKeys = envelope.Snapshot.Resources.Select(r => r.TargetKey).ToArray();
var manifest = new SnapshotManifest
{
SchemaVersion = envelope.Snapshot.SchemaVersion,
ApplicationId = envelope.Snapshot.ApplicationId,
ApplicationVersion = envelope.Snapshot.ApplicationVersion,
CreatedAt = envelope.Snapshot.CreatedAt,
Browser = envelope.Snapshot.Browser,
Metadata = envelope.Snapshot.Metadata,
ResourceKeys = resourceKeys.ToList()
};
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
await using var connection = await _dataSource.OpenConnectionAsync();
await using var transaction = await connection.BeginTransactionAsync();
await UpsertEntryAsync(connection, transaction, envelope.Id, ManifestEntryKey, manifestJson);
// Only upsert resources whose content hash actually changed.
var changedKeys = changedResourceKeys.ToHashSet();
foreach (var resource in envelope.Snapshot.Resources)
{
if (changedKeys.Contains(resource.TargetKey))
await UpsertEntryAsync(connection, transaction, envelope.Id, resource.TargetKey, JsonSerializer.Serialize(resource, JsonOptions));
}
// Drop resource rows that no longer exist in this snapshot.
await connection.ExecuteAsync(
"DELETE FROM snapshot_entries WHERE id = @Id AND entry_key <> @ManifestKey AND NOT (entry_key = ANY(@CurrentKeys))",
new { Id = envelope.Id, ManifestKey = ManifestEntryKey, CurrentKeys = resourceKeys },
transaction);
await transaction.CommitAsync();
}
public async Task<ApplicationSnapshot?> LoadAsync(string id)
{
await using var connection = await _dataSource.OpenConnectionAsync();
var manifestJson = await connection.QuerySingleOrDefaultAsync<string>(
"SELECT payload::text FROM snapshot_entries WHERE id = @Id AND entry_key = @ManifestKey",
new { Id = id, ManifestKey = ManifestEntryKey });
if (manifestJson is null) return null; // RestSnapshotStore.load() treats a 404 as "no snapshot yet"
var manifest = JsonSerializer.Deserialize<SnapshotManifest>(manifestJson, JsonOptions)!;
var resourceRows = (await connection.QueryAsync<string>(
"SELECT payload::text FROM snapshot_entries WHERE id = @Id AND entry_key = ANY(@ResourceKeys)",
new { Id = id, ResourceKeys = manifest.ResourceKeys })).ToArray();
return new ApplicationSnapshot
{
SchemaVersion = manifest.SchemaVersion,
ApplicationId = manifest.ApplicationId,
ApplicationVersion = manifest.ApplicationVersion,
CreatedAt = manifest.CreatedAt,
Browser = manifest.Browser,
Metadata = manifest.Metadata,
Resources = resourceRows.Select(r => JsonSerializer.Deserialize<ResourceSnapshot>(r, JsonOptions)!).ToList()
};
}
public async Task DeleteAsync(string id)
{
await using var connection = await _dataSource.OpenConnectionAsync();
await connection.ExecuteAsync("DELETE FROM snapshot_entries WHERE id = @Id", new { Id = id });
}
private static Task UpsertEntryAsync(NpgsqlConnection connection, NpgsqlTransaction transaction, string id, string entryKey, string payloadJson) =>
connection.ExecuteAsync(
"""
INSERT INTO snapshot_entries (id, entry_key, payload) VALUES (@Id, @EntryKey, @Payload::jsonb)
ON CONFLICT (id, entry_key) DO UPDATE SET payload = EXCLUDED.payload
""",
new { Id = id, EntryKey = entryKey, Payload = payloadJson }, transaction);
}
// SnapshotsController.cs — HTTP concerns only: routing, model binding, status codes.
using Microsoft.AspNetCore.Mvc;
public record SaveSnapshotRequest(SnapshotEnvelope Envelope, string[] ChangedResourceKeys);
public record SnapshotResponse(string Id, ApplicationSnapshot Snapshot);
[ApiController]
[Route("api/snapshots")]
public class SnapshotsController : ControllerBase
{
private readonly ISnapshotService _snapshotService;
public SnapshotsController(ISnapshotService snapshotService)
{
_snapshotService = snapshotService;
}
// PUT /api/snapshots/{id} — body: { envelope: SnapshotEnvelope, changedResourceKeys: string[] }
[HttpPut("{id}")]
public async Task<IActionResult> Save(string id, [FromBody] SaveSnapshotRequest request)
{
if (request.Envelope.Id != id) return BadRequest(new { error = "id mismatch" });
await _snapshotService.SaveAsync(request.Envelope, request.ChangedResourceKeys);
return NoContent();
}
// GET /api/snapshots/{id}
[HttpGet("{id}")]
public async Task<ActionResult<SnapshotResponse>> Load(string id)
{
var snapshot = await _snapshotService.LoadAsync(id);
if (snapshot is null) return NotFound(); // RestSnapshotStore.load() treats a 404 as "no snapshot yet"
return new SnapshotResponse(id, snapshot);
}
// DELETE /api/snapshots/{id}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(string id)
{
await _snapshotService.DeleteAsync(id);
return NoContent();
}
}
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddNpgsqlDataSource(builder.Configuration.GetConnectionString("Snapshots")!);
builder.Services.AddScoped<ISnapshotService, SnapshotService>();
var app = builder.Build();
app.MapControllers();
app.Run();
Swap pg/Npgsql/SQL for whatever your real database is (Mongo, Firestore, DynamoDB, SQL Server, a different ORM, etc.) — the shape that matters is the contract with RestSnapshotStore: PUT accepts { envelope, changedResourceKeys } and returns 2xx, GET returns the envelope JSON or 404, DELETE returns 2xx. Nothing on the Angular side needs to know or care how the endpoint actually persists it, or in which language.
Run this workspace
npm install
npm start
npm start builds the library into dist/application-state, then serves the demo app against that compiled package at http://localhost:4200.
npm run build # builds both the library and the demo app
- Angular
- TypeScript
- Signals
- IndexedDB
- Mapbox GL