@barefootjs/client 0.26.3 → 0.27.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.
@@ -0,0 +1,449 @@
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, CREATED if SSR emitted an empty value (`textNodeAfterComment`,
29
+ * exactly `$t`'s `tAfter` behavior). Writes are a `nodeValue` assignment
30
+ * — the Text node's identity never changes, which is the guarantee
31
+ * effect closures and `mapArray`'s same-key path rely on.
32
+ * - 'markup': held ref is BOTH boundary comments (start = anchor, end =
33
+ * the matching `<!--/-->` found by a nesting-depth walk (any further
34
+ * `bf:`-prefixed comment along the way opens a nested region). A string
35
+ * write clears everything strictly between the boundaries and inserts
36
+ * freshly `<template>`-parsed HTML before the end comment; a `Node`
37
+ * write clears the range and splices the node in by identity (the
38
+ * `__bfText` live-Node case). The boundaries themselves are never
39
+ * removed, so the range stays writeable on every later write. Because
40
+ * the end ref is already held, a write never needs to re-walk for
41
+ * nesting depth — only the CLAIM does.
42
+ *
43
+ * Warn-don't-guess (§4, "one ownership rule"): a path that fails to resolve
44
+ * to its slot's own `bf:sN` comment — out of range, or shape drift where
45
+ * the path now lands on some other node — falls back to a marker scan
46
+ * within the claim root, using the same ownership rule as `query.ts`'s `$t`:
47
+ * a `bf:sN` comment owned by a nested `bf-s` scope (a child component's own
48
+ * same-numbered slot — ids are
49
+ * assigned per component, so collisions are expected) is never a candidate
50
+ * — UNLESS the id is `^`-prefixed (`BF_PARENT_OWNED_PREFIX`), meaning the
51
+ * marker is content the claiming component itself authored and merely
52
+ * forwarded through one or more descendants' `children` (see
53
+ * `findOwnedMarker`'s docstring); for those the ownership walk is skipped
54
+ * outright, matching `query.ts`'s `$()`. If neither the path nor the
55
+ * fallback scan finds an owned marker, that one slot is dropped with a
56
+ * `console.warn` and the rest of the plan still claims — never guess a
57
+ * boundary, and never let one bad slot sink its siblings. An EMPTY path
58
+ * (`[]`) skips straight to the scan without that first warning — the
59
+ * compiler emits `path: []` deliberately when a slot's position can't be
60
+ * statically pathed (slot unification A3), so a miss there is expected, not
61
+ * drift; only a non-empty path that fails to resolve signals a real shape
62
+ * mismatch worth warning about.
63
+ *
64
+ * Row-pristine lazy claim (§3(a)): `lazySlots` touches NOTHING until the
65
+ * first write, and that first write claims the WHOLE plan at once (not just
66
+ * the slot being written) — so no earlier write into one slot can shift a
67
+ * sibling slot's still-unclaimed path out from under it. A row that never
68
+ * updates never pays for a claim at all. `claimSlots` is the eager escape
69
+ * hatch for callers that cannot honor that invariant (streaming/portal
70
+ * paths that mutate row content before any write would occur, per §6's
71
+ * risk note) — it claims every slot in the plan immediately.
72
+ *
73
+ * Dedup, no trust-first-run (slot unification A3 follow-up): a 'markup'
74
+ * slot's write door holds a `last` value alongside its boundary refs. Every
75
+ * write — INCLUDING THE FIRST — clears-and-inserts unless the new string
76
+ * equals `last`, in which case the DOM touch is skipped. `last` starts
77
+ * `undefined`, which never equals a `String(...)`-coerced value, so the
78
+ * first write can never dedup away; it always patches. A `Node` write is
79
+ * deduped by identity (mirrors `__bfText`'s `value === current` check) and,
80
+ * like the string case, is never skipped on the first write — a freshly
81
+ * `createComponent`-built element is a distinct object from whatever the
82
+ * SSR markup rendered, so it always splices. 'text' writes stay a plain
83
+ * `nodeValue` assignment (already idempotent, per §5's design note) — no
84
+ * dedup state needed.
85
+ *
86
+ * The first write is never skipped on the assumption that the claimed
87
+ * range already matches SSR/CSR content ("trust-first-run") — that
88
+ * assumption only holds for a preamble-region row whose SSR content and
89
+ * the effect's mount-time recomputation are both derived from the exact
90
+ * same source data, so they cannot disagree. It is false in general: any
91
+ * markup slot whose value comes from client-only state that the server
92
+ * cannot see — `createSignal(readFromLocalStorage())`, a client-side region
93
+ * swap adopting HTML the server rendered from a different default — can
94
+ * genuinely differ from the SSR/CSR content on the very first write, and
95
+ * skipping that first write would silently leave the stale SSR default on
96
+ * screen until the NEXT change (regression pin: site/ui's
97
+ * `admin-gallery.spec.ts` cross-page time-range persistence test). So every
98
+ * write unconditionally applies unless deduped by value/identity — never
99
+ * because it happens to be the first one — for every 'markup' caller,
100
+ * including the preamble-region case (which only loses a same-value
101
+ * redundant-patch skip on mount, not correctness).
102
+ */
103
+
104
+ import { BF_SCOPE, BF_PARENT_OWNED_PREFIX } from '@barefootjs/shared'
105
+ import { textNodeAfterComment, commentsInScope } from './query.ts'
106
+ import { commentScopeRegistry } from './scope.ts'
107
+
108
+ /**
109
+ * A slot's compile-time descriptor. `path` is the list of child indices
110
+ * from the claim root to the slot's ANCHOR NODE — in Step A that anchor is
111
+ * always the existing `<!--bf:sN-->` start comment (markers are still
112
+ * emitted; Step B may point paths at other node kinds), so resolution
113
+ * walks `childNodes` by index with no assumption about the target's
114
+ * `nodeType` until the kind-specific claim inspects it. `id` is kept for
115
+ * diagnostics and as the marker-scan fallback's search key.
116
+ */
117
+ export interface SlotSpec {
118
+ id: string
119
+ kind: 'text' | 'markup'
120
+ path: readonly number[]
121
+ /**
122
+ * Slot unification Step B (`spec/slot-unification.md` §3(b), §5 Step B):
123
+ * true when NO `<!--bf:id-->…<!--/-->` marker was emitted for this slot at
124
+ * all — `path` is then a path to the slot's POSITION itself (the LAST
125
+ * index is this slot's own index within its parent's `childNodes`, not an
126
+ * anchor comment to search from). Only ever set for `kind: 'text'` — a
127
+ * `'markup'` slot always keeps its markers (an empty-able range needs a
128
+ * physical anchor to splice into; see `spec/slot-unification.md` §3(b)
129
+ * case (ii)). Resolution CREATES a Text node at that position if SSR
130
+ * rendered the slot empty (nothing to adopt there yet) — see `claimOne`.
131
+ * The compiler emits this only when it has already proven the position
132
+ * safe (`client-only-elision.ts`); the runtime never re-derives it.
133
+ */
134
+ markerless?: boolean
135
+ }
136
+
137
+ export type ClaimPlan = readonly SlotSpec[]
138
+
139
+ /** A claimed 'text' slot: the live Text node, held by identity forever. */
140
+ interface ClaimedTextSlot {
141
+ readonly kind: 'text'
142
+ readonly node: Text
143
+ }
144
+
145
+ /**
146
+ * A claimed 'markup' slot: both boundary comments, held by identity.
147
+ * Content lives strictly between `start` and `end`; both survive every
148
+ * write. `last` is the trust-first-run + dedup state (see module docstring)
149
+ * — `undefined` until the first write, a `string` once a string has been
150
+ * recorded/patched, or the live `Node` once one has been spliced in.
151
+ */
152
+ interface ClaimedMarkupSlot {
153
+ readonly kind: 'markup'
154
+ readonly start: Comment
155
+ readonly end: Comment
156
+ last: string | Node | undefined
157
+ }
158
+
159
+ type ClaimedSlotRef = ClaimedTextSlot | ClaimedMarkupSlot
160
+
161
+ /**
162
+ * The result of claiming a plan: a write door keyed by slot id. Writing an
163
+ * id that failed to claim (or was never in the plan) warns and no-ops —
164
+ * one bad/missing slot never breaks any other slot's writes.
165
+ */
166
+ export interface ClaimedSlots {
167
+ write(id: string, value: unknown): void
168
+ }
169
+
170
+ /** `lazySlots`'s per-write function — the same shape `ClaimedSlots.write` has. */
171
+ export type SlotWriter = (id: string, value: unknown) => void
172
+
173
+ // --- path resolution ---
174
+
175
+ /** Walk `childNodes` by index from `root`. No node-kind assumption — the
176
+ * caller checks whether the result is actually the expected comment. */
177
+ function resolvePath(root: Node, path: readonly number[]): Node | null {
178
+ let node: Node = root
179
+ for (const index of path) {
180
+ const child: Node | undefined = node.childNodes[index]
181
+ if (!child) return null
182
+ node = child
183
+ }
184
+ return node
185
+ }
186
+
187
+ function isSlotComment(node: Node | null, id: string): node is Comment {
188
+ return node != null && node.nodeType === Node.COMMENT_NODE && (node as Comment).nodeValue === `bf:${id}`
189
+ }
190
+
191
+ /**
192
+ * Fallback marker scan, used only when a slot's compile-time path fails to
193
+ * resolve to its own `bf:sN` comment (shape drift, or a plan built against
194
+ * a differently-shaped claim root). The ownership rule: a same-id marker
195
+ * owned by a nested `bf-s` scope (a child component's own slot — ids
196
+ * collide across components by design) is skipped so the fallback can
197
+ * never claim into a child's content.
198
+ *
199
+ * `commentsInScope` (not a bare `document.createTreeWalker(root, …)`) so a
200
+ * whole-item loop conditional's claim root (`insert.ts`'s detached
201
+ * `commentScopeRegistry` proxy for a `<!--bf-loop-i:key-->` anchor, #1665)
202
+ * resolves correctly: the proxy has no DOM children of its own — the row's
203
+ * real content lives as SIBLINGS of the registered comment — and
204
+ * `commentsInScope` already knows to walk that sibling range instead of
205
+ * `root`'s (empty) descendants. The ownership boundary adapts to match:
206
+ * every node in a comment-scope's range shares the registered comment's
207
+ * OWN parent element, so that (not the unreachable proxy `root`) is where
208
+ * the ancestor walk must stop.
209
+ *
210
+ * Parent-owned slots (`^`-prefixed id, `BF_PARENT_OWNED_PREFIX`) skip the
211
+ * ownership walk entirely — same carve-out as `query.ts`'s `$()` and its
212
+ * `findText` marker map. A `^sN` id is JSX children the CLAIMING component
213
+ * itself authored (e.g. `<Button><span>{displayText()}</span></Button>`)
214
+ * that only physically lands inside descendant components' DOM because it
215
+ * was forwarded through their `children` prop — every one of those
216
+ * descendants (Button, its own children, …) legitimately carries its own
217
+ * `bf-s` scope attribute, but that scope boundary says nothing about who
218
+ * authored THIS content. Without the carve-out, any slot forwarded more
219
+ * than zero levels deep is unfindable — every ordinary ancestor bf-s
220
+ * attribute trips the "nested scope" rejection meant for a same-numbered
221
+ * marker some unrelated component happens to render for itself.
222
+ */
223
+ function findOwnedMarker(root: Element, id: string): Comment | null {
224
+ const marker = `bf:${id}`
225
+ const parentOwned = id.startsWith(BF_PARENT_OWNED_PREFIX)
226
+ const registryInfo = commentScopeRegistry.get(root)
227
+ const boundary = registryInfo ? registryInfo.commentNode.parentElement : root
228
+ for (const comment of commentsInScope(root)) {
229
+ if (comment.nodeValue !== marker) continue
230
+ if (parentOwned) return comment
231
+ let owned = true
232
+ for (let el = comment.parentElement; el && el !== boundary; el = el.parentElement) {
233
+ if (el.hasAttribute(BF_SCOPE)) {
234
+ owned = false
235
+ break
236
+ }
237
+ }
238
+ if (owned) return comment
239
+ }
240
+ return null
241
+ }
242
+
243
+ /**
244
+ * Find the matching `<!--/-->` end comment for a 'markup' slot's start
245
+ * comment: any further `bf:`-prefixed comment along the way opens a nested
246
+ * region (a leaf rendered inside this one can carry its own ordinary slot
247
+ * markers) and increments a depth counter so that region's own `/` doesn't
248
+ * prematurely close this outer range. Runs once, at claim time — writes
249
+ * never need this since the end ref is held afterward.
250
+ */
251
+ function findMarkupEnd(start: Comment): Comment | null {
252
+ let depth = 0
253
+ let node: Node | null = start.nextSibling
254
+ while (node) {
255
+ if (node.nodeType === Node.COMMENT_NODE) {
256
+ const value = (node as Comment).nodeValue ?? ''
257
+ if (value.startsWith('bf:')) {
258
+ depth++
259
+ } else if (value === '/') {
260
+ if (depth === 0) return node as Comment
261
+ depth--
262
+ }
263
+ }
264
+ node = node.nextSibling
265
+ }
266
+ return null
267
+ }
268
+
269
+ /**
270
+ * Resolve one slot's anchor comment: try the compile-time path first, fall
271
+ * back to an owned marker scan on any miss (path resolves to nothing, or to
272
+ * a node that isn't this slot's own comment — shape drift), and warn on
273
+ * either the fallback-needed or the total-miss case — EXCEPT when the plan
274
+ * shipped an empty path (`spec.path.length === 0`, slot unification A3's
275
+ * "cannot be statically pathed" case, `spec/slot-unification.md` §5-A3):
276
+ * an empty path is a deliberate "no compile-time path available" marker,
277
+ * not a claim that index `0` addresses this slot, so going straight to the
278
+ * scan is the plan's INTENDED behavior, not a drift to warn about. Never
279
+ * throws — a bad slot returns `null` and the caller drops it from the
280
+ * claimed set.
281
+ */
282
+ function resolveAnchor(root: Element, spec: SlotSpec): Comment | null {
283
+ if (spec.path.length > 0) {
284
+ const resolved = resolvePath(root, spec.path)
285
+ if (isSlotComment(resolved, spec.id)) return resolved
286
+ console.warn(
287
+ `[barefootjs] claim path for slot ${spec.id} did not resolve to its bf:${spec.id} marker; falling back to a scan`,
288
+ )
289
+ }
290
+
291
+ const found = findOwnedMarker(root, spec.id)
292
+ if (!found) {
293
+ console.warn(`[barefootjs] slot ${spec.id} marker not found; skipping`)
294
+ }
295
+ return found
296
+ }
297
+
298
+ /**
299
+ * Resolve a `markerless` 'text' slot (slot unification Step B): `path`'s
300
+ * LAST index is the slot's own position within its parent's `childNodes` —
301
+ * there is no anchor comment to walk from or scan for, since the compiler
302
+ * only ever sets `markerless` when it has already proven no marker is
303
+ * needed (`client-only-elision.ts`). If SSR/CSR rendered the slot non-empty,
304
+ * a Text node already sits at that position — adopt it. If SSR rendered it
305
+ * empty (the only case Step B currently elides — `/* @client *\/`
306
+ * expressions, always empty at claim time), nothing sits there yet — create
307
+ * one and insert it before whatever currently occupies that index (or at
308
+ * the end, if the index is past the end of `childNodes`). Never falls back
309
+ * to a marker scan — there is no marker to find — so a path miss here is a
310
+ * genuine, loud failure, not the "cannot be statically pathed" case
311
+ * `resolveAnchor`'s empty-path allowance covers.
312
+ */
313
+ function claimMarkerlessText(root: Element, spec: SlotSpec): ClaimedTextSlot | null {
314
+ if (spec.path.length === 0) {
315
+ console.warn(`[barefootjs] markerless slot ${spec.id} has an empty path; skipping`)
316
+ return null
317
+ }
318
+ const parentPath = spec.path.slice(0, -1)
319
+ const idx = spec.path[spec.path.length - 1]
320
+ const parent = resolvePath(root, parentPath)
321
+ if (!parent) {
322
+ console.warn(`[barefootjs] markerless claim path for slot ${spec.id} did not resolve to a parent node; skipping`)
323
+ return null
324
+ }
325
+ const existing = parent.childNodes[idx] as Node | undefined
326
+ if (existing && existing.nodeType === Node.TEXT_NODE) {
327
+ return { kind: 'text', node: existing as Text }
328
+ }
329
+ const node = document.createTextNode('')
330
+ parent.insertBefore(node, existing ?? null)
331
+ return { kind: 'text', node }
332
+ }
333
+
334
+ /** Claim one slot per its kind's contract. `null` on any failure (already warned). */
335
+ function claimOne(root: Element, spec: SlotSpec): ClaimedSlotRef | null {
336
+ if (spec.kind === 'text' && spec.markerless) {
337
+ return claimMarkerlessText(root, spec)
338
+ }
339
+ const anchor = resolveAnchor(root, spec)
340
+ if (!anchor) return null
341
+
342
+ if (spec.kind === 'text') {
343
+ return { kind: 'text', node: textNodeAfterComment(anchor) }
344
+ }
345
+
346
+ const end = findMarkupEnd(anchor)
347
+ if (!end) {
348
+ console.warn(`[barefootjs] slot ${spec.id} has no end marker; skipping`)
349
+ return null
350
+ }
351
+ return { kind: 'markup', start: anchor, end, last: undefined }
352
+ }
353
+
354
+ // --- writes ---
355
+
356
+ function writeText(ref: ClaimedTextSlot, value: unknown): void {
357
+ ref.node.nodeValue = String(value ?? '')
358
+ }
359
+
360
+ /** Remove every node strictly between `start` and `end` (both survive). */
361
+ function clearMarkupRange(start: Comment, end: Comment): void {
362
+ const parent = end.parentNode
363
+ if (!parent) return
364
+ let node: Node | null = start.nextSibling
365
+ while (node && node !== end) {
366
+ const next = node.nextSibling
367
+ parent.removeChild(node)
368
+ node = next
369
+ }
370
+ }
371
+
372
+ function writeMarkup(ref: ClaimedMarkupSlot, value: unknown): void {
373
+ const { start, end } = ref
374
+ const parent = end.parentNode
375
+ if (!parent) return
376
+
377
+ // Slot markers (`__slot()`, `@barefootjs/client/slot.ts`): a caller-passed
378
+ // JSX prop that itself contains a component. Leave the server-rendered DOM
379
+ // untouched entirely — no write, no `last` update either, so a later real
380
+ // value still gets a correct dedup read. Mirrors `__bfText`'s identical
381
+ // guard (#1663).
382
+ if (value != null && (value as { __isSlot?: boolean }).__isSlot) return
383
+
384
+ if (typeof Node !== 'undefined' && value instanceof Node) {
385
+ // Identity dedup, mirrors `__bfText`'s `value === current` check — the
386
+ // same live node handed back again is a no-op. `ref.last` starts
387
+ // `undefined`, which no real Node is ever `===` to, so the first Node
388
+ // write always splices — a freshly rendered Node is never the
389
+ // SSR-rendered markup by identity.
390
+ if (value === ref.last) return
391
+ clearMarkupRange(start, end)
392
+ parent.insertBefore(value, end)
393
+ ref.last = value
394
+ return
395
+ }
396
+
397
+ const text = String(value ?? '')
398
+ if (text === ref.last) return // dedup: identical string, skip the DOM touch
399
+ clearMarkupRange(start, end)
400
+ const tpl = document.createElement('template')
401
+ tpl.innerHTML = text
402
+ parent.insertBefore(tpl.content, end)
403
+ ref.last = text
404
+ }
405
+
406
+ function writeSlot(refs: ReadonlyMap<string, ClaimedSlotRef>, id: string, value: unknown): void {
407
+ const ref = refs.get(id)
408
+ if (!ref) {
409
+ console.warn(`[barefootjs] no claimed slot for id ${id}; write ignored`)
410
+ return
411
+ }
412
+ if (ref.kind === 'text') {
413
+ writeText(ref, value)
414
+ } else {
415
+ writeMarkup(ref, value)
416
+ }
417
+ }
418
+
419
+ // --- public API ---
420
+
421
+ /**
422
+ * Claim every slot in `plan` against `root` NOW. Escape hatch for callers
423
+ * that cannot honor the row-pristine invariant `lazySlots` relies on
424
+ * (streaming/portal paths that may mutate row content before any write
425
+ * would naturally occur, per §6) — claim eagerly there instead.
426
+ */
427
+ export function claimSlots(root: Element, plan: ClaimPlan): ClaimedSlots {
428
+ const refs = new Map<string, ClaimedSlotRef>()
429
+ for (const spec of plan) {
430
+ const ref = claimOne(root, spec)
431
+ if (ref) refs.set(spec.id, ref)
432
+ }
433
+ return { write: (id, value) => writeSlot(refs, id, value) }
434
+ }
435
+
436
+ /**
437
+ * Lazy wrapper honoring the row-pristine invariant (§3(a)): nothing touches
438
+ * `root`'s DOM until the first write, and that first write claims the
439
+ * WHOLE plan at once — so no earlier write into a sibling slot can shift
440
+ * this row's still-unclaimed paths first. A row that never updates never
441
+ * pays for a claim at all.
442
+ */
443
+ export function lazySlots(root: Element, plan: ClaimPlan): SlotWriter {
444
+ let claimed: ClaimedSlots | null = null
445
+ return (id: string, value: unknown) => {
446
+ if (!claimed) claimed = claimSlots(root, plan)
447
+ claimed.write(id, value)
448
+ }
449
+ }
@@ -2,7 +2,7 @@
2
2
  * BarefootJS - Component Creation
3
3
  *
4
4
  * Functions for dynamically creating component instances at runtime.
5
- * Used by reconcileList() when rendering components in loops.
5
+ * Used by mapArray()/mapArrayAnchored() when rendering components in loops.
6
6
  */
7
7
 
8
8
  import { getTemplate } from './template.ts'
@@ -25,15 +25,6 @@ export function setParentScopeId(id: string | null): void {
25
25
  _parentScopeId = id
26
26
  }
27
27
 
28
- // WeakMap to store props update functions for each component element
29
- // This allows reconcileList to update props when an element is reused
30
- const propsUpdateMap = new WeakMap<HTMLElement, (props: Record<string, unknown>) => void>()
31
-
32
- // WeakMap to store the current props for each component element
33
- // Used to pass props to existing elements when they are reused
34
- const propsMap = new WeakMap<HTMLElement, Record<string, unknown>>()
35
-
36
-
37
28
  /**
38
29
  * Create a component instance with DOM element and initialized state.
39
30
  *
@@ -214,14 +205,12 @@ export function createComponent(
214
205
  if (materialised && !materialised.hasAttribute(BF_PLACEHOLDER)) {
215
206
  // The deferred child was created in place of the placeholder.
216
207
  // `materialised` is the child's OWN element, created via
217
- // upsertChild -> createComponent, which already registered itself
218
- // (hydratedScopes / propsMap / registerPropsUpdate) keyed to the
219
- // child with the child's own props. We must NOT re-register it here:
220
- // overwriting propsMap/registerPropsUpdate with the *parent's* props
221
- // would mis-key the child (e.g. a later getComponentProps would read
222
- // the parent's props), and re-running the parent's init on an element
223
- // whose placeholder is already gone could not re-materialise. So just
224
- // restore the scope and return the already-registered child.
208
+ // upsertChild -> createComponent, which already marked itself
209
+ // hydrated with its own props. We must NOT re-run this function's
210
+ // own registration steps on it here that would re-run the
211
+ // *parent's* init on an element whose placeholder is already gone
212
+ // and could not re-materialise. So just restore the scope and
213
+ // return the already-registered child.
225
214
  // (Parent-scope effects are unaffected: createEffect ownership lives
226
215
  // in the EffectContext tree, not the discarded placeholder element.)
227
216
  setCurrentScope(prevScope)
@@ -248,51 +237,9 @@ export function createComponent(
248
237
  // 12. Mark element as initialized
249
238
  hydratedScopes.add(element)
250
239
 
251
- // 13. Store props and register update function for element reuse in reconcileList
252
- propsMap.set(element, props)
253
- registerPropsUpdate(element, name, props)
254
-
255
240
  return element
256
241
  }
257
242
 
258
- /**
259
- * Get the props stored for a component element.
260
- * Used by reconcileList to pass props to an existing element.
261
- */
262
- export function getComponentProps(element: HTMLElement): Record<string, unknown> | undefined {
263
- return propsMap.get(element)
264
- }
265
-
266
- /**
267
- * Register a props update function for a component element.
268
- * When called, this function re-initializes the component with new props.
269
- */
270
- function registerPropsUpdate(
271
- element: HTMLElement,
272
- name: string,
273
- _initialProps: Record<string, unknown>
274
- ): void {
275
- // Register update function that will be called by reconcileList
276
- propsUpdateMap.set(element, (newProps: Record<string, unknown>) => {
277
- // Re-initialize the component with new props
278
- // This allows the component to capture new values (e.g., todo with editing: true)
279
- // and set up new effects that reference the new values
280
- const init = getComponentInit(name)
281
- if (init) {
282
- init(element, newProps)
283
- }
284
- })
285
- }
286
-
287
- /**
288
- * Get the props update function for an element.
289
- * Used by reconcileList to update props when reusing an element.
290
- */
291
- export function getPropsUpdateFn(element: HTMLElement): ((props: Record<string, unknown>) => void) | undefined {
292
- return propsUpdateMap.get(element)
293
- }
294
-
295
-
296
243
  /**
297
244
  * Render a child component's template to an HTML string.
298
245
  * Used by compiler-generated template functions when a stateless component
@@ -471,18 +418,44 @@ export function escapeAttr(value: unknown): string {
471
418
  *
472
419
  * A nullish value renders as empty text — the JSX/Solid semantics the Hono
473
420
  * SSR reference follows (`{undefined}` / `{null}` produce no text), and
474
- * what the reactive text-update path already does (`dynamic-text.ts` and
475
- * `client-marker.ts` both `String(value ?? '')`). Only this initial-render
476
- * escape site used to stringify `undefined` / `null` into literal
477
- * "undefined" / "null" text, so a bare `{props.x}` on an absent prop
478
- * diverged from SSR at first paint (#2137). Non-nullish values (including
479
- * `0` and `false`) keep their `String()` form, matching the reactive path.
421
+ * what the reactive text-update path already does (`claim-slots.ts`'s
422
+ * `writeText`/`writeMarkup` and `dynamic-text.ts` all `String(value ?? '')`).
423
+ * Only this initial-render escape site used to stringify `undefined` /
424
+ * `null` into literal "undefined" / "null" text, so a bare `{props.x}` on
425
+ * an absent prop diverged from SSR at first paint (#2137). Non-nullish
426
+ * values (including `0` and `false`) keep their `String()` form, matching
427
+ * the reactive path.
480
428
  */
481
429
  export function escapeText(value: unknown): string {
482
430
  if (value == null) return ''
483
431
  return escapeAttr(value)
484
432
  }
485
433
 
434
+ /**
435
+ * `escapeText`'s counterpart for a claimed 'markup' slot's REACTIVE write
436
+ * (slot unification A3 follow-up), where the value is a plain-JS expression
437
+ * that may resolve to either a string or a live `Node` (e.g. `{cond &&
438
+ * logo(id)}`, a hoisted `renderNode` callback, #1213). `writeMarkup`
439
+ * (`claim-slots.ts`) inserts a string via `<template>.innerHTML =`, which —
440
+ * unlike the old `__bfText`'s plain `Text.nodeValue =` assignment — DOES
441
+ * interpret HTML, so a raw un-escaped string is an injection/corruption
442
+ * risk exactly where the initial SSR/CSR TEMPLATE already calls
443
+ * `escapeText` on the same expression (`html-template.ts`'s
444
+ * `escapeTextSlotExpr`). A live `Node`, by contrast, must pass through
445
+ * untouched — `escapeText(node)` would stringify it to garbage, and
446
+ * `writeMarkup`'s own `instanceof Node` check needs the real object to
447
+ * splice in by identity. This is the single call every "dynamic JSX/text
448
+ * slot, value may be a Node" emission site (`emit-reactive.ts`,
449
+ * `stringify/loop-child-arm.ts`, `stringify/insert.ts`) wraps the value in
450
+ * before handing it to a 'markup' writer — NOT the preamble-region case
451
+ * (`stringify/loop.ts`), whose value is already-built HTML from a nested
452
+ * compiled render and must stay unescaped.
453
+ */
454
+ export function escapeTextOrNode(value: unknown): string | Node {
455
+ if (typeof Node !== 'undefined' && value instanceof Node) return value
456
+ return escapeText(value)
457
+ }
458
+
486
459
  const SVG_NS = 'http://www.w3.org/2000/svg'
487
460
 
488
461
  /**
@@ -593,8 +566,5 @@ function createComponentFromDef(
593
566
  // Mark as initialized
594
567
  hydratedScopes.add(element)
595
568
 
596
- // Store props for element reuse
597
- propsMap.set(element, props)
598
-
599
569
  return element
600
570
  }
@@ -1,7 +1,30 @@
1
1
  /**
2
2
  * Dynamic text/JSX slot updater (#1663).
3
3
  *
4
- * The compiler wraps reactive child expressions (`<div>{expr}</div>`) in a
4
+ * Slot unification A3 (`spec/slot-unification.md` §5-A3) replaced every
5
+ * OTHER `__bfText` call site with a claimed 'markup' slot writer
6
+ * (`claim-slots.ts`'s `writeMarkup` provides the identical Node/text
7
+ * contract). ONE emission site still calls `__bfText` directly and is
8
+ * deliberately deferred: `emitDynamicTextUpdates`'s `conditionalElems`
9
+ * path (`ir-to-client-js/emit-reactive.ts`) — a dynamic text/JSX
10
+ * expression nested inside a top-level (non-loop) conditional, tracked by
11
+ * an effect OUTSIDE the conditional's own `insert()` `bindEvents`. That
12
+ * effect re-resolves its anchor via `$t(__scope, slotId)` on EVERY run
13
+ * because `insert()` may swap the branch independently of this effect's own
14
+ * reruns — a cached `lazySlots` claim would go stale across such a swap, and
15
+ * a 'markup' slot's dedup `last` state can't safely survive being re-claimed
16
+ * fresh every run either: a fresh claim's `last` always starts `undefined`,
17
+ * so re-claiming per-run would throw away the dedup skip on every single
18
+ * run (every write would re-clear-and-reparse even when the value hasn't
19
+ * changed) — unlike the 'text'-kind conditional cases elsewhere in the
20
+ * compiler, which have no such state to go stale and so DO re-claim fresh
21
+ * each run safely. Moving this one case onto the claim-plan model needs the
22
+ * slot's claim door tied to the branch's OWN activation lifecycle instead of
23
+ * this separate effect's — real architectural work, not a mechanical swap —
24
+ * so it stays on `$t`/`__bfText` for now.
25
+ *
26
+ * The mechanism itself, for the reader who lands here from that one site:
27
+ * the compiler wraps reactive child expressions (`<div>{expr}</div>`) in a
5
28
  * `createEffect` that writes the value into the text node sitting between
6
29
  * the slot's `<!--bf:sX-->` / `<!--/-->` comment markers. That was a pure
7
30
  * `nodeValue = String(value)` assignment, which is correct for primitives