4.7 KiB
layer-stack — module-level mutation-layer engine
(no submodules)
Purpose
The layer-stack module is the runtime engine that backs <ThemeProvider>, useThemeMutator(), and useResolvedThemeTokens(). It owns a single ordered list of MutationLayer records, each carrying that layer's contribution to the CSS custom-property bag applied on document.documentElement (or whatever root element the provider has bound). Each <ThemeProvider> pushes one plugin layer when it mounts and replaces it whenever setTheme(...) resolves. Each useThemeMutator() push s its own mutator layer on mount and removes it on unmount. The stack engine is the single point that touches the DOM — every consumer surface ultimately delegates to it, which is how nested providers and stacked mutators end up with a coherent innermost-wins-per-key semantics on a shared root element.
The stack is module-level by design: every provider in the tree targets the same root, so per-provider state would still need a cross-provider tiebreaker. Putting the stack at the module level means the ordering is exactly the React mount order, which is the only ordering with a sensible "innermost wins" interpretation.
Features
- Push / update / remove a layer, identified by a stable
idgenerated vianewLayerId(...). - Track every CSS custom-property name the stack has ever written, so unmount cleanly removes any orphan.
- Subscribe to stack changes via
subscribe(listener)— fired AFTER the DOM has been re-applied so listeners observe the live state. - Read the merged result against an active-plugin baseline via
getMergedResolved(baseline)for consumer hooks. - Test-only entry points (
__resetLayerStackForTests,bindRoot) so each test can mount into a clean root and tear down without leaking state across files.
Usage: Basic
import {
bindRoot,
newLayerId,
newProviderId,
pushLayer,
removeLayer,
} from "@suaveplan/react-ui/theme/layer-stack";
bindRoot(document.documentElement);
const providerId = newProviderId();
const layerId = newLayerId("plugin");
pushLayer({
id: layerId,
providerId,
origin: "plugin",
chassis: { primary: "rgb(10 10 10)" },
extensions: { "--brand-font": "Inter" },
});
// later
removeLayer(layerId);
Usage: Advanced
A consumer that wants to react to merged stack changes — for example a recharts colour palette consumer — subscribes via subscribe() and reads the live snapshot:
import {
getMergedResolved,
subscribe,
} from "@suaveplan/react-ui/theme/layer-stack";
import { lightTheme } from "@suaveplan/react-ui/theme";
const unsubscribe = subscribe(() => {
const { chassis, extensions } = getMergedResolved(lightTheme.cssVariables);
repaint(chassis.primary, extensions["--brand-accent"] ?? chassis.accent);
});
// teardown
unsubscribe();
API Reference
newProviderId(): string— mint a unique provider identifier.newLayerId(prefix: "plugin" | "mutator"): string— mint a unique layer identifier.pushLayer({ id, providerId, origin, chassis?, extensions?, motionTokens?, fontTokens? })— append a new layer and re-apply.updateLayer(id, { chassis?, extensions?, motionTokens?, fontTokens? })— replace a layer's bags (each bag is replaced, not merged).removeLayer(id)— drop a layer and re-apply.subscribe(listener)— register a change listener; returns anunsubscribefunction.getMergedResolved(baseline)— compute the live read state against a baseline chassis bag.snapshotLayers()— read-only copy of the current layer list. For tests anduseSyncExternalStore.chassisTokenToCssVar(name)—cardForeground→--card-foreground.bindRoot(root)/__resetLayerStackForTests()— test-only.
Implementation Notes
The merge function walks layers in mount order; the last layer to declare a name wins. After computing the merged set, every name that was previously tracked but is no longer present gets removeProperty(...), which is how unmount cleanup falls out for free. Tracked names are never forgotten — that is intentional, because a name first written by layer N and later supplied only by layer M still has to be removed if M itself unmounts.
getMergedResolved deliberately copies its inputs into fresh Record<string,string> bags so consumers cannot mutate the live layer storage by accident. The returned extensions bag is frozen.
pushLayer notifies AFTER applying the DOM, never before. This guarantees that a listener reading getMergedResolved(...) (or getPropertyValue(...)) on the next tick sees the post-write state. A pre-write notify would race with useSyncExternalStore.