5.0 KiB
5.0 KiB
Design — runtime theme-token mutator + extension-token surface
Goals
- Let a runtime-data-driven consumer (e.g. tenant-branding fetcher) push partial chassis-token overrides on top of the active
ThemePluginwithout re-registering a wholeThemePlugin. - Let the same consumer push arbitrary
--*CSS custom properties (extension tokens) that are namespace-orthogonal to chassis tokens. - Composability: nested
<ThemeProvider>s and stacked mutators co-exist with innermost-wins semantics per-key. - Cleanup: every
style.setPropertyis balanced by astyle.removePropertyon the mutator's unmount.
Non-goals
- Persistence (out of scope).
- Variant-override mutation (out of scope).
- SSR mutator serialisation (out of scope).
Approach — module-level mutation-layer stack
The provider drives a single module-level stack of mutation layers attached to a single shared root element (default document.documentElement). Each layer carries:
interface MutationLayer {
readonly id: string; // unique per mount; uuid via Math.random+counter
readonly providerId: string; // which provider hosts this layer
readonly origin: "plugin" | "mutator";
chassis: Partial<ThemeTokenSet>; // empty for layer-0-plugin source, full for it
extensions: Record<string, string>; // CSS-var → value
}
Layer-0 of every provider is its own active ThemePlugin. Above it sit the mutator layers, in mount order. When the stack changes (push/pop/replace), the provider runs applyMergedLayers(root):
- Compute
tracked— the union of all CSS-var names ever written by any layer in the stack so far (kept as a module-levelSet<string>). - For each name in
tracked, walk the stack from bottom to top; the last layer that declares the name wins. If no layer declares it,removeProperty(name)(this is the unmount/clear path). - For each "winning"
(name, value),setProperty(name, value).
This is O(layers × tracked). For realistic counts (≤ ~5 layers, ≤ ~50 tracked names) it is trivially fast and runs only on mutation/unmount.
API surface
useThemeMutator(): ThemeMutatorHandle
interface ThemeMutatorHandle {
readonly mutatorId: string;
setChassisOverrides(overrides: Partial<ThemeTokenSet>): void;
setExtensionTokens(tokens: Readonly<Record<string, string>>): void;
clear(): void;
}
- Returns a stable handle for the calling component's lifetime (single
useRef+useEffectcleanup). setChassisOverridesREPLACES the mutator's chassis overrides. To accumulate, the caller merges before calling.setExtensionTokensREPLACES the mutator's extension tokens. Same semantics. Validates every key starts with--; throwsREACT_UI.INVALID_EXTENSION_TOKEN_NAMEwithcontext.tokenNameotherwise.clear()removes the mutator's contributions and triggers a re-apply.- On unmount, an internal
useEffectcleanup callsclear()for the consumer.
useResolvedThemeTokens(): ResolvedThemeTokens
interface ResolvedThemeTokens {
readonly chassis: ThemeTokenSet;
readonly extensions: Readonly<Record<string, string>>;
}
Subscribes to provider's notifier; re-renders when the merged set changes. Used by consumers that want to render based on resolved theme state (e.g. a tenant logo that picks brand-primary).
File layout
packages/react/react-ui/src/theme/
layer-stack/
index.ts
layer-stack.ts # module-level stack + applyMergedLayers
layer-stack.md
layer-stack.test.ts
layer-stack.types.ts
use-theme-mutator/
index.ts
use-theme-mutator.ts
use-theme-mutator.md
use-theme-mutator.test.tsx
use-theme-mutator.types.ts
use-resolved-theme-tokens/
index.ts
use-resolved-theme-tokens.ts
use-resolved-theme-tokens.md
use-resolved-theme-tokens.test.tsx
theme-provider/
theme-provider.tsx # MODIFIED — registers provider as a layer host
theme-provider.test.tsx # extended
Decisions
- Why module-level stack over per-provider stack: every layer ultimately writes to a single shared root element. Nested providers all target
document.documentElementby default; the only coherent semantics are "all layers globally in mount order, innermost (= latest mounted) wins per key". A per-provider stack would still need a cross-provider tiebreaker. - Why a notifier + module-level listeners rather than tree-shaped context:
useResolvedThemeTokensmust see contributions from sibling providers if both target the same root. A flat listener registry handles this naturally. - Why two setters not one: keeps chassis (typed, schema-validated) separate from extensions (untyped, name-validated). Mixing them would force the consumer to spell each chassis token with
--and lose schema typing. - Why
clear()is exposed: lets a fetcher clear branding when the user logs out, without unmounting the component.
Error codes
REACT_UI.INVALID_EXTENSION_TOKEN_NAME— extension token name does not start with--. Context:{ tokenName: string }.