@barefootjs/jsx 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 (106) hide show
  1. package/dist/compiler.d.ts.map +1 -1
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +941 -130
  5. package/dist/ir-to-client-js/client-only-elision.d.ts +103 -0
  6. package/dist/ir-to-client-js/client-only-elision.d.ts.map +1 -0
  7. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +6 -0
  9. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts +2 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/build-insert.d.ts +7 -0
  13. package/dist/ir-to-client-js/control-flow/plan/build-insert.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +107 -0
  15. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -0
  16. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts +9 -1
  17. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts +203 -0
  19. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -0
  20. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +10 -0
  21. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/shared.d.ts +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/branch-loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/stringify/claim-plan.d.ts +54 -0
  25. package/dist/ir-to-client-js/control-flow/stringify/claim-plan.d.ts.map +1 -0
  26. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/control-flow/stringify/insert.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts +98 -0
  29. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -0
  30. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +5 -0
  31. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  32. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +17 -20
  33. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  34. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +63 -4
  35. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  36. package/dist/ir-to-client-js/control-flow.d.ts +1 -1
  37. package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
  38. package/dist/ir-to-client-js/element-refs.d.ts +8 -3
  39. package/dist/ir-to-client-js/element-refs.d.ts.map +1 -1
  40. package/dist/ir-to-client-js/emit-reactive.d.ts +8 -1
  41. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/html-template.d.ts +24 -0
  43. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  44. package/dist/ir-to-client-js/imports.d.ts +2 -2
  45. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  46. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  47. package/dist/ir-to-client-js/types.d.ts +9 -1
  48. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  49. package/dist/ir-to-client-js/utils.d.ts +1 -1
  50. package/dist/types.d.ts +25 -2
  51. package/dist/types.d.ts.map +1 -1
  52. package/package.json +2 -2
  53. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +542 -689
  54. package/src/__tests__/child-components-in-map.test.ts +4 -4
  55. package/src/__tests__/client-js-generation.test.ts +23 -12
  56. package/src/__tests__/composite-branch-loop.test.ts +14 -5
  57. package/src/__tests__/conditional-mapArray-key.test.ts +7 -1
  58. package/src/__tests__/create-selector.test.ts +21 -7
  59. package/src/__tests__/cross-file-client-signal.test.ts +5 -1
  60. package/src/__tests__/early-return-scope-var-ref.test.ts +10 -11
  61. package/src/__tests__/inline-jsx-callback.test.ts +6 -4
  62. package/src/__tests__/ir-jsx-props.test.ts +6 -5
  63. package/src/__tests__/lazy-row-eligibility.test.ts +692 -0
  64. package/src/__tests__/loop-branch-bare-expression-reactive-text.test.ts +98 -0
  65. package/src/__tests__/loop-fallback-wrap.test.ts +34 -16
  66. package/src/__tests__/loop-hoisted-template.test.ts +10 -6
  67. package/src/__tests__/module-level-signal.audit.test.ts +7 -2
  68. package/src/__tests__/nested-loop-conditional.test.ts +9 -7
  69. package/src/__tests__/nested-loop-reactive-attrs.test.ts +2 -2
  70. package/src/__tests__/preamble-region-patch.test.ts +18 -12
  71. package/src/__tests__/profile-cond-binding-ids.test.ts +3 -2
  72. package/src/__tests__/profile-loop-binding-ids.test.ts +4 -3
  73. package/src/__tests__/profile-nested-binding-ids.test.ts +11 -6
  74. package/src/__tests__/reactive-attrs-in-map.test.ts +6 -4
  75. package/src/__tests__/static-loop-csr-materialize.test.ts +6 -1
  76. package/src/__tests__/template-closure.test.ts +1 -1
  77. package/src/compiler.ts +11 -0
  78. package/src/index.ts +1 -0
  79. package/src/ir-to-client-js/client-only-elision.ts +273 -0
  80. package/src/ir-to-client-js/collect-elements.ts +56 -17
  81. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +6 -0
  82. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +35 -12
  83. package/src/ir-to-client-js/control-flow/plan/build-insert.ts +9 -2
  84. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +305 -0
  85. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +44 -12
  86. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +440 -0
  87. package/src/ir-to-client-js/control-flow/plan/loop.ts +10 -0
  88. package/src/ir-to-client-js/control-flow/shared.ts +1 -1
  89. package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +34 -4
  90. package/src/ir-to-client-js/control-flow/stringify/claim-plan.ts +68 -0
  91. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +18 -9
  92. package/src/ir-to-client-js/control-flow/stringify/insert.ts +17 -11
  93. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +478 -0
  94. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +41 -17
  95. package/src/ir-to-client-js/control-flow/stringify/loop.ts +71 -46
  96. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +241 -31
  97. package/src/ir-to-client-js/control-flow.ts +8 -3
  98. package/src/ir-to-client-js/element-refs.ts +9 -11
  99. package/src/ir-to-client-js/emit-reactive.ts +50 -14
  100. package/src/ir-to-client-js/html-template.ts +29 -9
  101. package/src/ir-to-client-js/imports.ts +13 -4
  102. package/src/ir-to-client-js/reactivity.ts +3 -2
  103. package/src/ir-to-client-js/types.ts +10 -2
  104. package/src/ir-to-client-js/utils.ts +1 -1
  105. package/src/jsx-to-ir.ts +26 -8
  106. package/src/types.ts +25 -2
