@mmstack/primitives 21.0.23 → 21.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,6 +69,14 @@ const upper = derived(user, {
69
69
 
70
70
  When the source is a `MutableSignal`, the derived signal is also a `MutableSignal` — `derived(state, 'items').mutate(arr => { arr.push(...); return arr })` propagates correctly.
71
71
 
72
+ Pass `vivify` on the key/index form to create a missing container when writing through a `null`/`undefined` source — instead of throwing (mutable / array) or dropping the write. Choose `'object'`, `'array'`, `'auto'` (an array for index keys, an object otherwise), or a `() => container` factory; it defaults to off.
73
+
74
+ ```typescript
75
+ const user = signal<{ name: string } | null>(null);
76
+ derived(user, 'name', { vivify: 'object' }).set('Ada');
77
+ // user() === { name: 'Ada' }
78
+ ```
79
+
72
80
  ### `store` / `mutableStore`
73
81
 
74
82
  Proxies an object (or signal of an object) into a tree of `WritableSignal`s — one per property, lazily created and cached via `WeakRef`. Arrays expose indices as signals plus a `.length` signal and `Symbol.iterator`. Mutability propagates: if the root is a `MutableSignal`, every child is too.
@@ -92,8 +100,52 @@ settings.notifications.mutate((n) => {
92
100
  });
93
101
  ```
94
102
 
103
+ **Autovivification (opt-in).** By default, a write through a `null`/`undefined` path is dropped. Pass `vivify` to create the missing intermediate containers instead:
104
+
105
+ ```typescript
106
+ const form = store(
107
+ { user: null as { address?: { city: string } } | null },
108
+ { vivify: 'auto' },
109
+ );
110
+
111
+ form.user.address.city.set('NYC');
112
+ // form() === { user: { address: { city: 'NYC' } } }
113
+ ```
114
+
115
+ Each level's shape is resolved from what's known: a value that is currently an object/array re-creates as that same shape (resolved per path and cached, so it survives the value later being nulled), while genuinely-unknown levels follow your option — `'auto'` (an array for index keys, an object otherwise), `'object'`, `'array'`, or a `() => container` factory. `false` (the default) keeps writes through `null` as no-ops. Adding a key that simply wasn't present on an existing object always works and needs no `vivify`.
116
+
95
117
  Top-level array support isn't exposed yet — use `indexArray` / `keyArray` for those.
96
118
 
119
+ ### `extend` (scoped overlay)
120
+
121
+ `store.extend(seed)` (on any store kind) creates a **scoped overlay** — a child store that **shares** the parent's signals for inherited keys (the same `WritableSignal`: writes go through to the parent and parent changes flow down) while keeping the seed and any new keys in a **local layer** that never propagates upward. No diffing, no syncing — local keys simply aren't wired to the parent.
122
+
123
+ ```typescript
124
+ const app = store({ user: { name: 'Alice' }, theme: 'dark' });
125
+
126
+ const scope = app.extend({ draft: '' }); // inherits user + theme, adds a local draft
127
+
128
+ scope.user === app.user; // true — the same signal (shared, two-way)
129
+ scope.user.name.set('Bob'); // writes through to the parent
130
+ scope.draft.set('hello'); // local only — `app` never gains `draft`
131
+ scope(); // { user: { name: 'Bob' }, theme: 'dark', draft: 'hello' }
132
+ ```
133
+
134
+ Resolution per key is **local → parent → local**: a seed key (or one set on the scope before it exists on the parent) is local and _shadows_ the parent — and keeps shadowing even if the parent later grows that key; a key that exists only on the parent writes through to it; a brand-new key lands locally. `scope()` is the merged view (local shadowing), and `Object.keys(scope)` / `key in scope` are the union of both layers. `extend` composes — `a.extend(x).extend(y)` chains parents.
135
+
136
+ The seed may also be a **signal** of the matching kind, so an existing (externally-owned, reactive) signal becomes the local layer:
137
+
138
+ ```typescript
139
+ const draft = signal({ title: '' });
140
+ const scope = app.extend(draft); // writes to scope.title flow out to `draft`, and back in
141
+ ```
142
+
143
+ A few release notes:
144
+
145
+ - The local layer is a plain store (vivify off). Inherited paths vivify when the _parent_ was created with `vivify`; to autovivify local keys, seed with a vivify-enabled store — `app.extend(store(seed, { vivify: 'auto' }))`.
146
+ - Reserved names — `extend`, `asReadonlyStore`, and the signal methods (`set` / `update` / `mutate` / `inline` / `asReadonly`) — shadow same-named data keys, as on any store.
147
+ - `scope.asReadonlyStore()` returns a read-only **snapshot view** of the merge (reactive reads, no writes); it does not share sub-store identity.
148
+
97
149
  ### `toWritable`
98
150
 
99
151
  Turn any read-only `Signal<T>` into a `WritableSignal<T>` by providing custom `set` / `update` implementations. Powers `derived` internally; use it directly when you have a `computed` you want to expose as writable.