@zakkster/lite-map
v1.4.1
Published
Zero-GC keyed list reconciliation for @zakkster/lite-signal: mapArray and indexArray that MOVE and REBIND per-item reactive scopes instead of rebuilding them, reusing retired scopes from a free-list so steady-state list churn never grows the pool.
Maintainers
Readme
@zakkster/lite-map
Zero-GC keyed list reconciliation for @zakkster/lite-signal.
Map a reactive array to one reactive scope per item. When the list mutates, lite-map moves and rebinds the existing scopes instead of rebuilding them, and only the items that actually changed recompute.
The thing that sets it apart from Solid's mapArray: removed items' scopes are not
thrown away. They go to a free-list and are reused by later inserts. So the
realistic shape of a live list -- a few rows in, a few out, a reorder -- pulls
nothing from the lite-signal pool and never grows it.
DOM-free. The mapped output is whatever your mapFn returns: a DOM node, a derived
value, a sub-view. A <For> is one consumer of the () => O[] accessor, not the
primitive itself.
npm install @zakkster/lite-mapPeer dependency:
@zakkster/lite-signal^1.6.0-beta-1. lite-map is built oncreateScope-- the detached per-item-subtree disposal primitive introduced in1.6.0-preview.0. ESM only. MIT.
The idea
A naive array -> views mapping rebuilds every view when the array changes. That
allocates, runs every item's setup again, and (for DOM) churns nodes. lite-map keeps
a scope per item and reconciles:
- value changed, position same -> set one signal, the item's own effects re-run.
- item moved -> set its index signal, only index-dependent bindings re-run.
- item removed -> its scope is parked on a free-list.
- item inserted -> a parked scope is rebound to the new item (no rebuild), or one is built if the pool is empty.
Two primitives, differing in what they key on. Both pass the changing dimension
as an accessor -- that is precisely what lets a parked scope be reused by setting a
signal rather than re-running mapFn.
indexArray -- position-keyed
item is an accessor (the value at this slot can change); index is a plain number
(the slot itself is stable). For fixed-shape lists whose contents change: a grid,
a fixed set of rows, a ring of samples.
import { indexArray } from "@zakkster/lite-map";
import { signal, effect } from "@zakkster/lite-signal";
const prices = signal([100, 200, 300]);
const cells = indexArray(prices, (price, i) => {
const el = document.createElement("div");
effect(() => { el.textContent = `#${i}: ${price()}`; }); // re-runs only when THIS slot's value changes
return el;
});
// later: same length, new values -> three signal sets, zero scopes rebuilt
prices.set([110, 200, 333]);Value churn at a stable length is pure signal-set -- zero-GC. Growing appends new slots; shrinking parks tail slots on a free-pool, and growing back reuses them (push/pop at the tail is zero-GC).
mapArray -- item-keyed
Both item and index are accessors. Items keep their identity across a reorder
(their reactive index updates to the new position), and a retired scope can be reused
for a brand-new item. For lists where rows are identities that move, come, and go:
a todo list, a feed, a sortable table.
import { mapArray } from "@zakkster/lite-map";
import { signal, effect } from "@zakkster/lite-signal";
const todos = signal([
{ id: 1, text: "buy milk" },
{ id: 2, text: "walk dog" },
]);
const rows = mapArray(
todos,
(todo, index) => {
const li = document.createElement("li");
effect(() => { li.textContent = `${index() + 1}. ${todo().text}`; });
return li;
},
{ key: (t) => t.id }, // default is reference identity; here we key by id
);
// reorder: both rows survive, their index accessors update -> no rebuild
todos.set([{ id: 2, text: "walk dog" }, { id: 1, text: "buy milk" }]);
// remove id:1, then insert id:3 -> id:1's scope is parked, then reused for id:3
todos.set([{ id: 2, text: "walk dog" }]);
todos.set([{ id: 2, text: "walk dog" }, { id: 3, text: "pay rent" }]);Keys must be unique within the list: only the first occurrence of a key is addressable,
so a duplicate gets a scope of its own but is never reachable by key. Since 1.1 a duplicate
is contained -- it cannot evict the entry of the row that legitimately owns that key, so a
transient duplicate (an overlapping page, a fanned-out join) can no longer cost a unique key
its identity once the list is unique again. maxPool caps how many retired scopes are kept
for reuse (default: unbounded).
Scope lifecycle
Each item's scope moves through these states. Parking and reuse are what keep churn off the allocator.
stateDiagram-v2
[*] --> Live: build (createScope) -- or reuse a Parked scope
Live --> Live: item changed (itemSig.set) / moved (idxSig.set)
Live --> Parked: removed from the list -> free-list (LIFO)
Parked --> Live: insert reuses it (rebind item + index signals)
Parked --> Disposed: dispose() / pool over maxPool
Live --> Disposed: dispose()
Disposed --> [*]The build and dispose edges are the only ones that touch the pool. Steady-state list editing rides the Live <-> Parked loop, which sets signals and rebinds -- no allocation, no pool growth.
Output and ownership
mapped() returns the current outputs in source order. It is one persistent array,
mutated in place -- read it fresh on each reactive run; do not retain it or compare
it by reference. It fires only on a real structural change (membership or order), not
on value-only churn (those flow through each item's own effects).
effect(() => {
parent.replaceChildren(...rows()); // re-runs when rows are added/removed/reordered
});Each item owns a createScope scope: its mapFn effects and computeds are cascaded
on disposal. Per-item scopes and the reconcile driver are detached from the calling
scope -- an enclosing effect re-running will never tear your list down. In exchange,
the caller owns teardown:
rows.dispose(); // stop the driver, dispose every live + parked scopeThere is no auto-cleanup. Call dispose() when the list is no longer needed (a <For>
wrapper does this on unmount).
Zero-GC -- and the honest non-claims
Measured against the engine's pool counters (poolGrowths / totalAllocations),
flat after warm-up:
| Operation | Pool behavior |
| --- | --- |
| indexArray value churn, stable length | flat (20k updates, verified) |
| mapArray reorder / move | flat -- idxSig.set on shifted survivors |
| mapArray insert/remove, warm pool | flat (5k iters, verified) -- reuse a parked scope |
| indexArray tail grow/shrink | flat -- serviced by the free-pool |
Not claimed:
- Growth past the previous high-water mark pulls scopes from the pool -- there is
no representing N+1 distinct items with N scopes. Warm to your peak. Since 1.2 this
is measurable:
mapped.stats().highWateris exactly that mark, so a run stays pool-flat for as long aslive + parkednever exceeds it.stats()itself is allocation-free -- one frozen live-view object per list, its getters reading the current slot/pool lengths and a closure counter, so a monitor can poll it in a hot loop without adding a byte of garbage. - The keyed
byKeyMap mutates (set/delete) on actual key changes -- a JS-heap allocation the pool counters do not see. A reorder touches no key, so it does not churn the Map; inserts/removes touch one key each. - Your
mapFnbody. If it allocates per re-run, that allocation is yours.
A note on createScope: it cascades effects and computeds but not signals (the
engine never owner-adopts signals). lite-map disposes the item/index signals it
creates. A bare signal you create inside mapFn is not auto-disposed on scope
teardown -- prefer effects/computeds (which are cascaded), or dispose it yourself.
API
indexArray(list, (item: () => T, index: number) => O, opts?: { maxPool?: number }): Mapped<O>
mapArray(list, (item: () => T, index: () => number) => O, opts?: { key?: (item: T) => unknown; maxPool?: number }): Mapped<O>
mapArray(list, (item: T, index: () => number) => O, opts: { byValue: true }): Mapped<O> // [1.3] plain-item opt-in
createMapper(registry): { mapArray, indexArray } // bind to a non-default registry
// Mapped<O>:
mapped(): O[] // outputs in source order (one persistent array)
mapped.dispose(): void // stop the driver, dispose every live + parked scope
mapped.stats(): MappedStats // { live, parked, highWater } -- a reused frozen live viewlist is an accessor (() => T[]); a lite-signal handle is callable and satisfies it.
Mapped<O> is (() => O[]) & { dispose(): void; stats(): MappedStats }.
Pool observability (1.2)
mapped.stats() reports the pool for a list -- for a monitor, for tuning maxPool
by measurement, or for a bench that reads a scoped counter instead of the whole
engine ledger. It works on both primitives.
const rows = mapArray(todos, rowFn, { key: (t) => t.id });
const s = rows.stats();
s.live; // slots currently in the list
s.parked; // scopes on the free-list, ready to be reused
s.highWater; // all-time peak of (live + parked) on this list| Field | Meaning |
| --- | --- |
| live | current slot count (= mapped().length) |
| parked | free-list length (retired scopes kept for reuse) |
| highWater | all-time peak of live + parked; historical, survives dispose() |
stats() returns one frozen object per list, reused on every call -- zero
allocation per read. It is a LIVE VIEW: the three fields change between reads
without calling stats() again, so destructure (const { live } = rows.stats())
if you need a point-in-time snapshot. After dispose() it pins
{ live: 0, parked: 0, highWater: <peak> } and never throws.
maxPool: 0is falsy and is treated as unset (unbounded) today -- pass>= 1to bound the pool. Documented, not changed.
by-value mapArray (1.3)
The default mapArray passes item as an accessor -- that is what lets a retired
scope be reused for a new item (set its signals, no rebuild). Opt into
plain-item ergonomics (Solid-style) with { byValue: true }:
const rows = mapArray(todos, (todo, index) => renderRow(todo, index()), { byValue: true });
// ^^^^ PLAIN value ^^^^^^^ index is still an accessorA by-value view bakes the item into mapFn's closure, so there is no signal to
redirect it. The whole cost model follows from that one fact, and it is stated at
the call site:
| by-value operation | cost |
| --- | --- |
| move / reorder (same item, new index) | pool-flat -- rides idxSig.set, mapFn does not re-run |
| insert (a new item) | re-runs mapFn exactly once and pulls a fixed node count k from the pool |
| remove | disposes immediately -- there is no free-list, so stats().parked is always 0 |
Gated (T6 phase 4): a warm 500-row reorder window shows poolGrowths /
totalAllocations deltas 0 and zero mapFn re-runs; each insert allocates
exactly k engine nodes (k = 3 for a one-index-effect mapFn: the scope,
the index signal, the index effect), calibrated once and asserted == on every
insert, whether or not a scope was ever retired.
- Keys are by reference identity (SameValueZero):
-0and0are the same key (not an item change); the same reference twice is a contained duplicate that never evicts the owner's scope. - The door fails closed (ASCII, did-you-mean):
{ byValue: true }cannot be combined withkey(a by-value view cannot absorb an item change under a custom key) ormaxPool(a cap on a free-list that is never used is a silent no-op); a truthy non-truebyValuethrows too. The accessor mode (byValueabsent orfalse) is byte-for-byte unchanged.
Not in 1.0 (deferred)
- LIS minimal-move ordering -- documented as met (see
decisions/0002-lis-ordering.md). Reorders are already index-signal-minimal:idxSig.setfires only for a survivor whose index actually changed, so index updates equal the genuinely-moved rows (movedon a 1000-row rotate = 1000, an adjacent swap = 2; gated in the torture T6 tier). The classicn - LISfloor is a MOVE-operation count for an insertion-ordered container; the output arraymapped()is positionally indexed, so its store floor isn - redundant(redundant == n - moved), a different quantity that an LIS pass does not reach (bench/lis-probe.mjs:redundant/n >= 0.50on only 1 of 5 shapes). An unconditional LIS guard in the reconcile hot path is therefore not justified; output-move minimization is a consumer concern. Numbers and the full rationale:decisions/0002-lis-ordering.md.
Tail fast-paths (1.1)
mapArray now classifies the dominant real-world mutations -- append, pop,
and in-place value churn (feeds, logs, push/pop) -- with a position-aligned
common-prefix scan by key, before the general keyed diff. When the whole prefix
is intact, the general path (its per-item byKey.get, scratch swap and full retire
scan) is skipped: the only structural work is O(delta) at the tail, and the byKey
Map and the scope pool never churn.
// pure append: every existing row keeps its scope; only the new tail row mounts.
todos.set([...todos(), { id: 99, text: "new" }]);
// pure pop: the tail row's scope is parked (reused by the next push); survivors
// are untouched -- their index accessors do not fire.
todos.set(todos().slice(0, -1));
// same order, changed value: refreshed in place through itemSig; the structural
// output signal stays silent (only real membership/order changes fire it).
todos.set(todos().map(t => t.id === 2 ? { ...t, text: "walk dog now" } : t));Detection is a pure key scan with no side effects, so any non-tail shape
(prepend, reorder, middle insert/remove) falls cleanly through to the correct
general path -- these fast-paths are strictly a performance layer over identical
semantics. Gate: 10,000 push/pop cycles on a warm list are zero-GC
(poolGrowths / totalAllocations flat), and an append to an N-row list preserves
all N survivor scopes. The prefix scan itself is O(min(n, prevN)) comparisons; the
allocation, Map and scope-move cost is what stays flat versus list length.
License
MIT (c) 2026 Zahary Shinikchiev <[email protected]>