@@ -9,6 +9,7 @@ import type { AttrMeta, IRMetadata } from '../types.ts'
9
9
  import { isBooleanAttr } from '../html-constants.ts'
10
10
  import type { ClientJsContext } from './types.ts'
11
11
  import { toHtmlAttrName, varSlotId, PROPS_PARAM } from './utils.ts'
12
+ import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from './control-flow/stringify/claim-plan.ts'
12
13
  import { createTemplateAwareStringProtector } from './html-template.ts'
13
14
  import { datePlugin, DATE_METHODS } from '../date-lowering.ts'
14
15
  import { toLocaleDatePlugin, foldedArgToClientJs } from '../to-locale-date-lowering.ts'
@@ -303,24 +304,44 @@ export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): v
303
304
  const normalElems = elems.filter(e => !e.insideConditional)
304
305
 
305
306
  if (normalElems.length > 0 || conditionalElems.length > 0) {
306
- // Persistent slot trackers for non-conditional elements. `__bfText`
307
- // returns the node now occupying the slot; a JSX-valued expression
308
- // (`{themeLogo(id)}`) replaces the text node with a live element, so
309
- // the next reactive run must operate on that element, not the stale
310
- // text node (#1663). Primitive values keep the same text node.
311
- for (const elem of normalElems) {
312
- const v = varSlotId(elem.slotId)
313
- lines.push(` let __anchor_${v} = _${v}`)
314
- }
315
307
  const __textSlot = (normalElems[0] ?? conditionalElems[0])?.slotId
308
+
309
+ // Non-conditional elements: ONE claimed 'markup' writer covering every
310
+ // slot this expression feeds (slot unification A3, row-level claim at
311
+ // `__scope` — spec/slot-unification.md §3(a)/§5-A3). 'markup' (not
312
+ // 'text') because the value may be a live Node (e.g. a JSX-returning
313
+ // call like `{themeLogo(id)}`) — the writer splices a Node in by
314
+ // identity, exactly the contract `__bfText` used to provide (#1663).
315
+ let writer = ''
316
+ if (normalElems.length > 0) {
317
+ const slots: ClaimSlotSpec[] = normalElems.map(elem => ({ id: elem.slotId, kind: 'markup', path: [] }))
318
+ writer = claimWriterVarName(slots, varSlotId)
319
+ lines.push(` const ${writer} = lazySlots(__scope, ${claimPlanLiteral(slots)})`)
320
+ }
321
+
316
322
  lines.push(` createEffect(() => {`)
317
323
  if (normalElems.length > 0) {
318
324
  // Expression is always evaluated for non-conditional elements
319
325
  lines.push(` const __val = ${expr}`)
326
+ // `escapeTextOrNode`: `writeMarkup` inserts a string via
327
+ // `innerHTML =`, which — unlike the plain `Text.nodeValue =`
328
+ // assignment the initial SSR/CSR TEMPLATE's `escapeText(${expr})`
329
+ // was standing in for — DOES interpret HTML, so a raw un-escaped
330
+ // string here is an injection/byte-parity gap; a live Node (this
331
+ // expression may return one) must pass through untouched instead.
320
332
  for (const elem of normalElems) {
321
- const v = varSlotId(elem.slotId)
322
- lines.push(` __anchor_${v} = __bfText(__anchor_${v}, __val)`)
333
+ lines.push(` ${writer}('${elem.slotId}', escapeTextOrNode(__val))`)
323
334
  }
335
+ // Conditional elements: deferred from slot unification A3 — see
336
+ // `__bfText`'s docstring for why. `insert()` may swap the branch's
337
+ // DOM independently of this effect's own reruns, and a cached claim
338
+ // door's dedup state would go stale across that swap (unlike the
339
+ // 'text'-kind conditional cases elsewhere in the compiler, this
340
+ // slot's value may be a live Node, so it can't reuse the "just
341
+ // re-claim fresh every run" trick those use — a fresh 'markup'
342
+ // claim's dedup state always starts empty, so re-claiming per-run
343
+ // would throw away the unchanged-value skip on every single run).
344
+ // Keeps `$t` + `__bfText`.
324
345
  for (const elem of conditionalElems) {
325
346
  const v = varSlotId(elem.slotId)
326
347
  lines.push(` const [__el_${v}] = $t(__scope, '${elem.slotId}')`)
@@ -348,13 +369,28 @@ export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): v
348
369
  }
349
370
  }
