@wcstack/storage
v4.12.0
Published
Declarative persistent storage component for Web Components. Framework-agnostic localStorage/sessionStorage via wc-bindable-protocol.
Maintainers
Readme
@wcstack/storage
🤖 AI coding agents: This README is a package-level reference, not the primary entry point for building a wcstack application. If you have not already done so, first read the repository README and AGENTS.md, then use the wcstack-app skill.
@wcstack/storage is a headless storage component for the wcstack ecosystem.
It is not a visual UI widget. It is an I/O node that connects browser storage (localStorage / sessionStorage) to reactive state.
When combined with @wcstack/state, <wcs-storage> can be bound directly through a path contract:
- Input / Command Surface:
key,type,trigger - Output State Surface:
value,loading,error,errorInfo
This means you can express browser storage persistence declaratively in HTML, without writing localStorage.getItem(), JSON.parse(), or serialization glue code in the UI layer.
@wcstack/storage follows the CSBC (Core / Shell / Binding Contract) architecture:
- Core (
StorageCore) handles storage read/write and cross-tab sync - Shell (
<wcs-storage>) connects that state to the DOM - Frameworks and binding systems consume it via the wc-bindable-protocol
Why this exists
Frontend applications frequently use localStorage / sessionStorage for persisting user settings and session data. Yet the glue code — reading, JSON parsing, saving, error handling — follows the same pattern every time.
@wcstack/storage moves that glue code into a reusable component and exposes the stored value as bindable state.
The flow with @wcstack/state:
<wcs-storage>auto-loads from storage on connectionvalueis bound to the UI viadata-wcs- State changes are automatically written back to storage
- Changes from other tabs are automatically detected
Persistence becomes a state transition, not imperative glue code.
Install
npm install @wcstack/storageQuick Start
1. Primitive value auto-save
Primitive values (strings, numbers, booleans) work with just a value binding for two-way persistence.
<script type="module" src="https://esm.run/@wcstack/storage/auto"></script>
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
<wcs-state>
<script type="module">
export default {
// null is what an empty key loads as — seed the element's real initial
// value, not a convenient one (see "5. Load-before-bind" below)
username: null,
};
</script>
</wcs-state>
<wcs-storage key="username" data-wcs="value#init=element: username"></wcs-storage>
<input data-wcs="value: username" placeholder="Username">
<p>Saved: <span data-wcs="textContent: username"></span></p>This is the default mode:
- Set a
keyto auto-load on connection - Bind to
valuefor two-way persistence - Optionally bind
loadinganderroras well - Add
#init=elementto thevaluebinding so the stored value wins the initial sync — see 5. Load-before-bind for why
2. Persisting a form as one object
The most common shape: several inputs, one storage key, restored on reload. Give the state an accessor pair — a getter that assembles the object and a setter that takes it apart — and bind value#init=element to it:
<wcs-state>
<script type="module">
export default {
form: { name: "", email: "" },
// Save path. Reading each field *by path* makes the getter re-evaluate
// whenever any of them changes; the new object flows into <wcs-storage>'s
// value, which saves it (write-through).
get formSnapshot() {
return { name: this["form.name"], email: this["form.email"] };
},
// Restore path. The persisted object arrives here on load (null for an empty key).
set formSnapshot(v) {
if (v) this.form = { ...this.form, ...v };
},
};
</script>
</wcs-state>
<wcs-storage key="signup-form" type="local"
data-wcs="value#init=element: formSnapshot"></wcs-storage>
<input data-wcs="value: form.name" placeholder="Name">
<input data-wcs="value: form.email" type="email" placeholder="Email">Flow:
- On connection
<wcs-storage>loads the key;#init=elementwrites the loaded object toformSnapshot→ the setter fillsform→ the inputs show the restored values - Typing writes
form.name→formSnapshotis dirty → its binding applies the new object tovalue→ saved - The save's own
valueevent is dispatched while state writesvalue, with the same object: it is state's own write coming back, which is not written to the state (the rule). The setter runs on load, and when another tab saves the key — its object is a new one. Across tabs the key is one unit: the last save wins
Four details that are easy to get wrong:
A draft that belongs to one window (a form half filled in) wants
type="session": it survives a reload, and a second tab gets its own. WithlocalStorage(the default) the second tab's typing replaces the first tab's draft as it is saved. A draft that belongs to a record (a reply to a ticket) wants a key per record instead (key: draftKeywith a getter on the record's id), usually inlocalStorage: one fixed key carries the draft from record to record, and session storage is lost with the tab and copied into a duplicated one (Pages that stay open).Read fields as
this["form.name"], notthis.form.name. The second form registers a dependency onformonly (.nameis a plain property access on the returned object), so editing a field never re-evaluates the getter — see Dependency tracking boundaries.A stored value outlives a deploy. The key holds what the previous version of the app saved, and a tab still open on the previous version receives what the new one saves (the
storageevent), and the reverse — without a word when the shape changed. Keep a version in the stored object and migrate in the setter, or change the key with the shape.With an empty key the first load writes the seed object once:
nullgoes through the setter, and a write to an accessor pair always re-evaluates the getter. Nothing persisted is ever overwritten — a stored object is restored, then re-saved unchanged.
Why not bind value straight to form? A binding is re-applied only when its own path is assigned. A write to form.name — or, for a list, to cart.0.quantity — does not re-apply value: form, so the change is never saved: no error, and a reload brings back the old value. The getter above reads every field by path, so it re-evaluates on each edit. A list works the same way: a row getter that returns what to save and $getAll to collect it (get "cart.*.saved"() { … }, get savedCart() { return this.$getAll("cart.*.saved", []); }). wcs-validate reports the direct form as wcs/input-nested-write, #init=element on a slot seeded with anything but null as wcs/storage-init-seed, and an accessor pair bound without #init=element (whose getter's starting value is saved over the stored one) as wcs/storage-seed-clobber.
This pattern is fixed by packages/storage/__tests__/integration.accessorPairForm.test.ts against the real @wcstack/state.
Saving on demand (manual + trigger)
To save at a moment of your choosing instead of on every change, add manual and drive trigger: from a boolean. The dependency walk is parent → child only, so a settings binding does not fire when settings.theme changes; $dependOn lists the fields to watch and trigger commits the whole object:
<wcs-state>
<script type="module">
export default defineState({
settings: { theme: "light", lang: "en" },
get settingsChanged() {
this.$dependOn("settings.theme");
this.$dependOn("settings.lang");
return true;
},
});
</script>
</wcs-state>
<wcs-storage key="app-settings" manual
data-wcs="value: settings; trigger: settingsChanged">
</wcs-storage>
<select data-wcs="value: settings.theme">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<select data-wcs="value: settings.lang">
<option value="en">English</option>
<option value="ja">日本語</option>
</select>Flow:
- User changes theme →
settings.themeupdates - Dynamic dependency triggers
settingsChangedre-evaluation → returnstrue trigger: settingsChangedbinding fires →save()executes- The entire
settingsobject is saved to localStorage
3. Using sessionStorage
Use type="session" for sessionStorage:
<wcs-state>
<script type="module">
export default { sessionData: null };
</script>
</wcs-state>
<wcs-storage key="session-data" type="session"
data-wcs="value#init=element: sessionData">
</wcs-storage>
<p data-wcs="textContent: sessionData"></p>4. Cross-tab sync
localStorage changes are automatically detected from other tabs:
<wcs-state>
<script type="module">
export default {
// see "5. Load-before-bind" below
sharedCounter: null,
};
</script>
</wcs-state>
<wcs-storage key="shared-counter"
data-wcs="value#init=element: sharedCounter">
</wcs-storage>
<!-- Changes from other tabs update this value automatically -->
<p data-wcs="textContent: sharedCounter"></p>Note: The
storageevent only fires for changes made in other tabs of the same origin. Since sessionStorage is not shared across tabs, cross-tab sync only works with localStorage.
5. Load-before-bind: #init=element
<wcs-storage> loads and announces the persisted value in its own connectedCallback. Depending on script loading, that can happen before <wcs-state> finishes attaching its bindings — with two consequences:
- Clobber:
valueis the one bidirectional member on this element, so its default binding authority isstate. The initial state→element apply writes the state slot's seed intovalue, and the write-through save overwrites the persisted data on every reload. - Missed load: the value event announcing the loaded data fired before anyone was listening, so the state slot can stay at its seed.
One modifier closes both. #init=element makes the element the authority for the initial sync:
<wcs-state>
<script type="module">
export default {
// An empty key loads as null, so that is the honest seed. Reads go
// through a normalizing getter instead of a convenient [] seed.
todos: null,
get list() {
return Array.isArray(this.todos) ? this.todos : [];
},
};
</script>
</wcs-state>
<wcs-storage key="todos" type="local" data-wcs="value#init=element: todos"></wcs-storage>#init=elementskips the initial state→element write (no clobber) and pulls the element's currentvalueinto the state slot (no missed load).- Authority governs the initial sync only: every later
todosassignment still flows state→element, so auto-save keeps working. - Seed the slot with the element's real initial value (
nullfor an empty key) and null-guard reads through a derived getter. A convenient[]/""seed does not survive the initial pull anyway. - Needs
enableDirectionalInitialSync, on by default (since v1.21.0; in 4.0 a key of the root state's$behavior, no longer abootstrapState()option). If you turn it off ($behavior: { enableDirectionalInitialSync: false }),#init=throws; seedundefinedand pull once in$connectedCallbackinstead. - Working examples:
examples/state-cross-tab-todo,examples/state-color-palette.
State Surface vs Command Surface
<wcs-storage> exposes two kinds of properties.
Output State (bindable state)
Represents the current storage value and is the CSBC main surface:
| Property | Type | Description |
|----------|------|-------------|
| value | any | Value stored in storage |
| loading | boolean | true during read/write |
| error | WcsStorageError \| Error \| null | Storage operation error |
| errorInfo | WcsIoErrorInfo \| null | Serializable failure taxonomy (stable code / phase / recoverable), derived from error. Additive — the error shape is unchanged. |
Input / Command Surface
Controls storage operations from HTML, JS, or @wcstack/state bindings:
| Property | Type | Description |
|----------|------|-------------|
| key | string | Storage key |
| storageKey | string | key under another name, for React and Vue (since 4.6; see Framework Integration) |
| type | "local" \| "session" | Storage type |
| value | any | Setting this auto-saves (when not manual) |
| trigger | boolean | One-way save trigger |
| manual | boolean | Disables auto-load and auto-save |
null and undefined. The inputs backed by an attribute (key, type, manual) take null as "clear": the attribute is removed and the input falls back to its default (no key, local, not manual). undefined means "no value supplied": the attribute goes back to what it was before the first write — the value written in the markup, or none. Neither is ever written as the string "null" / "undefined". (@wcstack/state never writes undefined; React 19 does when a prop that had a value is removed, and so can a direct assignment.) value = null removes the key from storage (in manual mode it stages null for the next save); value = undefined does nothing — the stored entry is kept and nothing is staged. trigger ignores both.
CSS styling with :state()
<wcs-storage> reflects two boolean output states onto its
ElementInternals CustomStateSet,
so you can style it directly from CSS with the :state() pseudo-class — no
data-wcs binding or extra class toggling required.
| State | On when |
|-------|---------|
| loading | wcs-storage:loading-changed fires with true (cleared on false) |
| error | wcs-storage:error fires with a non-null detail (cleared on null) |
wcs-storage:state(loading) ~ .spinner { display: block; }
wcs-storage:state(loading) ~ .spinner { display: none; } /* default */
form:has(wcs-storage:state(error)) .banner { display: block; }Unlike attributes or classes, :state() cannot be written from outside the
element, so there is no risk of confusing this output state with an input.
Browser support (:state(x) syntax): Chrome/Edge 125+, Safari 17.4+,
Firefox 126+. In older browsers the states are simply never set — :state()
selectors never match, but <wcs-storage> itself keeps working normally
(graceful degradation, never-throw).
SSR: :state() cannot be serialized into HTML, so server-rendered markup
never carries these states on first paint (@wcstack/server is unaffected).
If you need to style the pre-hydration gap, pair your rule with
wcs-storage:not(:defined) instead.
Debugging
Custom states are invisible in DevTools' Elements panel and attachInternals()
cannot be called twice, so there is no console way to inspect them directly.
Two debug-only aids are provided for that:
el.debugStates— a snapshot array of the currently-on state names (e.g.["loading"]). It is not part ofwc-bindable(not a bind target) and its shape is not a guaranteed contract — use it for debugging only.The
debug-statesattribute (opt-in, default off) mirrors state changes ontodata-wcs-state-loading/data-wcs-state-errorattributes on the element, so the Elements panel highlights them as they toggle:<wcs-storage key="prefs" debug-states></wcs-storage>
Write your CSS against :state(), not data-wcs-state-*. The mirrored
attributes exist purely to make state changes visible while debugging with
DevTools open; they are not a supported styling hook.
Architecture
@wcstack/storage follows the CSBC architecture.
Core: StorageCore
StorageCore is a pure EventTarget class. It encapsulates:
- Storage read, write, and remove
- Automatic JSON serialization / deserialization
- Cross-tab sync via
storageevent wc-bindable-protocoldeclaration
It works headlessly in any runtime that supports EventTarget and localStorage / sessionStorage.
Shell: <wcs-storage>
<wcs-storage> is a thin HTMLElement wrapper around StorageCore. It adds:
- Attribute / property mapping
- DOM lifecycle integration (auto-load on connect, cleanup on disconnect)
- Auto-save via the
valuesetter - Declarative execution helpers like
trigger
This separation keeps storage logic portable while enabling natural integration with DOM-based binding systems like @wcstack/state.
Target injection
Core uses target injection to fire events directly on the Shell, avoiding event re-dispatch.
Headless usage (Core only)
StorageCore can be used standalone without DOM. It declares static wcBindable, so you can subscribe to state with bind() from @wc-bindable/core — the same mechanism used by framework adapters:
import { StorageCore } from "@wcstack/storage";
import { bind } from "@wc-bindable/core";
const core = new StorageCore();
const unbind = bind(core, (name, value) => {
console.log(`${name}:`, value);
});
core.key = "my-data";
core.load();
unbind();Auto JSON serialization
StorageCore automatically serializes and deserializes based on data type:
| Type on save | Format in storage | Type on load |
|-------------|-------------------|-------------|
| Object / Array | JSON.stringify() result | JSON.parse() result |
| String | As-is | Parsed if valid JSON, otherwise the raw string |
| Number / boolean | JSON.stringify() result | JSON.parse() result |
| null / undefined | Key removed | null |
| A Blob / File, a Map / Set, binary data (anywhere in the value) | {} (JSON keeps none of them) | an empty object |
This is the Core's save(value). A recorded clip or a picked file kept in a saved draft comes back as {} after a reload; keep such values out of the stored one (IndexedDB or a server keeps them). Since 4.5 a save that holds one warns once per element ([wcs/storage-unserializable]), naming what and where. The element's value setter removes the key on null but ignores undefined (see Input / Command Surface).
The structural Core surface is normative across wcstack IO nodes (async-io-node-guidelines §3.9); to bind it into signals with no element at all, see @wcstack/signals — Binding a Core directly.
Element Reference
<wcs-storage>
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| key | string | — | Storage key |
| storage-key | string | — | The storage key under a name React and Vue leave alone (since 4.6); key wins when both are written |
| type | "local" \| "session" | local | Storage type |
| manual | boolean | false | Disables auto-load and auto-save |
| Property | Type | Description |
|----------|------|-------------|
| value | any | Storage value (auto-saves on set) |
| loading | boolean | true during read/write |
| error | WcsStorageError \| Error \| null | Error info |
| errorInfo | WcsIoErrorInfo \| null | Failure taxonomy (code / phase / recoverable), derived from error |
| trigger | boolean | Set true to execute save |
| manual | boolean | Manual mode |
| Method | Description |
|--------|-------------|
| load() | Load value from storage |
| save() | Save current value to storage |
| remove() | Remove key from storage |
wc-bindable-protocol
Both StorageCore and <wcs-storage> conform to the wc-bindable-protocol, enabling interop with any protocol-aware framework or component.
The declaration follows the full wc-bindable interface model — three independent surfaces:
properties— observable outputs thatbind()subscribes to (value,loading,error, and the Shell'strigger)inputs— the settable surface (key,type, …); declarative metadata that tooling, codegen, and remote proxying readcommands— invocable methods (load,save,remove); a binding system such as@wcstack/statecan invoke them by name
Per the protocol, only properties is interpreted by core bind(); inputs / commands (and the attribute / async hints) are descriptive. They do not create implicit two-way data flow.
Core (StorageCore)
StorageCore declares the bindable state any runtime can subscribe to, plus its portable input/command surface:
static wcBindable = {
protocol: "wc-bindable",
version: 1,
properties: [
{ name: "value", event: "wcs-storage:value-changed",
getter: (e) => e.detail },
{ name: "loading", event: "wcs-storage:loading-changed" },
{ name: "error", event: "wcs-storage:error" },
{ name: "errorInfo", event: "wcs-storage:error-info-changed" },
],
inputs: [
{ name: "key" },
{ name: "type" },
],
commands: [
{ name: "load" },
{ name: "save" },
{ name: "remove" },
],
};Headless consumers call core.load() / core.save(value) directly — no trigger needed.
Shell (<wcs-storage>)
The Shell extends the Core declaration with the trigger output and the DOM-driven input surface; commands (load / save / remove) are inherited unchanged:
static wcBindable = {
...StorageCore.wcBindable,
properties: [
...StorageCore.wcBindable.properties,
{ name: "trigger", event: "wcs-storage:trigger-changed" },
],
inputs: [
{ name: "key" },
{ name: "storageKey" },
{ name: "type" },
{ name: "value" },
{ name: "manual" },
{ name: "trigger" },
],
};The Shell's inputs intentionally carry no attribute hint: the key / storageKey / type / manual setters already reflect to their attributes, so a binding system that mirrors inputs[].attribute would set the attribute twice.
TypeScript Types
import type {
WcsStorageError, WcsStorageCoreValues, WcsStorageValues, StorageType
} from "@wcstack/storage";type StorageType = "local" | "session";
// Storage operation error
interface WcsStorageError {
operation: "load" | "save" | "remove" | "type";
message: string;
}
// Core (headless) — 3 state properties
interface WcsStorageCoreValues<T = unknown> {
value: T;
loading: boolean;
error: WcsStorageError | Error | null;
}
// Shell (<wcs-storage>) — extends Core with trigger
interface WcsStorageValues<T = unknown> extends WcsStorageCoreValues<T> {
trigger: boolean;
}Why it works well with @wcstack/state
@wcstack/state uses path strings as the sole contract between UI and state.
<wcs-storage> fits naturally into this model:
<wcs-storage>auto-loads from storage on connectionvalueis bound to a state path, reflected in the UI- User interactions update state, which auto-saves back to storage
- State survives page reloads
- The binding takes
#init=elementand the slot starts asnull(Quick Start 5), so a reload never overwrites what was saved
Persistence looks just like any other state update.
Framework Integration
<wcs-storage> is CSBC + wc-bindable-protocol, so it works in any framework via thin @wc-bindable/* adapters.
Write storage-key in a framework's template, not key (since 4.6). React and Vue keep key for themselves and never hand it to the element: <wcs-storage key="…"> in JSX or a Vue template has no key, so it loads and stores nothing, and the element now warns [wcs/command-not-ready] at its first save. storage-key (the property storageKey) is the same input; the examples below use it in every framework. On 4.5 and earlier, set the property through a ref: el.key = "app-settings".
React
import { useWcBindable } from "@wc-bindable/react";
import type { WcsStorageValues } from "@wcstack/storage";
interface Settings { theme: string; lang: string; }
function SettingsPanel() {
const [ref, { value: settings, loading, error }] =
useWcBindable<HTMLElement, WcsStorageValues<Settings>>();
return (
<>
<wcs-storage ref={ref} storage-key="app-settings" />
{loading && <p>Loading...</p>}
{settings && <p>Theme: {settings.theme}</p>}
</>
);
}Saving from React. Do not pass value as a prop built in the render (value={{ q, page }}). Each render writes a new object, each write saves and announces value, and the adapter's update renders again: React stops with "Maximum update depth exceeded". The first render would also write the empty defaults over what was stored. Read the stored value once, then write only when a field changed:
type StorageElement = HTMLElement & { value: Filters | null };
function FilterStore({ filters, onRestore }: { filters: Filters; onRestore: (f: Filters) => void }) {
const el = useRef<StorageElement | null>(null);
const [restored, setRestored] = useState(false);
// The element loads when it connects, and its ref runs after that: read the stored value once.
const ref = useCallback((node: StorageElement | null) => {
el.current = node;
if (node === null) return;
if (node.value) onRestore(node.value);
setRestored(true);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Save after the read, and only when a field changed: the dependencies are the fields, not an object.
useEffect(() => {
if (restored && el.current) el.current.value = { q: filters.q, page: filters.page };
}, [restored, filters.q, filters.page]);
return <wcs-storage ref={ref} storage-key="inv-filters" />;
}@wcstack/state does the same with value#init=element (§5).
Following other tabs. Another tab's save arrives as wcs-storage:value-changed. Listen to it on the element, and skip the events your own save dispatches (they fire during the assignment). Applying the adapter's value instead brings your own save back a render late, and it undoes what was typed in between: typing "bolt" quickly saved and applied "bol" and "bolt" in turn without end.
const saving = useRef(false);
useEffect(() => {
const node = el.current;
if (!node) return;
const onChange = (e: Event) => {
const v = (e as CustomEvent<Filters | null>).detail;
if (!saving.current && v) onRestore(v);
};
node.addEventListener("wcs-storage:value-changed", onChange);
return () => node.removeEventListener("wcs-storage:value-changed", onChange);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// and in the effect that saves:
// saving.current = true; try { el.current.value = { q: filters.q, page: filters.page }; } finally { saving.current = false; }examples/fw-inventory keeps its filters this way.
Vue
<script setup lang="ts">
import { useWcBindable } from "@wc-bindable/vue";
import type { WcsStorageValues } from "@wcstack/storage";
interface Settings { theme: string; lang: string; }
const { ref: settings, values } = useWcBindable<HTMLElement, WcsStorageValues<Settings>>();
</script>
<template>
<wcs-storage ref="settings" storage-key="app-settings" />
<p v-if="values.loading">Loading...</p>
<p v-else-if="values.value">Theme: {{ values.value.theme }}</p>
</template>Name the template ref statically (ref="settings"): in <script setup> a :ref="…" binding gets the ref unwrapped, null, and the adapter never binds.
Svelte
<script>
import { wcBindable } from "@wc-bindable/svelte";
let settings = $state(null);
let loading = $state(false);
</script>
<wcs-storage storage-key="app-settings"
use:wcBindable={{ onUpdate: (name, v) => {
if (name === "value") settings = v;
if (name === "loading") loading = v;
}}} />
{#if loading}
<p>Loading...</p>
{:else if settings}
<p>Theme: {settings.theme}</p>
{/if}Solid
import { createWcBindable } from "@wc-bindable/solid";
import type { WcsStorageValues } from "@wcstack/storage";
interface Settings { theme: string; lang: string; }
function SettingsPanel() {
const [values, directive] = createWcBindable<WcsStorageValues<Settings>>();
return (
<>
<wcs-storage ref={directive} storage-key="app-settings" />
<Show when={!values.loading} fallback={<p>Loading...</p>}>
<p>Theme: {values.value?.theme}</p>
</Show>
</>
);
}Vanilla — direct bind()
import { bind } from "@wc-bindable/core";
const storageEl = document.querySelector("wcs-storage");
bind(storageEl, (name, value) => {
console.log(`${name} changed:`, value);
});Optional DOM Triggering
When autoTrigger is enabled (default), clicking an element with a data-storagetarget attribute executes save() on the corresponding <wcs-storage>:
<button data-storagetarget="settings-store">Save Settings</button>
<wcs-storage id="settings-store" key="settings" manual
data-wcs="value: settings"></wcs-storage>Configuration
import { bootstrapStorage } from "@wcstack/storage";
bootstrapStorage({
autoTrigger: true,
triggerAttribute: "data-storagetarget",
tagNames: {
storage: "wcs-storage",
},
});bootstrapStorage() throws on an option it does not have, on a value whose type differs from the option's default (null, or an array where an object is expected, included), and on a tagNames key it does not define or a tag name that is not a string. It checks every option before applying any, so nothing is applied when it throws. An option whose value is undefined is skipped.
Design Notes
- Since 4.8, a reconnect loads in a microtask. The first connect loads at once — a binding attached in the same task pulls
valueand expects it loaded. A reconnect loads one microtask later, with thekeyas it is then (timing contract §23):@wcstack/routerputs a route's elements back on a parameter-only navigation before@wcstack/statewrites the next route's inputs, and loading at once read the previous route's key and published its value for a moment. value,loading,errorare output statekey,type,triggerare input / command surfacetriggeris intentionally one-way: writingtruesaves, reset signals completion.save()is never-throw (a failure such as an unsetkeyis routed to theerrorproperty, not thrown);triggeris reset tofalseand the completion event fires regardless, so it never gets stuck in thetruestate.- The
valuesetter auto-saves when not inmanualmode valuesetter vssave()/trigger: assigningvalue(non-manual) persists the assigned argument (write-through).save()andtrigger, by contrast, persist the currentvalue— which a priorload()or a cross-tabstorageevent may have updated. This meanstrigger/save()can write back a value that arrived from another tab.valueinmanualmode: thevaluesetter stages the value (no storage write) instead of persisting it.el.value = xupdates the readable value (el.value === x) but does not touch storage; the actual write happens only viasave()/trigger. This is what makes thevalue: …+trigger: …binding pair work — the bound value is staged, then committed on trigger.- No echo guard on the non-manual
valuepath: only the staging path (the Corevaluesetter, used inmanualmode) skips a same-valuevalue-changedre-dispatch. The main non-manual path (valuesetter →save()) is deliberately write-through: every assignment persists and re-emitsvalue-changed, even when the assigned value equals the current one. This is intentional — the write-through contract above must hold, and same-tabstorageevents do not re-fire, so there is no feedback loop. In adata-wcs="value: x"two-way binding the echoedvalue-changedis harmless:@wcstack/statededups the round-trip on its side. savecommand arity: the headless Core takessave(value), while the Shell exposessave()(persists the current value). Both appear under the samecommandsnamesave; the protocol'scommandsmetadata is descriptive and arity-less, so this is contractual, not a protocol violation.- Invalid
type: anytypeattribute other than"session"is treated as"local". An invalid value (e.g.type="sesion") falls back tolocalrather than throwing, and the element warns[wcs/storage-type-invalid]once per value: the data is kept on disk after the session ends. - Runtime
typechange: changing thetypeattribute after connection updates the Core's storage area for subsequent operations but does not re-load from the new area (onlykeychanges auto-reload in non-manual mode). Re-load explicitly withload()if you need the value from the newly selected area. errorshape: on a storage failure,erroris set to aWcsStorageError({ operation, message }) identifying which call failed (load/save/remove, ortypefor an invalid headlesstypeassignment). Operations are never-throw: calling one with no key does not throw — it is surfaced onerroras{ operation, message: "key is required." }(and dispatched aswcs-storage:error), and the element warns[wcs/command-not-ready], once until it has a key again (since 4.8; before, once per element). In practiceerroris therefore always either aWcsStorageErrorornull; the widerWcsStorageError | Error | nulltype is kept for forward compatibility and consistency with sibling packages.errorInfotaxonomy: an additive bindable output (wcs-storage:error-info-changed) that classifies the same failure into a serializableWcsIoErrorInfowith a stablecode/phase/recoverable, without changing theerrorshape. Validation failures (invalidtype/ missingkey) areinvalid-argument(phasestart, not recoverable). A caught storage exception is phaseexecuteand classified by itsError.name:QuotaExceededError→quota-exceeded(recoverable — succeeds once space is freed),SecurityError→not-allowed(storage access denied, not recoverable), anything else →storage-error.errorInfotransitions exactly whenerrordoes (cleared tonullon any successful operation); the sharedWcsIoErrorInfotype and theWCS_STORAGE_ERROR_CODEconstants are exported.- JSON auto-serialization handles objects, arrays, and primitives transparently
- Saving
nullremoves the key from storage (so does the Core'ssave(undefined)); assigningundefinedto the element'svalueis a no-op that keeps the stored entry - Cross-tab sync via
storageevent works only with localStorage. The Shell binds the watcher to its currentkey/typeon connect (and re-binds on re-attach), so cross-tab sync works even inmanualmode where no auto-load runs. Changing thekeyattribute after connection always re-syncs the Core key, so cross-tab sync follows the new key even inmanualmode or when the key is cleared. A successful cross-tab update also clears any staleerror(just likeload()/save()/remove()do at the start of a successful operation), so a fresh value never coexists with a leftover error from an earlier failure. manualis useful when you want explicit control over save timing
License
MIT
