@barefootjs/client 0.26.4 → 0.28.0

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.
Files changed (44) hide show
  1. package/dist/reactive.d.ts.map +1 -1
  2. package/dist/runtime/claim-slots.d.ts +222 -0
  3. package/dist/runtime/claim-slots.d.ts.map +1 -0
  4. package/dist/runtime/component.d.ts +40 -18
  5. package/dist/runtime/component.d.ts.map +1 -1
  6. package/dist/runtime/dynamic-text.d.ts +24 -1
  7. package/dist/runtime/dynamic-text.d.ts.map +1 -1
  8. package/dist/runtime/index.d.ts +4 -5
  9. package/dist/runtime/index.d.ts.map +1 -1
  10. package/dist/runtime/index.js +467 -259
  11. package/dist/runtime/loop-markers.d.ts +26 -0
  12. package/dist/runtime/loop-markers.d.ts.map +1 -0
  13. package/dist/runtime/map-array-lazy.d.ts +164 -0
  14. package/dist/runtime/map-array-lazy.d.ts.map +1 -0
  15. package/dist/runtime/map-array.d.ts +35 -0
  16. package/dist/runtime/map-array.d.ts.map +1 -1
  17. package/dist/runtime/qsa-item.d.ts +7 -0
  18. package/dist/runtime/qsa-item.d.ts.map +1 -1
  19. package/dist/runtime/registry.d.ts.map +1 -1
  20. package/dist/runtime/standalone.js +455 -248
  21. package/package.json +2 -2
  22. package/src/reactive.ts +2 -1
  23. package/src/runtime/claim-slots.ts +647 -0
  24. package/src/runtime/component.ts +153 -70
  25. package/src/runtime/dynamic-text.ts +24 -1
  26. package/src/runtime/index.ts +20 -7
  27. package/src/runtime/insert.ts +1 -1
  28. package/src/runtime/loop-markers.ts +100 -0
  29. package/src/runtime/map-array-lazy.ts +470 -0
  30. package/src/runtime/map-array.ts +68 -11
  31. package/src/runtime/qsa-item.ts +9 -3
  32. package/src/runtime/registry.ts +5 -3
  33. package/dist/runtime/client-marker.d.ts +0 -21
  34. package/dist/runtime/client-marker.d.ts.map +0 -1
  35. package/dist/runtime/list.d.ts +0 -21
  36. package/dist/runtime/list.d.ts.map +0 -1
  37. package/dist/runtime/patch-slot-range.d.ts +0 -47
  38. package/dist/runtime/patch-slot-range.d.ts.map +0 -1
  39. package/dist/runtime/reconcile-elements.d.ts +0 -44
  40. package/dist/runtime/reconcile-elements.d.ts.map +0 -1
  41. package/src/runtime/client-marker.ts +0 -46
  42. package/src/runtime/list.ts +0 -47
  43. package/src/runtime/patch-slot-range.ts +0 -105
  44. package/src/runtime/reconcile-elements.ts +0 -391
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/client",
3
- "version": "0.26.4",
3
+ "version": "0.28.0",
4
4
  "description": "BarefootJS client package: reactive primitives (SSR-safe) plus browser runtime under the `/runtime` subpath (compiler target)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -55,7 +55,7 @@
55
55
  "directory": "packages/client"
56
56
  },