350
371
 
351
- /** Emit createEffect blocks for client-only expressions using comment markers. */
372
+ /**
373
+ * Emit createEffect blocks for `@client` expressions. Slot unification A3:
374
+ * these are ordinary claimed 'text' slots now (never Node-valued — `@client`
375
+ * only ever evaluates a plain expression), one writer per slot at `__scope`,
376
+ * replacing `updateClientMarker`'s full-tree rescan + zero-width-space
377
+ * managed-node marker (no longer needed: a claimed Text node's identity is
378
+ * already known, so there's nothing to disambiguate from other content).
379
+ */
352
380
  export function emitClientOnlyExpressions(lines: string[], ctx: ClientJsContext): void {
353
381
  for (const elem of ctx.clientOnlyElements) {
382
+ // Slot unification Step B: `elem.elidedPath`, when present, was proven
383
+ // safe by `client-only-elision.ts` before this pass ran — use the real
384
+ // position-based path and skip the marker-scan fallback entirely.
385
+ const slots: ClaimSlotSpec[] = elem.elidedPath
386
+ ? [{ id: elem.slotId, kind: 'text', path: elem.elidedPath, markerless: true }]
387
+ : [{ id: elem.slotId, kind: 'text', path: [] }]
388
+ const writer = claimWriterVarName(slots, varSlotId)
354
389
  lines.push(` // @client: ${elem.slotId}`)
390
+ lines.push(` { const ${writer} = lazySlots(__scope, ${claimPlanLiteral(slots)})`)
355
391
  lines.push(` createEffect(() => {`)
356
- lines.push(` updateClientMarker(__scope, '${elem.slotId}', ${elem.expression})`)
357
- lines.push(` }${bindingIdArg(ctx, elem.slotId)})`)
392
+ lines.push(` ${writer}('${elem.slotId}', ${elem.expression})`)
393
+ lines.push(` }${bindingIdArg(ctx, elem.slotId)}) }`)
358
394
  lines.push('')
359
395
  }
360
396
  }
@@ -126,7 +126,11 @@ export function createTemplateAwareStringProtector(): {
126
126
  return { protect, restore, replaceProtectedCall }
127
127
  }
128
128
 
129
- const VOID_ELEMENTS = new Set([
129
+ // Exported (not just module-local) so `client-only-elision.ts` (slot
130
+ // unification Step B) can reuse the exact same void-element list when it
131
+ // walks the IR tree computing markerless-eligible child-index paths —
132
+ // duplicating this list would risk the two walks silently drifting apart.
133
+ export const VOID_ELEMENTS = new Set([
130
134
  'area', 'base', 'br', 'col', 'embed', 'hr', 'img',
131
135
  'input', 'link', 'meta', 'param', 'source', 'track', 'wbr',
132
136
  ])
@@ -794,6 +798,16 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
794
798
 
795
799
  case 'expression': {
796
800
  if (node.expr === 'null' || node.expr === 'undefined') return ''
801
+ // Slot unification Step B (`markerless`, decided once by
802
+ // `client-only-elision.ts` before this CSR pass runs): drop the
803
+ // marker pair and fall through to the same bare `${...}` shape the
804
+ // no-slotId branch below already uses — `elidedPath` (not this
805
+ // string) is what the claim plan resolves against, so no marker
806
+ // anchor is needed here at all.
807
+ if (node.markerless) {
808
+ const bare = wrapInterpolation(wrapExpr(node.expr))
809
+ return `\${${bare}}`
810
+ }
797
811
  const inner = wrapInterpolation(wrapExpr(node.expr))
798
812
  // Stage 3 / D4 — an element-array child ({out}) built by an arbitrary
799
813
  // .map() preamble is an array of HTML strings; join it rather than let
@@ -908,8 +922,8 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
908
922
  // (Go's `bf_sort`, etc.) applied the sort separately and
909
923
  // hydration only needed to match, but broken on Hono / CSR
910
924
  // where the template is the only source of truth. The chain
911
- // mirrors `buildChainedArrayExpr` so reconcileList sees the
912
- // same array shape this template emits.
925
+ // mirrors `buildChainedArrayExpr` so mapArray/mapArrayAnchored sees
926
+ // the same array shape this template emits.
913
927
  const rawChainedArray = applyLoopChain(node)
914
928
  const { array: iterArray, callbackParam } = applyIterationShape(node, rawChainedArray, indexParam)
915
929
  const wrappedArray = wrapExpr(iterArray)
@@ -1120,7 +1134,9 @@ export interface SkeletonSlotPaths {
1120
1134
  * the loop keeps its hoisted-clone fast path, but every slot lookup falls
1121
1135
  * back to `qsa`/`$t`.
1122
1136
  */
1123
- const SKELETON_PATH_HAZARD_TAGS = new Set([
1137
+ // Exported for reuse by `client-only-elision.ts` (Step B) — same hazard
1138
+ // class, same reasoning, must never drift out of sync with a second copy.
1139
+ export const SKELETON_PATH_HAZARD_TAGS = new Set([
1124
1140
  'table', 'thead', 'tbody', 'tfoot', 'caption', 'colgroup', 'col',
1125
1141
  'select', 'optgroup',
1126
1142
  'p',
@@ -1136,7 +1152,7 @@ const SKELETON_PATH_HAZARD_TAGS = new Set([
1136
1152
  * `<dt>` closes the `<dt>`). A skeleton hitting this bails on path
1137
1153
  * computation entirely (see `SKELETON_PATH_HAZARD_TAGS` doc).
1138
1154
  */
1139
- const SKELETON_PATH_FORCE_CLOSE_GROUPS: ReadonlyArray<ReadonlySet<string>> = [
1155
+ export const SKELETON_PATH_FORCE_CLOSE_GROUPS: ReadonlyArray<ReadonlySet<string>> = [
1140
1156
  new Set(['a']),
1141
1157
  new Set(['button']),
1142
1158
  new Set(['form']),
@@ -1146,7 +1162,7 @@ const SKELETON_PATH_FORCE_CLOSE_GROUPS: ReadonlyArray<ReadonlySet<string>> = [
1146
1162
  new Set(['li']),
1147
1163
  ]
1148
1164
 
1149
- function skeletonForceCloseGroup(tag: string): number {
1165
+ export function skeletonForceCloseGroup(tag: string): number {
1150
1166
  return SKELETON_PATH_FORCE_CLOSE_GROUPS.findIndex(group => group.has(tag))
1151
1167
  }
1152
1168
 
@@ -1204,7 +1220,7 @@ function walkSkeletonPathNode(
1204
1220
  }
1205
1221
 
1206
1222
  /** True if `children` (already flattened) contains anything the HTML parser would foster-parent out of a `<tr>`. */
1207
- function hasForeignTableRowContent(children: readonly IRNode[]): boolean {
1223
+ export function hasForeignTableRowContent(children: readonly IRNode[]): boolean {
1208
1224
  for (const child of children) {
1209
1225
  if (child.type === 'text') {
1210
1226
  if (child.value.trim() !== '') return true
@@ -1218,7 +1234,7 @@ function hasForeignTableRowContent(children: readonly IRNode[]): boolean {
1218
1234
  }
1219
1235
 
1220
1236
  /** Splice fragment children inline — a fragment contributes no DOM node of its own. */
1221
- function flattenSkeletonChildren(children: readonly IRNode[]): IRNode[] {
1237
+ export function flattenSkeletonChildren(children: readonly IRNode[]): IRNode[] {
1222
1238
  const out: IRNode[] = []
1223
1239
  for (const child of children) {
1224
1240
  if (child.type === 'fragment') {
@@ -2322,7 +2338,11 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2322
2338
  case 'expression':
2323
2339
  if (node.expr === 'null' || node.expr === 'undefined') return ''
2324
2340
  if (node.clientOnly && node.slotId) {
2325
- return `<!--bf-client:${node.slotId}--><!--/-->`
2341
+ // Ordinary claimed 'text' slot pair (slot unification A3) — matches
2342
+ // the SSR adapters' `renderExpression` byte-for-byte (byte parity):
2343
+ // empty at CSR mount too, since the expression is evaluated only
2344
+ // once init's createEffect runs, same as the SSR case.
2345
+ return `<!--bf:${node.slotId}--><!--/-->`
2326
2346
  }
2327
2347
  {
2328
2348
  const transformed = transformExpr(node.expr, node.templateExpr)
@@ -8,13 +8,22 @@ import { isClientBuiltinName } from '../builtins.ts'
8
8
  // All exports from @barefootjs/client/runtime that may be used in generated code
9
9
  export const RUNTIME_IMPORT_CANDIDATES = [
10
10
  'createSignal', 'createMemo', 'createEffect', 'onCleanup', 'onMount',
11
- 'hydrate', 'insert', 'reconcileElements', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'mapArrayAnchored', 'patchLeaf', 'patchSlotRange', 'createDisposableEffect',
12
- 'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild', 'updateClientMarker',
11
+ 'hydrate', 'insert', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'mapArrayAnchored', 'mapArrayLazy', 'patchLeaf', 'createDisposableEffect',
12
+ 'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild',
13
13
  'createPortal',
14
14
  'provideContext', 'createContext', 'useContext',
15
- 'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss', 'escapeAttr', 'escapeText',
15
+ 'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss', 'escapeAttr', 'escapeText', 'escapeTextOrNode',
16
16
  'qsa', 'qsaItem', 'qsaChildScope', 'qsaChildScopes', 'upsertChildItem', '__slot', '__bfSlot', '__bfText',
17
- 'tAfter',
17
+ // Claim-plan interpreter (slot unification A2/A3, spec/slot-unification.md)
18
+ // — the "one claim mechanism" that replaced `patchSlotRange` and
19
+ // `updateClientMarker` (both deleted) as the content-slot update door.
20
+ // `lazyClaimSlots` is the read-capable twin of `lazySlots` over the same
21
+ // claim — emitted only by lazy loops that seed an outer-involving TEXT
22
+ // binding by read-compare-write (§9.3(1)).
23
+ // `textOrNode` is the 'text' door's Node guard: a child-position value that
24
+ // turns out to be a live Node must reach the writer as a Node so the claim
25
+ // can promote to 'markup', never as `String(node)`.
26
+ 'claimSlots', 'lazySlots', 'lazyClaimSlots', 'textOrNode',
18
27
  // Profile mode (#1690, SR3) — turn-boundary markers around event handlers.
19
28
  'beginTurn', 'endTurn',
20
29
  // Catalogued `Date` lowering (#2274/#2292) — the client counterpart to
@@ -571,8 +571,9 @@ export function collectLoopChildReactiveTexts(
571
571
  expression: ({ node: n, scope: insideConditional }) => {
572
572
  if (!n.slotId) return
573
573
  // #2389 — a preamble-patched region (see `IRLoop.preambleRegions`) has
574
- // its own `patchSlotRange`-based effect (`preambleRegions` in the
575
- // client-JS loop plan); it must never ALSO become a `reactiveTexts`
574
+ // its own claimed 'markup'-slot effect (`preambleRegions` in the
575
+ // client-JS loop plan; slot unification A3, `@barefootjs/client/
576
+ // runtime/claim-slots.ts`); it must never ALSO become a `reactiveTexts`
576
577
  // entry, which would patch it via `.textContent` and corrupt markup
577
578
  // (a `joinArrayChild` region's value is raw HTML, not text).
578
579
  if (n.preambleRegion) return
@@ -255,7 +255,7 @@ export interface LoopCore {
255
255
  /**
256
256
  * Unique id for this loop's `<!--bf-loop:<id>--> ... <!--bf-/loop:<id>-->`
257
257
  * marker pair, threaded from `IRLoop.markerId`. Each `mapArray()` /
258
- * `reconcileElements()` call passes this id so sibling loops under the
258
+ * `mapArrayAnchored()` call passes this id so sibling loops under the
259
259
  * same parent reconcile their own range (#1087).
260
260
  */
261
261
  markerId: string
@@ -609,7 +609,7 @@ export interface TopLevelLoop extends LoopCore {
609
609
  // Per-item bindings (events / reactiveAttrs / reactiveTexts / refs / conditionals)
610
610
  // now live on `LoopCore.bindings` — see issue #1244 §B.
611
611
  isStaticArray: boolean // True if array is a static prop (not a signal)
612
- useElementReconciliation?: boolean // True: reconcileElements + composite rendering (native root with child components)
612
+ useElementReconciliation?: boolean // True: mapArray/mapArrayAnchored + composite rendering (native root with child components)
613
613
  /** Inner loop metadata for composite element reconciliation (array, param, key, container) */
614
614
  innerLoops?: NestedLoop[]
615
615
  /** Offset of this loop's items past its preceding container siblings (#1693). */
@@ -654,6 +654,14 @@ export interface ReactiveAttribute extends AttrMeta {
654
654
  export interface ClientOnlyElement {
655
655
  slotId: string
656
656
  expression: string
657
+ /**
658
+ * Slot unification Step B (`spec/slot-unification.md` §3(b), §5 Step B):
659
+ * copied from `IRExpression.elidedPath` (`client-only-elision.ts`) when
660
+ * the compiler proved this slot's marker pair can be elided. Absent
661
+ * (`undefined`) is always safe — the claim plan falls back to `path: []`
662
+ * / marker-based resolution exactly as before Step B.
663
+ */
664
+ elidedPath?: readonly number[]
657
665
  }
658
666
 
659
667
 
@@ -180,7 +180,7 @@ export function applyObjectIterationWrap(
180
180
  }
181
181
 
182
182
  /**
183
- * Build the chained array expression for reconcileList. Thin
183
+ * Build the chained array expression for mapArray/mapArrayAnchored. Thin
184
184
  * adapter over `buildLoopChainExpr` that unpacks the collected
185
185
  * `TopLevelLoop` / `BranchLoop` shape into the primitive inputs.
186
186
  * Branch loops carry the same `filterPredicate` / `sortComparator`
package/src/jsx-to-ir.ts CHANGED
@@ -2707,7 +2707,24 @@ function transformConditionalBranch(
2707
2707
  const callsReactive = exprCallsReactiveGetters(node, ctx)
2708
2708
  const hasCalls = exprHasFunctionCalls(node)
2709
2709
  const reactive = isReactiveExpression(exprText, ctx, node) || isReactiveOrigin(branchOrigin)
2710
- const needsSlot = reactive || callsReactive
2710
+ // A branch whose entire value is a bare loop-item read (`row.label`) sets
2711
+ // neither `reactive` nor `callsReactive`: `render-item` is deliberately
2712
+ // excluded from `REACTIVE_BINDING_KINDS` (types.ts) because per-item
2713
+ // reactivity flows through the loop's own per-item signal accessor, not
2714
+ // this origin-based classification, and there is no call to trip
2715
+ // `callsReactive`/`hasCalls` either. Without a slotId here, a keyed loop
2716
+ // row that changes value without its condition flipping has nothing for
2717
+ // `collectLoopChildReactiveTexts` (ir-to-client-js/reactivity.ts) to
2718
+ // attach an update effect to, and the branch is frozen at its
2719
+ // mount-time value forever (the loop-branch-stale-text defect). Read the
2720
+ // freeRefs this function already computed above — no new parse, and no
2721
+ // regex re-scan of `exprText` (contrast the legacy `referencesLoopParam`
2722
+ // used by the sibling `transformConditional`/`transformLogicalAnd`
2723
+ // condition-side decisions, which token-matches the loop param name
2724
+ // against expression TEXT and can false-match inside an unrelated string
2725
+ // literal branch like `"this row is empty"`).
2726
+ const refsLoopParam = branchOrigin.freeRefs?.some(r => r.kind === 'render-item') ?? false
2727
+ const needsSlot = reactive || callsReactive || refsLoopParam
2711
2728
  const slotId = needsSlot ? generateSlotId(ctx) : null
2712
2729
  return {
2713
2730
  type: 'expression',
@@ -4610,8 +4627,8 @@ function transformMapCall(
4610
4627
  const bodyIsMultiRoot = loopBodyIsMultiRoot(children)
4611
4628
 
4612
4629
  // Determine if array is static (prop) or dynamic (signal/memo).
4613
- // Static arrays don't need reconcileList — SSR elements are hydrated
4614
- // directly. Signal / memo arrays need reconcileList for dynamic DOM
4630
+ // Static arrays don't need mapArray/mapArrayAnchored — SSR elements are
4631
+ // hydrated directly. Signal / memo arrays need mapArray for dynamic DOM
4615
4632
  // updates.
4616
4633
  //
4617
4634
  // Solid-style wrap-by-default fallback (#943, follow-up to
@@ -4621,7 +4638,7 @@ function transformMapCall(
4621
4638
  // where `getItems` is an imported helper previously silent-dropped
4622
4639
  // into the static-render path, freezing the SSR-time list on the
4623
4640
  // client. Over-reconciling an array that happens to contain a pure
4624
- // call costs one extra `reconcileList` per loop; under-reconciling
4641
+ // call costs one extra `mapArray` per loop; under-reconciling
4625
4642
  // is the silent-drop bug this closes.
4626
4643
  //
4627
4644
  // Destructured map params (`([, cfg]) => ...`, `({ a, b }) => ...`,
@@ -4660,8 +4677,9 @@ function transformMapCall(
4660
4677
 
4661
4678
  // Collect nested components for both static and dynamic arrays.
4662
4679
  // Static arrays: needed for initChild hydration.
4663
- // Dynamic arrays with native root + component descendants: enables reconcileElements
4664
- // with composite rendering (placeholder + createComponent replacement).
4680
+ // Dynamic arrays with native root + component descendants: enables
4681
+ // mapArray/mapArrayAnchored with composite rendering (placeholder +
4682
+ // createComponent replacement).
4665
4683
  const nestedComponents = collectNestedComponents(children).filter(c => c.name !== childComponent?.name)
4666
4684
 
4667
4685
  return {
@@ -6564,7 +6582,7 @@ function isArrayExprDirectPropRef(arrayExpr: ts.Expression, ctx: TransformContex
6564
6582
 
6565
6583
  /**
6566
6584
  * Check if array expression is a signal or memo getter call.
6567
- * Used to determine if a loop needs reconcileList for dynamic DOM updates.
6585
+ * Used to determine if a loop needs mapArray for dynamic DOM updates.
6568
6586
  * Props and local constants are considered static (don't change at runtime).
6569
6587
  */
6570
6588
  function isSignalOrMemoArray(array: string, ctx: TransformContext): boolean {
@@ -6767,7 +6785,7 @@ function attrValueReactivityProbe(value: AttrValue): string | null {
6767
6785
 
6768
6786
  /**
6769
6787
  * Propagate slotId to loop children that need it.
6770
- * Loops need to use their parent element's slotId for reconcileList.
6788
+ * Loops need to use their parent element's slotId for mapArray.
6771
6789
  * This handles loops directly in children or nested in fragments.
6772
6790
  */
6773
6791
  function propagateSlotIdToLoops(children: IRNode[], slotId: string): void {
package/src/types.ts CHANGED
@@ -386,7 +386,8 @@ export interface IRExpression {
386
386
  * intersect the enclosing loop's `preamble.declaredNames`. Excludes the
387
387
  * node from `collectLoopChildReactiveTexts` (which patches via
388
388
  * `.textContent`, wrong for markup) regardless of `reactive` / `slotId` —
389
- * the region-patch effect (`patchSlotRange`) is its only wiring.
389
+ * the region-patch effect (a claimed 'markup' slot writer, slot
390
+ * unification A3) is its only wiring.
390
391
  */
391
392
  preambleRegion?: boolean
392
393
  /** When true, expression calls signal getters or memos (has reactive `foo()` pattern). */
@@ -400,6 +401,28 @@ export interface IRExpression {
400
401
  * source of truth for free-reference / reactivity classification.
401
402
  */
402
403
  origin: OriginInfo
404
+ /**
405
+ * Slot unification Step B (`spec/slot-unification.md` §3(b), §5 Step B):
406
+ * true when this slot's `<!--bf:sN-->…<!--/-->` marker pair can be safely
407
+ * omitted from BOTH SSR and CSR output, because `elidedPath` already gives
408
+ * every claimer a real compile-time DOM path to the slot's position.
409
+ * Decided EXACTLY ONCE, by `client-only-elision.ts`, before either
410
+ * `adapter.generate()` (SSR) or `generateClientJs()` (CSR) run — every one
411
+ * of the nine SSR adapters' `renderExpression` and the CSR template
412
+ * emitters (`html-template.ts`) read this single flag and must never
413
+ * re-derive their own elision decision. Unset/false is always safe
414
+ * (keeps markers, today's behavior); only the compiler pass above may
415
+ * ever set it true.
416
+ */
417
+ markerless?: boolean
418
+ /**
419
+ * Root-relative child-index path to this slot's position (parent index
420
+ * chain, LAST element = the index of the slot itself within its parent's
421
+ * `childNodes`). Valid, and required, only when `markerless` is true —
422
+ * see `SlotSpec.markerless` in `@barefootjs/client/runtime/claim-slots.ts`
423
+ * for the claim-time resolve-or-create semantics this path feeds.
424
+ */
425
+ elidedPath?: readonly number[]
403
426
  }
404
427
 
405
428
  export interface IRConditional {
@@ -723,7 +746,7 @@ export interface IRLoop {
723
746
  * `slotId` is emitted as the usual `<!--bf:sN-->...<!--/-->` marker pair
724
747
  * (same door as a reactive text slot — `irToHtmlTemplate` / Hono's
725
748
  * `renderExpression` key off `slotId` alone), but the CLIENT wiring is a
726
- * distinct `patchSlotRange`-based region-patch effect (`preambleRegions`
749
+ * distinct claimed 'markup'-slot region-patch effect (`preambleRegions`
727
750
  * in the client-JS loop plan), NOT a `reactiveTexts` entry — patching via
728
751
  * `.textContent` would escape markup that a `joinArrayChild` region must
729
752
  * render raw. `collectLoopChildReactiveTexts` excludes any node collected