57
57
  "dependencies": {
58
- "@barefootjs/shared": "0.26.4"
58
+ "@barefootjs/shared": "0.28.0"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@barefootjs/jsx": ">=0.2.0"
package/src/reactive.ts CHANGED
@@ -319,7 +319,8 @@ export function createSignal<T>(initialValue: T, __bfId?: string): Signal<T> {
319
319
  export function createEffect(fn: EffectFn, __bfId?: string, __bfKind: SubscriberKind = 'effect'): void {
320
320
  // Note: Nested effects are now allowed. runEffect() properly saves/restores
321
321
  // prevEffect, so nested effects correctly track their own dependencies.
322
- // This enables synchronous component initialization in reconcileList.
322
+ // This enables synchronous component initialization inside loop reconcilers
323
+ // (mapArray/mapArrayAnchored).
323
324
 
324
325
  const effect: EffectContext = {
325
326
  fn,
@@ -0,0 +1,647 @@
1
+ /**
2
+ * Claim-plan interpreter and claimed-slot primitives (slot unification
3
+ * Steps A2/A3, `spec/slot-unification.md` §4/§5).
4
+ *
5
+ * This module is the ONE claim mechanism the spec's §4 target architecture
6
+ * describes: a compile-time `ClaimPlan` (per-slot child-index paths from a
7
+ * claim root down to the slot's anchor comment) is resolved ONCE — either
8
+ * eagerly (`claimSlots`) or lazily on first write (`lazySlots`) — and every
9
+ * later write goes through the held reference, never re-scanning the DOM.
10
+ * As of A3 the compiler emits claim plans for every content slot; the
11
+ * `patchSlotRange` and `updateClientMarker` mechanisms it superseded are
12
+ * deleted. `$t`/text-effect writes and `__bfText` are ALSO superseded for
13
+ * every emission site but one — see `dynamic-text.ts`'s docstring for the
14
+ * one deliberately-deferred case (`emitDynamicTextUpdates`'s
15
+ * `conditionalElems` path).
16
+ *
17
+ * Anchors are still the existing `<!--bf:sN-->…<!--/-->` marker pairs — SSR
18
+ * bytes are unchanged in Step A (§5, §6) so the new client claims against
19
+ * known-good SSR output. A path is only required to be valid AT THE MOMENT
20
+ * OF CLAIM (§2): once a 'text' Text node or a 'markup' boundary pair is
21
+ * held, subsequent writes never consult the path or the marker again, so a
22
+ * sibling slot's later variable-length change cannot invalidate anything
23
+ * already claimed.
24
+ *
25
+ * Kind contracts (mirroring the mechanisms being superseded — this is the
26
+ * "one slot concept with an identity contract" of §4):
27
+ * - 'text': held ref is the Text node immediately after the anchor
28
+ * comment. The CLAIM is non-mutating: it adopts that node when SSR
29
+ * rendered the slot non-empty and otherwise holds the ANCHOR COMMENT
30
+ * itself as a stand-in for the not-yet-created node, deferring
31
+ * `document.createTextNode` to the first write that actually needs it
32
+ * (`materializeText` reads the insertion point off that comment). That
33
+ * one-field representation is deliberate — see `ClaimedTextSlot`. What
34
+ * lets
35
+ * `read` exist — a seed that only compares must be able to inspect a
36
+ * slot without leaving an empty Text node behind on every row
37
+ * (§9.3(1)). Writes are a `nodeValue` assignment, and once materialized
38
+ * the node's identity never changes — the guarantee effect closures and
39
+ * `mapArray`'s same-key path rely on.
40
+ * - 'markup': held ref is BOTH boundary comments (start = anchor, end =
41
+ * the matching `<!--/-->` found by a nesting-depth walk (any further
42
+ * `bf:`-prefixed comment along the way opens a nested region). A string
43
+ * write clears everything strictly between the boundaries and inserts
44
+ * freshly `<template>`-parsed HTML before the end comment; a `Node`
45
+ * write clears the range and splices the node in by identity (the
46
+ * `__bfText` live-Node case). The boundaries themselves are never
47
+ * removed, so the range stays writeable on every later write. Because
48
+ * the end ref is already held, a write never needs to re-walk for
49
+ * nesting depth — only the CLAIM does.
50
+ *
51
+ * Warn-don't-guess (§4, "one ownership rule"): a path that fails to resolve
52
+ * to its slot's own `bf:sN` comment — out of range, or shape drift where
53
+ * the path now lands on some other node — falls back to a marker scan
54
+ * within the claim root, using the same ownership rule as `query.ts`'s `$t`:
55
+ * a `bf:sN` comment owned by a nested `bf-s` scope (a child component's own
56
+ * same-numbered slot — ids are
57
+ * assigned per component, so collisions are expected) is never a candidate
58
+ * — UNLESS the id is `^`-prefixed (`BF_PARENT_OWNED_PREFIX`), meaning the
59
+ * marker is content the claiming component itself authored and merely
60
+ * forwarded through one or more descendants' `children` (see
61
+ * `findOwnedMarker`'s docstring); for those the ownership walk is skipped
62
+ * outright, matching `query.ts`'s `$()`. If neither the path nor the
63
+ * fallback scan finds an owned marker, that one slot is dropped with a
64
+ * `console.warn` and the rest of the plan still claims — never guess a
65
+ * boundary, and never let one bad slot sink its siblings. An EMPTY path
66
+ * (`[]`) skips straight to the scan without that first warning — the
67
+ * compiler emits `path: []` deliberately when a slot's position can't be
68
+ * statically pathed (slot unification A3), so a miss there is expected, not
69
+ * drift; only a non-empty path that fails to resolve signals a real shape
70
+ * mismatch worth warning about.
71
+ *
72
+ * Row-pristine lazy claim (§3(a)): `lazySlots` touches NOTHING until the
73
+ * first write, and that first write claims the WHOLE plan at once (not just
74
+ * the slot being written) — so no earlier write into one slot can shift a
75
+ * sibling slot's still-unclaimed path out from under it. A row that never
76
+ * updates never pays for a claim at all. `claimSlots` is the eager escape
77
+ * hatch for callers that cannot honor that invariant (streaming/portal
78
+ * paths that mutate row content before any write would occur, per §6's
79
+ * risk note) — it claims every slot in the plan immediately.
80
+ *
81
+ * Dedup, no trust-first-run (slot unification A3 follow-up): a 'markup'
82
+ * slot's write door holds a `last` value alongside its boundary refs. Every
83
+ * write — INCLUDING THE FIRST — clears-and-inserts unless the new string
84
+ * equals `last`, in which case the DOM touch is skipped. `last` starts
85
+ * `undefined`, which never equals a `String(...)`-coerced value, so the
86
+ * first write can never dedup away; it always patches. A `Node` write is
87
+ * deduped by identity (mirrors `__bfText`'s `value === current` check) and,
88
+ * like the string case, is never skipped on the first write — a freshly
89
+ * `createComponent`-built element is a distinct object from whatever the
90
+ * SSR markup rendered, so it always splices. 'text' writes stay a plain
91
+ * `nodeValue` assignment (already idempotent, per §5's design note) — no
92
+ * dedup state needed.
93
+ *
94
+ * The first write is never skipped on the assumption that the claimed
95
+ * range already matches SSR/CSR content ("trust-first-run") — that
96
+ * assumption only holds for a preamble-region row whose SSR content and
97
+ * the effect's mount-time recomputation are both derived from the exact
98
+ * same source data, so they cannot disagree. It is false in general: any
99
+ * markup slot whose value comes from client-only state that the server
100
+ * cannot see — `createSignal(readFromLocalStorage())`, a client-side region
101
+ * swap adopting HTML the server rendered from a different default — can
102
+ * genuinely differ from the SSR/CSR content on the very first write, and
103
+ * skipping that first write would silently leave the stale SSR default on
104
+ * screen until the NEXT change (regression pin: site/ui's
105
+ * `admin-gallery.spec.ts` cross-page time-range persistence test). So every
106
+ * write unconditionally applies unless deduped by value/identity — never
107
+ * because it happens to be the first one — for every 'markup' caller,
108
+ * including the preamble-region case (which only loses a same-value
109
+ * redundant-patch skip on mount, not correctness).
110
+ */
111
+
112
+ import { BF_SCOPE, BF_PARENT_OWNED_PREFIX } from '@barefootjs/shared'
113
+ import { commentsInScope } from './query.ts'
114
+ import { commentScopeRegistry } from './scope.ts'
115
+
116
+ /**
117
+ * A slot's compile-time descriptor. `path` is the list of child indices
118
+ * from the claim root to the slot's ANCHOR NODE — in Step A that anchor is
119
+ * always the existing `<!--bf:sN-->` start comment (markers are still
120
+ * emitted; Step B may point paths at other node kinds), so resolution
121
+ * walks `childNodes` by index with no assumption about the target's
122
+ * `nodeType` until the kind-specific claim inspects it. `id` is kept for
123
+ * diagnostics and as the marker-scan fallback's search key.
124
+ */
125
+ export interface SlotSpec {
126
+ id: string
127
+ kind: 'text' | 'markup'
128
+ path: readonly number[]
129
+ /**
130
+ * Slot unification Step B (`spec/slot-unification.md` §3(b), §5 Step B):
131
+ * true when NO `<!--bf:id-->…<!--/-->` marker was emitted for this slot at
132
+ * all — `path` is then a path to the slot's POSITION itself (the LAST
133
+ * index is this slot's own index within its parent's `childNodes`, not an
134
+ * anchor comment to search from). Only ever set for `kind: 'text'` — a
135
+ * `'markup'` slot always keeps its markers (an empty-able range needs a
136
+ * physical anchor to splice into; see `spec/slot-unification.md` §3(b)
137
+ * case (ii)). Resolution CREATES a Text node at that position if SSR
138
+ * rendered the slot empty (nothing to adopt there yet) — see `claimOne`.
139
+ * The compiler emits this only when it has already proven the position
140
+ * safe (`client-only-elision.ts`); the runtime never re-derives it.
141
+ */
142
+ markerless?: boolean
143
+ }
144
+
145
+ export type ClaimPlan = readonly SlotSpec[]
146
+
147
+ /**
148
+ * A claimed 'text' slot, represented by ONE field. `ref` is the live Text
149
+ * node once the slot is materialized, and until then — only possible for a
150
+ * MARKED slot SSR rendered empty — the slot's anchor Comment, which doubles
151
+ * as the record of where the Text node must be created. `nodeType`
152
+ * discriminates the two.
153
+ *
154
+ * Claiming a marked text slot therefore never mutates the DOM, which is
155
+ * what the read door depends on: a seed that only compares must not leave a
156
+ * trail of empty Text nodes across every row (`spec/slot-unification.md`
157
+ * §9.3(1)). Markerless slots keep the original eager creation and so always
158
+ * arrive here already materialized — see `claimMarkerlessText` for why
159
+ * deferring them would cost more than it saves.
160
+ *
161
+ * One field rather than a node-plus-site pair because this object is
162
+ * allocated once per slot per row: on a 1k-row list every extra field is
163
+ * paid a thousand times over (measured: +77KB/1k rows for a
164
+ * node+after+parent+index shape). `materializeText` re-reads the insertion
165
+ * point off the anchor at creation time rather than capturing it at claim
166
+ * time, so a sibling slot's write in between cannot stale it.
167
+ *
168
+ * Once materialized the node is held by identity forever, exactly as
169
+ * before: effect closures and `mapArray`'s same-key path rely on that.
170
+ */
171
+ interface ClaimedTextSlot {
172
+ readonly kind: 'text'
173
+ /** Text node once materialized; the anchor Comment until then. */
174
+ ref: Text | Comment
175
+ }
176
+
177
+ /**
178
+ * A claimed 'markup' slot: both boundary comments, held by identity.
179
+ * Content lives strictly between `start` and `end`; both survive every
180
+ * write. `last` is the trust-first-run + dedup state (see module docstring)
181
+ * — `undefined` until the first write, a `string` once a string has been
182
+ * recorded/patched, or the live `Node` once one has been spliced in.
183
+ */
184
+ interface ClaimedMarkupSlot {
185
+ readonly kind: 'markup'
186
+ readonly start: Comment
187
+ readonly end: Comment
188
+ last: string | Node | undefined
189
+ }
190
+
191
+ type ClaimedSlotRef = ClaimedTextSlot | ClaimedMarkupSlot
192
+
193
+ /**
194
+ * The result of claiming a plan: a write door keyed by slot id. Writing an
195
+ * id that failed to claim (or was never in the plan) warns and no-ops —
196
+ * one bad/missing slot never breaks any other slot's writes.
197
+ */
198
+ export interface ClaimedSlots {
199
+ write(id: string, value: unknown): void
200
+ }
201
+
202
+ /**
203
+ * A claimed plan that can be read as well as written. Separate from
204
+ * {@link ClaimedSlots} because a door is allocated PER ROW: giving every
205
+ * claim a reader costs an extra closure on every row of a list, read or not
206
+ * (measured: ~40KB/1k rows). Only the loops that need read-compare-write
207
+ * seeding (`spec/slot-unification.md` §9.3(1)) ask for this shape. Both
208
+ * shapes sit on the SAME claim (`claimRefs`) — this is a second accessor
209
+ * bundle, never a second way to resolve a position (§2's claim-once rule).
210
+ */
211
+ export interface ClaimedSlotsRW extends ClaimedSlots {
212
+ /**
213
+ * Current DOM text of a 'text' slot. `''` when the slot rendered empty,
214
+ * `null` when the slot cannot answer (not a 'text' slot, or it failed to
215
+ * claim); `null` MUST be treated by the caller as "differs, write it".
216
+ */
217
+ read(id: string): string | null
218
+ }
219
+
220
+ /** `lazySlots`'s per-write function — the same shape `ClaimedSlots.write` has. */
221
+ export type SlotWriter = (id: string, value: unknown) => void
222
+
223
+ // --- path resolution ---
224
+
225
+ /** Walk `childNodes` by index from `root`. No node-kind assumption — the
226
+ * caller checks whether the result is actually the expected comment. */
227
+ function resolvePath(root: Node, path: readonly number[]): Node | null {
228
+ let node: Node = root
229
+ for (const index of path) {
230
+ const child: Node | undefined = node.childNodes[index]
231
+ if (!child) return null
232
+ node = child
233
+ }
234
+ return node
235
+ }
236
+
237
+ function isSlotComment(node: Node | null, id: string): node is Comment {
238
+ return node != null && node.nodeType === Node.COMMENT_NODE && (node as Comment).nodeValue === `bf:${id}`
239
+ }
240
+
241
+ /**
242
+ * Fallback marker scan, used only when a slot's compile-time path fails to
243
+ * resolve to its own `bf:sN` comment (shape drift, or a plan built against
244
+ * a differently-shaped claim root). The ownership rule: a same-id marker
245
+ * owned by a nested `bf-s` scope (a child component's own slot — ids
246
+ * collide across components by design) is skipped so the fallback can
247
+ * never claim into a child's content.
248
+ *
249
+ * `commentsInScope` (not a bare `document.createTreeWalker(root, …)`) so a
250
+ * whole-item loop conditional's claim root (`insert.ts`'s detached
251
+ * `commentScopeRegistry` proxy for a `<!--bf-loop-i:key-->` anchor, #1665)
252
+ * resolves correctly: the proxy has no DOM children of its own — the row's
253
+ * real content lives as SIBLINGS of the registered comment — and
254
+ * `commentsInScope` already knows to walk that sibling range instead of
255
+ * `root`'s (empty) descendants. The ownership boundary adapts to match:
256
+ * every node in a comment-scope's range shares the registered comment's
257
+ * OWN parent element, so that (not the unreachable proxy `root`) is where
258
+ * the ancestor walk must stop.
259
+ *
260
+ * Parent-owned slots (`^`-prefixed id, `BF_PARENT_OWNED_PREFIX`) skip the
261
+ * ownership walk entirely — same carve-out as `query.ts`'s `$()` and its
262
+ * `findText` marker map. A `^sN` id is JSX children the CLAIMING component
263
+ * itself authored (e.g. `<Button><span>{displayText()}</span></Button>`)
264
+ * that only physically lands inside descendant components' DOM because it
265
+ * was forwarded through their `children` prop — every one of those
266
+ * descendants (Button, its own children, …) legitimately carries its own
267
+ * `bf-s` scope attribute, but that scope boundary says nothing about who
268
+ * authored THIS content. Without the carve-out, any slot forwarded more
269
+ * than zero levels deep is unfindable — every ordinary ancestor bf-s
270
+ * attribute trips the "nested scope" rejection meant for a same-numbered
271
+ * marker some unrelated component happens to render for itself.
272
+ */
273
+ function findOwnedMarker(root: Element, id: string): Comment | null {
274
+ const marker = `bf:${id}`
275
+ const parentOwned = id.startsWith(BF_PARENT_OWNED_PREFIX)
276
+ const registryInfo = commentScopeRegistry.get(root)
277
+ const boundary = registryInfo ? registryInfo.commentNode.parentElement : root
278
+ for (const comment of commentsInScope(root)) {
279
+ if (comment.nodeValue !== marker) continue
280
+ if (parentOwned) return comment
281
+ let owned = true
282
+ for (let el = comment.parentElement; el && el !== boundary; el = el.parentElement) {
283
+ if (el.hasAttribute(BF_SCOPE)) {
284
+ owned = false
285
+ break
286
+ }
287
+ }
288
+ if (owned) return comment
289
+ }
290
+ return null
291
+ }
292
+
293
+ /**
294
+ * Find the matching `<!--/-->` end comment for a 'markup' slot's start
295
+ * comment: any further `bf:`-prefixed comment along the way opens a nested
296
+ * region (a leaf rendered inside this one can carry its own ordinary slot
297
+ * markers) and increments a depth counter so that region's own `/` doesn't
298
+ * prematurely close this outer range. Runs once, at claim time — writes
299
+ * never need this since the end ref is held afterward.
300
+ */
301
+ function findMarkupEnd(start: Comment): Comment | null {
302
+ let depth = 0
303
+ let node: Node | null = start.nextSibling
304
+ while (node) {
305
+ if (node.nodeType === Node.COMMENT_NODE) {
306
+ const value = (node as Comment).nodeValue ?? ''
307
+ if (value.startsWith('bf:')) {
308
+ depth++
309
+ } else if (value === '/') {
310
+ if (depth === 0) return node as Comment
311
+ depth--
312
+ }
313
+ }
314
+ node = node.nextSibling
315
+ }
316
+ return null
317
+ }
318
+
319
+ /**
320
+ * Resolve one slot's anchor comment: try the compile-time path first, fall
321
+ * back to an owned marker scan on any miss (path resolves to nothing, or to
322
+ * a node that isn't this slot's own comment — shape drift), and warn on
323
+ * either the fallback-needed or the total-miss case — EXCEPT when the plan
324
+ * shipped an empty path (`spec.path.length === 0`, slot unification A3's
325
+ * "cannot be statically pathed" case, `spec/slot-unification.md` §5-A3):
326
+ * an empty path is a deliberate "no compile-time path available" marker,
327
+ * not a claim that index `0` addresses this slot, so going straight to the
328
+ * scan is the plan's INTENDED behavior, not a drift to warn about. Never
329
+ * throws — a bad slot returns `null` and the caller drops it from the
330
+ * claimed set.
331
+ */
332
+ function resolveAnchor(root: Element, spec: SlotSpec): Comment | null {
333
+ if (spec.path.length > 0) {
334
+ const resolved = resolvePath(root, spec.path)
335
+ if (isSlotComment(resolved, spec.id)) return resolved
336
+ console.warn(
337
+ `[barefootjs] claim path for slot ${spec.id} did not resolve to its bf:${spec.id} marker; falling back to a scan`,
338
+ )
339
+ }
340
+
341
+ const found = findOwnedMarker(root, spec.id)
342
+ if (!found) {
343
+ console.warn(`[barefootjs] slot ${spec.id} marker not found; skipping`)
344
+ }
345
+ return found
346
+ }
347
+
348
+ /**
349
+ * Resolve a `markerless` 'text' slot (slot unification Step B): `path`'s
350
+ * LAST index is the slot's own position within its parent's `childNodes` —
351
+ * there is no anchor comment to walk from or scan for, since the compiler
352
+ * only ever sets `markerless` when it has already proven no marker is
353
+ * needed (`client-only-elision.ts`). If SSR/CSR rendered the slot non-empty,
354
+ * a Text node already sits at that position — adopt it. If SSR rendered it
355
+ * empty (the only case Step B currently elides — `/* @client *\/`
356
+ * expressions, always empty at claim time), nothing sits there yet — create
357
+ * one and insert it before whatever currently occupies that index (or at
358
+ * the end, if the index is past the end of `childNodes`). Never falls back
359
+ * to a marker scan — there is no marker to find — so a path miss here is a
360
+ * genuine, loud failure, not the "cannot be statically pathed" case
361
+ * `resolveAnchor`'s empty-path allowance covers.
362
+ */
363
+ function claimMarkerlessText(root: Element, spec: SlotSpec): ClaimedTextSlot | null {
364
+ if (spec.path.length === 0) {
365
+ console.warn(`[barefootjs] markerless slot ${spec.id} has an empty path; skipping`)
366
+ return null
367
+ }
368
+ const parentPath = spec.path.slice(0, -1)
369
+ const idx = spec.path[spec.path.length - 1]
370
+ const parent = resolvePath(root, parentPath)
371
+ if (!parent) {
372
+ console.warn(`[barefootjs] markerless claim path for slot ${spec.id} did not resolve to a parent node; skipping`)
373
+ return null
374
+ }
375
+ const existing = parent.childNodes[idx] as Node | undefined
376
+ if (existing && existing.nodeType === Node.TEXT_NODE) {
377
+ return { kind: 'text', ref: existing as Text }
378
+ }
379
+ // Markerless slots keep the original eager creation. They exist only for
380
+ // Step B's `/* @client */` elision, which never applies inside a loop, so
381
+ // no markerless slot is ever a lazy-row seed target — deferring here would
382
+ // buy nothing and would need the parent+index pair the shape above avoids.
383
+ const node = document.createTextNode('')
384
+ parent.insertBefore(node, existing ?? null)
385
+ return { kind: 'text', ref: node }
386
+ }
387
+
388
+ /** Claim one slot per its kind's contract. `null` on any failure (already warned). */
389
+ function claimOne(root: Element, spec: SlotSpec): ClaimedSlotRef | null {
390
+ if (spec.kind === 'text' && spec.markerless) {
391
+ return claimMarkerlessText(root, spec)
392
+ }
393
+ const anchor = resolveAnchor(root, spec)
394
+ if (!anchor) return null
395
+
396
+ if (spec.kind === 'text') {
397
+ const next = anchor.nextSibling
398
+ return { kind: 'text', ref: next?.nodeType === Node.TEXT_NODE ? (next as Text) : anchor }
399
+ }
400
+
401
+ const end = findMarkupEnd(anchor)
402
+ if (!end) {
403
+ console.warn(`[barefootjs] slot ${spec.id} has no end marker; skipping`)
404
+ return null
405
+ }
406
+ return { kind: 'markup', start: anchor, end, last: undefined }
407
+ }
408
+
409
+ // --- writes ---
410
+
411
+ /**
412
+ * Create the Text node a claim deliberately did not create, at the position
413
+ * the claim recorded. Reached only from a write against a slot SSR rendered
414
+ * empty — the one case where the DOM genuinely has nothing to write into.
415
+ */
416
+ function materializeText(slot: ClaimedTextSlot): Text | null {
417
+ const anchor = slot.ref as Comment
418
+ const parent = anchor.parentNode
419
+ if (!parent) return null
420
+ const node = document.createTextNode('')
421
+ parent.insertBefore(node, anchor.nextSibling)
422
+ slot.ref = node
423
+ return node
424
+ }
425
+
426
+ /** Current DOM text of a claimed 'text' slot; `''` while unmaterialized. */
427
+ function readText(slot: ClaimedTextSlot): string {
428
+ return slot.ref.nodeType === Node.TEXT_NODE ? ((slot.ref as Text).nodeValue ?? '') : ''
429
+ }
430
+
431
+ function writeText(slot: ClaimedTextSlot, value: unknown): void {
432
+ const node = slot.ref.nodeType === Node.TEXT_NODE ? (slot.ref as Text) : materializeText(slot)
433
+ if (!node) return
434
+ node.nodeValue = String(value ?? '')
435
+ }
436
+
437
+ /**
438
+ * Pass a live Node through untouched; coerce anything else with `String`.
439
+ *
440
+ * The 'text' door's counterpart to {@link escapeTextOrNode}. A 'text' slot
441
+ * writes through `nodeValue`, which needs no escaping — routing it through
442
+ * `escapeText` would double-escape — but it DOES need the Node case
443
+ * separated out, because a Text node cannot host an element and
444
+ * `String(node)` destroys it: `[object HTMLDivElement]` in a browser, the
445
+ * serialized markup rendered as visible text under some DOM shims. Wrong
446
+ * either way, and silently so, which is why the split lives here rather
447
+ * than at each call site.
448
+ *
449
+ * A Node reaches a content slot whenever a child-position interpolation
450
+ * calls something that builds one — `props.renderRow(item)` handed an
451
+ * inline-JSX arrow, which the compiler lifts into a component whose call
452
+ * returns a real element. Whether such a call returns a string or a Node is
453
+ * not decidable from the expression's syntax (both are `CallExpression`), so
454
+ * the decision belongs at runtime, on the value.
455
+ *
456
+ * `String(value)`, not `String(value ?? '')`: a non-Node value must coerce
457
+ * exactly as the previous inline `String(...)` emission did. The nullish
458
+ * collapse stays where it already was, in {@link writeText}.
459
+ */
460
+ export function textOrNode(value: unknown): string | Node {
461
+ if (typeof Node !== 'undefined' && value instanceof Node) return value
462
+ return String(value)
463
+ }
464
+
465
+ /**
466
+ * A Node landed on a slot claimed as 'text'. Promote the claim to the
467
+ * 'markup' contract in place — the anchor comment becomes `start`, its
468
+ * matching `<!--/-->` becomes `end` — so this and every later write on the
469
+ * id goes through {@link writeMarkup}, which already splices Nodes by
470
+ * identity.
471
+ *
472
+ * This is a promotion, not a re-claim: the anchor is the SAME comment the
473
+ * original claim resolved (§2's claim-once rule holds — no second position
474
+ * resolution). The Text node the claim adopted or created, if any, sits
475
+ * inside the new range and `clearMarkupRange` removes it on the write.
476
+ *
477
+ * `null` when the slot cannot host a Node — a markerless slot (Step B
478
+ * elision: no anchor to promote from) or a marked slot whose end comment is
479
+ * missing. Both warn: refusing loudly beats stringifying an element into
480
+ * visible `[object HTMLDivElement]`.
481
+ */
482
+ function promoteTextToMarkup(slot: ClaimedTextSlot, id: string): ClaimedMarkupSlot | null {
483
+ const anchor = slot.ref.nodeType === Node.COMMENT_NODE
484
+ ? (slot.ref as Comment)
485
+ // Materialized: `claimOne` adopts/creates the Text node immediately after
486
+ // the anchor, so the anchor is its previous sibling — unless the slot is
487
+ // markerless, where there is no anchor at all and `isSlotComment` says so.
488
+ : isSlotComment(slot.ref.previousSibling, id) ? slot.ref.previousSibling : null
489
+ if (!anchor) {
490
+ console.warn(
491
+ `[barefootjs] slot ${id} was claimed as text and received a Node, but has no anchor marker to promote from; write ignored`,
492
+ )
493
+ return null
494
+ }
495
+ const end = findMarkupEnd(anchor)
496
+ if (!end) {
497
+ console.warn(
498
+ `[barefootjs] slot ${id} was claimed as text and received a Node, but has no end marker; write ignored`,
499
+ )
500
+ return null
501
+ }
502
+ return { kind: 'markup', start: anchor, end, last: undefined }
503
+ }
504
+
505
+ /** Remove every node strictly between `start` and `end` (both survive). */
506
+ function clearMarkupRange(start: Comment, end: Comment): void {
507
+ const parent = end.parentNode
508
+ if (!parent) return
509
+ let node: Node | null = start.nextSibling
510
+ while (node && node !== end) {
511
+ const next = node.nextSibling
512
+ parent.removeChild(node)
513
+ node = next
514
+ }
515
+ }
516
+
517
+ function writeMarkup(ref: ClaimedMarkupSlot, value: unknown): void {
518
+ const { start, end } = ref
519
+ const parent = end.parentNode
520
+ if (!parent) return
521
+
522
+ // Slot markers (`__slot()`, `@barefootjs/client/slot.ts`): a caller-passed
523
+ // JSX prop that itself contains a component. Leave the server-rendered DOM
524
+ // untouched entirely — no write, no `last` update either, so a later real
525
+ // value still gets a correct dedup read. Mirrors `__bfText`'s identical
526
+ // guard (#1663).
527
+ if (value != null && (value as { __isSlot?: boolean }).__isSlot) return
528
+
529
+ if (typeof Node !== 'undefined' && value instanceof Node) {
530
+ // Identity dedup, mirrors `__bfText`'s `value === current` check — the
531
+ // same live node handed back again is a no-op. `ref.last` starts
532
+ // `undefined`, which no real Node is ever `===` to, so the first Node
533
+ // write always splices — a freshly rendered Node is never the
534
+ // SSR-rendered markup by identity.
535
+ if (value === ref.last) return
536
+ clearMarkupRange(start, end)
537
+ parent.insertBefore(value, end)
538
+ ref.last = value
539
+ return
540
+ }
541
+
542
+ const text = String(value ?? '')
543
+ if (text === ref.last) return // dedup: identical string, skip the DOM touch
544
+ clearMarkupRange(start, end)
545
+ const tpl = document.createElement('template')
546
+ tpl.innerHTML = text
547
+ parent.insertBefore(tpl.content, end)
548
+ ref.last = text
549
+ }
550
+
551
+ function writeSlot(refs: Map<string, ClaimedSlotRef>, id: string, value: unknown): void {
552
+ const ref = refs.get(id)
553
+ if (!ref) {
554
+ console.warn(`[barefootjs] no claimed slot for id ${id}; write ignored`)
555
+ return
556
+ }
557
+ if (ref.kind === 'text') {
558
+ // A 'text' slot cannot represent an element. Promote once, then fall
559
+ // through to the markup writer — which is also what makes the read door
560
+ // answer `null` for this id afterwards, i.e. "cannot answer, write it",
561
+ // the conservative direction.
562
+ if (typeof Node !== 'undefined' && value instanceof Node) {
563
+ const promoted = promoteTextToMarkup(ref, id)
564
+ if (!promoted) return
565
+ refs.set(id, promoted)
566
+ writeMarkup(promoted, value)
567
+ return
568
+ }
569
+ writeText(ref, value)
570
+ } else {
571
+ writeMarkup(ref, value)
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Read half of the door. `null` means "cannot answer" — the slot is not a
577
+ * 'text' slot, or it never claimed — and every caller must treat that as
578
+ * "differs" and write. Conservative, never unsound. Reads are silent: a
579
+ * slot that failed to claim already warned when it did so.
580
+ */
581
+ function readSlot(refs: ReadonlyMap<string, ClaimedSlotRef>, id: string): string | null {
582
+ const ref = refs.get(id)
583
+ if (!ref || ref.kind !== 'text') return null
584
+ return readText(ref)
585
+ }
586
+
587
+ // --- public API ---
588
+
589
+ /**
590
+ * Claim every slot in `plan` against `root` NOW. Escape hatch for callers
591
+ * that cannot honor the row-pristine invariant `lazySlots` relies on
592
+ * (streaming/portal paths that may mutate row content before any write
593
+ * would naturally occur, per §6) — claim eagerly there instead.
594
+ */
595
+ export function claimSlots(root: Element, plan: ClaimPlan): ClaimedSlots {
596
+ const refs = claimRefs(root, plan)
597
+ return { write: (id, value) => writeSlot(refs, id, value) }
598
+ }
599
+
600
+ /** Resolve every slot in `plan`; slots that fail to claim are simply absent. */
601
+ function claimRefs(root: Element, plan: ClaimPlan): Map<string, ClaimedSlotRef> {
602
+ const refs = new Map<string, ClaimedSlotRef>()
603
+ for (const spec of plan) {
604
+ const ref = claimOne(root, spec)
605
+ if (ref) refs.set(spec.id, ref)
606
+ }
607
+ return refs
608
+ }
609
+
610
+ /**
611
+ * Lazy wrapper honoring the row-pristine invariant (§3(a)): nothing touches
612
+ * `root`'s DOM until the first write, and that first write claims the
613
+ * WHOLE plan at once — so no earlier write into a sibling slot can shift
614
+ * this row's still-unclaimed paths first. A row that never updates never
615
+ * pays for a claim at all.
616
+ */
617
+ export function lazySlots(root: Element, plan: ClaimPlan): SlotWriter {
618
+ let claimed: ClaimedSlots | null = null
619
+ return (id: string, value: unknown) => {
620
+ if (!claimed) claimed = claimSlots(root, plan)
621
+ claimed.write(id, value)
622
+ }
623
+ }
624
+
625
+ /**
626
+ * The read-capable twin of `lazySlots`: the same deferred claim, exposed as
627
+ * the full `{ write, read }` door instead of a bare write function.
628
+ *
629
+ * Two entry points rather than one door with a `.read` property because the
630
+ * door is allocated PER ROW: attaching a reader to every writer costs the
631
+ * extra closures on every row in the list, whether or not it ever reads
632
+ * (measured: +84KB/1k rows). Loops that need read-compare-write seeding
633
+ * (`spec/slot-unification.md` §9.3(1)) pay for the reader; every other loop
634
+ * keeps the single-closure writer. There is still exactly ONE claim
635
+ * mechanism underneath — every entry point resolves through `claimRefs`,
636
+ * and the first access of either kind resolves the whole plan while the row
637
+ * is pristine (§2's claim-once rule; reads never open a second resolution
638
+ * path).
639
+ */
640
+ export function lazyClaimSlots(root: Element, plan: ClaimPlan): ClaimedSlotsRW {
641
+ let refs: Map<string, ClaimedSlotRef> | null = null
642
+ const ensure = (): Map<string, ClaimedSlotRef> => (refs ??= claimRefs(root, plan))
643
+ return {
644
+ write: (id, value) => writeSlot(ensure(), id, value),
645
+ read: (id) => readSlot(ensure(), id),
646
+ }
647
+ }