@barefootjs/jsx 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.
Files changed (99) 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 +735 -140
  5. package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/client-only-elision.d.ts +103 -0
  7. package/dist/ir-to-client-js/client-only-elision.d.ts.map +1 -0
  8. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +12 -1
  10. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -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-event-delegation.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +16 -1
  15. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +27 -0
  17. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/shared.d.ts +13 -2
  19. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/stringify/claim-plan.d.ts +54 -0
  21. package/dist/ir-to-client-js/control-flow/stringify/claim-plan.d.ts.map +1 -0
  22. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/stringify/insert.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +19 -3
  27. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +63 -4
  29. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/control-flow.d.ts +1 -1
  31. package/dist/ir-to-client-js/element-refs.d.ts +8 -3
  32. package/dist/ir-to-client-js/element-refs.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/emit-reactive.d.ts +8 -1
  34. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/html-template.d.ts +73 -1
  36. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  37. package/dist/ir-to-client-js/imports.d.ts +2 -2
  38. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  39. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  40. package/dist/ir-to-client-js/types.d.ts +35 -2
  41. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/utils.d.ts +1 -1
  43. package/dist/jsx-to-ir.d.ts.map +1 -1
  44. package/dist/types.d.ts +70 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +2 -2
  47. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +555 -678
  48. package/src/__tests__/child-components-in-map.test.ts +4 -4
  49. package/src/__tests__/client-js-generation.test.ts +26 -12
  50. package/src/__tests__/compiler-runtime-contract.test.ts +4 -4
  51. package/src/__tests__/composite-branch-loop.test.ts +2 -2
  52. package/src/__tests__/cross-file-client-signal.test.ts +5 -1
  53. package/src/__tests__/delegated-handler-preamble.test.ts +153 -0
  54. package/src/__tests__/early-return-scope-var-ref.test.ts +10 -11
  55. package/src/__tests__/flatmap-segments.test.ts +182 -0
  56. package/src/__tests__/inline-jsx-callback.test.ts +6 -4
  57. package/src/__tests__/ir-jsx-props.test.ts +6 -5
  58. package/src/__tests__/loop-fallback-wrap.test.ts +3 -4
  59. package/src/__tests__/map-body-no-silent-divergence.test.ts +45 -0
  60. package/src/__tests__/module-level-signal.audit.test.ts +7 -2
  61. package/src/__tests__/nested-loop-conditional.test.ts +9 -7
  62. package/src/__tests__/nested-loop-reactive-attrs.test.ts +2 -2
  63. package/src/__tests__/preamble-region-patch.test.ts +156 -0
  64. package/src/__tests__/profile-cond-binding-ids.test.ts +3 -2
  65. package/src/__tests__/profile-loop-binding-ids.test.ts +4 -3
  66. package/src/__tests__/profile-nested-binding-ids.test.ts +11 -6
  67. package/src/__tests__/reactive-attrs-in-map.test.ts +6 -4
  68. package/src/__tests__/static-loop-csr-materialize.test.ts +3 -2
  69. package/src/__tests__/template-closure.test.ts +1 -1
  70. package/src/compiler.ts +11 -0
  71. package/src/index.ts +1 -0
  72. package/src/ir-to-client-js/build-references.ts +16 -0
  73. package/src/ir-to-client-js/client-only-elision.ts +273 -0
  74. package/src/ir-to-client-js/collect-elements.ts +74 -14
  75. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +12 -1
  76. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +11 -3
  77. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +19 -3
  78. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +36 -0
  79. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +16 -1
  80. package/src/ir-to-client-js/control-flow/plan/loop.ts +28 -0
  81. package/src/ir-to-client-js/control-flow/shared.ts +27 -2
  82. package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +34 -4
  83. package/src/ir-to-client-js/control-flow/stringify/claim-plan.ts +68 -0
  84. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +67 -19
  85. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +18 -9
  86. package/src/ir-to-client-js/control-flow/stringify/insert.ts +17 -11
  87. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +35 -16
  88. package/src/ir-to-client-js/control-flow/stringify/loop.ts +80 -14
  89. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +241 -31
  90. package/src/ir-to-client-js/control-flow.ts +1 -1
  91. package/src/ir-to-client-js/element-refs.ts +9 -11
  92. package/src/ir-to-client-js/emit-reactive.ts +50 -14
  93. package/src/ir-to-client-js/html-template.ts +144 -15
  94. package/src/ir-to-client-js/imports.ts +7 -4
  95. package/src/ir-to-client-js/reactivity.ts +7 -0
  96. package/src/ir-to-client-js/types.ts +34 -2
  97. package/src/ir-to-client-js/utils.ts +1 -1
  98. package/src/jsx-to-ir.ts +382 -15
  99. package/src/types.ts +72 -0
package/src/jsx-to-ir.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  type FlatMapCallback,
29
29
  type MapCallbackPreamble,
30
30
  type PreambleSegment,
31
+ type PreambleRegionSource,
31
32
  tsxSourceText,
32
33
  type SourceLocation,
33
34
  type TypeInfo,
@@ -3637,6 +3638,99 @@ function checkLoopKey(
3637
3638
  * item; multi-root bodies (e.g. `<><path/><path/></>`) need per-item
3638
3639
  * boundary markers and multi-root template cloning (#1212).
3639
3640
  */
3641
+ /**
3642
+ * Recognize a flatMap PROJECTION body: a bare map-like call
3643
+ * (`it.tags.map(...)` / nested `.flatMap(...)`) as the whole body — either
3644
+ * an expression body (parenthesized or not) or a block whose ONLY statement
3645
+ * is `return <call>`. Anything carrying additional statements does not
3646
+ * qualify (it rides the structured-segments carrier instead). Returns the
3647
+ * call expression to lower as the loop's nested-loop child, or null.
3648
+ */
3649
+ function flatMapProjectionCall(body: ts.ConciseBody): ts.CallExpression | null {
3650
+ let expr: ts.Expression | undefined
3651
+ if (ts.isBlock(body)) {
3652
+ const real = body.statements
3653
+ if (real.length !== 1 || !ts.isReturnStatement(real[0]) || !real[0].expression) return null
3654
+ expr = real[0].expression
3655
+ } else {
3656
+ expr = body
3657
+ }
3658
+ while (ts.isParenthesizedExpression(expr)) expr = expr.expression
3659
+ if (!ts.isCallExpression(expr)) return null
3660
+ if (!getMapLikeMethod(expr)) return null
3661
+ // The descriptor synthesis renders the inner callback's raw params into
3662
+ // the client accessor (`.map((tag, i) => ({ k, h }))`), which is only
3663
+ // sound for plain-identifier params — a destructure pattern rides the
3664
+ // segments carrier instead.
3665
+ const cb = expr.arguments[0]
3666
+ if (!cb || (!ts.isArrowFunction(cb) && !ts.isFunctionExpression(cb))) return null
3667
+ for (const p of cb.parameters) {
3668
+ if (!ts.isIdentifier(p.name)) return null
3669
+ }
3670
+ // The client descriptor is one element per flattened leaf, so the inner
3671
+ // callback must produce a single element root (a JSX element, or a ternary
3672
+ // whose branches are elements). Fragments / array literals ride the
3673
+ // segments carrier.
3674
+ let innerBody: ts.Node = cb.body
3675
+ if (ts.isBlock(innerBody)) {
3676
+ const ret = innerBody.statements.find(
3677
+ (s): s is ts.ReturnStatement => ts.isReturnStatement(s) && s.expression != null,
3678
+ )
3679
+ if (innerBody.statements.length !== 1 || !ret?.expression) return null
3680
+ innerBody = ret.expression
3681
+ }
3682
+ while (ts.isParenthesizedExpression(innerBody)) innerBody = innerBody.expression
3683
+ const isElementish = (n: ts.Node): boolean => {
3684
+ let m = n
3685
+ while (ts.isParenthesizedExpression(m)) m = m.expression
3686
+ if (ts.isJsxElement(m) || ts.isJsxSelfClosingElement(m)) return leafIsWirelessElement(m)
3687
+ if (ts.isConditionalExpression(m)) return isElementish(m.whenTrue) && isElementish(m.whenFalse)
3688
+ return false
3689
+ }
3690
+ if (!isElementish(innerBody)) return null
3691
+ return expr
3692
+ }
3693
+
3694
+ /**
3695
+ * Syntactic pre-check that a projection leaf carries no per-element wiring —
3696
+ * no event handlers, spreads, component tags, or nested JSX-bearing loops.
3697
+ * Runs BEFORE the leaf is transformed (the accept/reject decision must not
3698
+ * leave transform side effects — slot ids, collected events — behind when
3699
+ * the shape falls back to the segments carrier, whose own transform would
3700
+ * then double-register them).
3701
+ */
3702
+ function leafIsWirelessElement(el: ts.JsxElement | ts.JsxSelfClosingElement): boolean {
3703
+ let ok = true
3704
+ const visit = (n: ts.Node): void => {
3705
+ if (!ok) return
3706
+ if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
3707
+ // Only a lowercase plain identifier (or a namespaced name like
3708
+ // `svg:path`) is an intrinsic element. A member/this tag
3709
+ // (`<icons.Tag/>`, `<this.Tag/>`) is a component per JSX semantics
3710
+ // regardless of case — the case test alone would let it through.
3711
+ const tagNode = n.tagName
3712
+ const isIntrinsic = ts.isIdentifier(tagNode)
3713
+ ? !/^[A-Z]/.test(tagNode.text)
3714
+ : ts.isJsxNamespacedName(tagNode)
3715
+ if (!isIntrinsic) { ok = false; return }
3716
+ for (const attr of n.attributes.properties) {
3717
+ if (ts.isJsxSpreadAttribute(attr)) { ok = false; return }
3718
+ if (ts.isJsxAttribute(attr)) {
3719
+ const name = attr.name.getText()
3720
+ if (/^on[A-Z]/.test(name)) { ok = false; return }
3721
+ }
3722
+ }
3723
+ }
3724
+ if (ts.isCallExpression(n) && getMapLikeMethod(n) && containsJsxInExpression(n)) {
3725
+ ok = false
3726
+ return
3727
+ }
3728
+ ts.forEachChild(n, visit)
3729
+ }
3730
+ visit(el)
3731
+ return ok
3732
+ }
3733
+
3640
3734
  function loopBodyIsMultiRoot(children: IRNode[]): boolean {
3641
3735
  const real = children.filter(
3642
3736
  (c) => !(c.type === 'text' && typeof c.value === 'string' && !c.value.trim())
@@ -3720,6 +3814,10 @@ function transformMapCall(
3720
3814
  // Capture nesting depth before we register this map's own params.
3721
3815
  // ctx.loopParams is populated by the *outer* map; if non-empty we are inside one.
3722
3816
  const isNested = ctx.loopParams.size > 0
3817
+ // Diagnostic count at entry — the structural net at the scalar fallthrough
3818
+ // de-dups against refusals fired DURING this call (leaf-wiring, DSL gates),
3819
+ // never against unrelated diagnostics recorded before it.
3820
+ const diagCountAtEntry = ctx.analyzer.errors.length
3723
3821
  // This loop's own depth (0 = outermost) is however many enclosing
3724
3822
  // loops are already active, captured before `ctx.loopDepth` below is
3725
3823
  // bumped for THIS loop's own descendants.
@@ -4271,14 +4369,85 @@ function transformMapCall(
4271
4369
  }
4272
4370
 
4273
4371
  // flatMap block body fallback: compile JSX inline when children
4274
- // couldn't be extracted via the standard single-return path.
4275
- if (method === 'flatMap' && children.length === 0) {
4372
+ // couldn't be extracted via the standard single-return path. A pure
4373
+ // single-`return <call>` projection is NOT taken here — it lowers to
4374
+ // neutral IR below (nested-loop child), which DSL adapters templatize.
4375
+ if (method === 'flatMap' && children.length === 0 && !flatMapProjectionCall(body)) {
4276
4376
  flatMapCallback = buildFlatMapCallback(callback, body, ctx)
4277
4377
  }
4278
4378
  } else {
4279
4379
  tryTransformRenderableBody(body)
4280
4380
  }
4281
4381
 
4382
+ // flatMap PROJECTION body — the canonical `flatMap(it => it.tags.map(tag
4383
+ // => <li key={...}/>))`, as an expression body or a single-`return` block
4384
+ // (parenthesized or not). This is a pure nested-loop projection, so it
4385
+ // lowers to NEUTRAL IR — an inner IRLoop as the loop's only child — which
4386
+ // every SSR adapter can templatize (nested `{{range}}` on DSL backends),
4387
+ // per spec/callback-fidelity.md's fidelity table ("single expr" row) and
4388
+ // the array-literal flatMap precedent. Only bodies carrying STATEMENTS
4389
+ // (early returns, consts) fall through to the segments carrier below and
4390
+ // its DSL gate. The client reconciles the flattened leaves through the
4391
+ // descriptor mapArray path, synthesized from this same neutral IR
4392
+ // (`renderFlatMapProjectionClientBody`); the leaf `key` renders as the
4393
+ // usual nested-loop `data-key-1` on both the SSR and client string
4394
+ // sides, and the flattened reconciliation identity (`data-key`) is
4395
+ // stamped by mapArray from the inner loop's `key` field.
4396
+ if (method === 'flatMap' && children.length === 0 && !flatMapCallback) {
4397
+ const projection = flatMapProjectionCall(body)
4398
+ if (projection) {
4399
+ const transformed = transformJsxExpression(projection, ctx, isClientOnly)
4400
+ if (transformed && transformed.type === 'loop') {
4401
+ children = [transformed]
4402
+ }
4403
+ }
4404
+ }
4405
+
4406
+ // flatMap EXPRESSION body containing JSX in a non-projection shape —
4407
+ // e.g. a call wrapping JSX. The block-body form already rides the
4408
+ // structured-segments carrier in the isBlock branch above; an unbraced
4409
+ // body is the same shape minus the braces, so route it through the same
4410
+ // door. Without this the dispatch falls to the IRExpression scalar path,
4411
+ // which splices the raw callback — JSX included — verbatim into the
4412
+ // client bundle: a silent SyntaxError that kills the whole component's
4413
+ // hydration.
4414
+ if (method === 'flatMap' && children.length === 0 && !flatMapCallback && !ts.isBlock(body)) {
4415
+ flatMapCallback = buildFlatMapCallback(callback, body, ctx)
4416
+ }
4417
+
4418
+ // A flatMapCallback carries the WHOLE body (statements included) as
4419
+ // structured segments — the generic statements-before-return `preamble`
4420
+ // the block-body path may have collected above is a duplicate carrier.
4421
+ // Worse, it isn't one: spliced into a mapArray renderItem it re-runs the
4422
+ // body's control flow against the wrong binding shape (the audited
4423
+ // `if (t().tags.length > maxTags) return [];` renderItem residue).
4424
+ // The segments carrier is the single door; drop the shadow copy.
4425
+ if (flatMapCallback) preamble = undefined
4426
+
4427
+ // Adapter gate (spec/callback-fidelity.md fidelity model): a flatMap body
4428
+ // carried as structured segments runs verbatim on a JS runtime; a DSL
4429
+ // template runtime cannot execute it at SSR — pre-gate, e.g. the Go
4430
+ // adapter emitted `{{range …}}{{end}}` with an EMPTY body (silent
4431
+ // divergence, the exact failure class the sound-or-loud invariant
4432
+ // forbids). Refuse loudly with the /* @client */ escape, mirroring the
4433
+ // const-preamble and array-builder gates above.
4434
+ if (flatMapCallback && !isClientOnly && !(ctx.analyzer.acceptsCallbackBody?.('flatMap') ?? false)) {
4435
+ ctx.analyzer.errors.push(
4436
+ createError(
4437
+ ErrorCodes.UNSUPPORTED_JSX_PATTERN,
4438
+ getSourceLocation(body, ctx.sourceFile, ctx.filePath),
4439
+ {
4440
+ message:
4441
+ 'A .flatMap() callback body with statements or a nested projection ' +
4442
+ 'cannot be lowered to a template on this backend.',
4443
+ suggestion: {
4444
+ message: 'Add /* @client */ to render this loop on the client only',
4445
+ },
4446
+ }
4447
+ )
4448
+ )
4449
+ }
4450
+
4282
4451
  // Unregister loop params
4283
4452
  if (paramBindings) {
4284
4453
  for (const b of paramBindings) ctx.loopParams.delete(b.name)
@@ -4293,6 +4462,43 @@ function transformMapCall(
4293
4462
  // fall back to treating the entire expression as an IRExpression — unless
4294
4463
  // flatMap already built a compiled callback (flatMapCallback).
4295
4464
  if (children.length === 0 && !flatMapCallback) {
4465
+ // Structural net (sound-or-loud, spec/callback-fidelity.md): a callback
4466
+ // body that carries an INLINE JSX literal but produced no loop lowering
4467
+ // must never fall through to the IRExpression scalar path — `ctx.getJS`
4468
+ // strips types, not JSX, so the raw `<li …>` would splice verbatim into
4469
+ // the emitted client bundle as a silent SyntaxError. Any recognizer gap
4470
+ // for a JSX-bearing shape becomes a loud diagnostic here instead of a
4471
+ // leak. (A JSX-helper CALL — `map(t => renderItem(t))` — carries no
4472
+ // inline JSX literal and legitimately stays on the reactive-text path.)
4473
+ const cb = node.arguments[0]
4474
+ const cbBody = cb && (ts.isArrowFunction(cb) || ts.isFunctionExpression(cb)) ? cb.body : undefined
4475
+ // Entry-count gate: a more specific refusal already fired for THIS call
4476
+ // (e.g. the flatMap leaf-wiring check) — don't stack the generic message
4477
+ // on top of it. Comparing against the count captured at entry (not
4478
+ // `=== 0`) keeps the net armed when an unrelated diagnostic was recorded
4479
+ // earlier in the file — a warning elsewhere must never silence the leak
4480
+ // guard for this callback.
4481
+ if (cbBody && containsJsxInExpression(cbBody) && ctx.analyzer.errors.length === diagCountAtEntry) {
4482
+ ctx.analyzer.errors.push(
4483
+ createError(
4484
+ ErrorCodes.UNSUPPORTED_JSX_PATTERN,
4485
+ getSourceLocation(cbBody, ctx.sourceFile, ctx.filePath),
4486
+ {
4487
+ message:
4488
+ `A .${method}() callback that builds JSX in this shape cannot be ` +
4489
+ 'compiled — the JSX would leak verbatim into the client bundle. ' +
4490
+ 'Recognized bodies: a JSX element/fragment, a ternary or ' +
4491
+ '&& / || / ?? expression, an array literal (flatMap), or a block ' +
4492
+ 'body whose return the compiler can lower.',
4493
+ suggestion: {
4494
+ message:
4495
+ 'Restructure the callback to return the JSX element directly ' +
4496
+ '(or via a block body with a plain `return`).',
4497
+ },
4498
+ }
4499
+ )
4500
+ )
4501
+ }
4296
4502
  return null
4297
4503
  }
4298
4504
 
@@ -4404,8 +4610,8 @@ function transformMapCall(
4404
4610
  const bodyIsMultiRoot = loopBodyIsMultiRoot(children)
4405
4611
 
4406
4612
  // Determine if array is static (prop) or dynamic (signal/memo).
4407
- // Static arrays don't need reconcileList — SSR elements are hydrated
4408
- // directly. Signal / memo arrays need reconcileList for dynamic DOM
4613
+ // Static arrays don't need mapArray/mapArrayAnchored — SSR elements are
4614
+ // hydrated directly. Signal / memo arrays need mapArray for dynamic DOM
4409
4615
  // updates.
4410
4616
  //
4411
4617
  // Solid-style wrap-by-default fallback (#943, follow-up to
@@ -4415,7 +4621,7 @@ function transformMapCall(
4415
4621
  // where `getItems` is an imported helper previously silent-dropped
4416
4622
  // into the static-render path, freezing the SSR-time list on the
4417
4623
  // client. Over-reconciling an array that happens to contain a pure
4418
- // call costs one extra `reconcileList` per loop; under-reconciling
4624
+ // call costs one extra `mapArray` per loop; under-reconciling
4419
4625
  // is the silent-drop bug this closes.
4420
4626
  //
4421
4627
  // Destructured map params (`([, cfg]) => ...`, `({ a, b }) => ...`,
@@ -4441,10 +4647,22 @@ function transformMapCall(
4441
4647
  // already handles correctly.
4442
4648
  && !objectIteration
4443
4649
 
4650
+ // #2389 patch-on-update — a signal-backed (non-static) loop whose body has
4651
+ // a preamble may reference a preamble-declared local directly as a child
4652
+ // expression (`{cells}`, `{stateLabel}`). `mapArray` reuses the same DOM
4653
+ // node on a same-key update, re-running only the wired text/attr slots —
4654
+ // this child has neither, so without a dedicated region-patch effect it
4655
+ // freezes at its mount-time value. Static arrays are never recreated via
4656
+ // signals, so there is nothing to patch; skip them entirely.
4657
+ const preambleRegions = preamble && !isStaticArray
4658
+ ? collectPreambleRegions(children, new Set(preamble.declaredNames), ctx)
4659
+ : undefined
4660
+
4444
4661
  // Collect nested components for both static and dynamic arrays.
4445
4662
  // Static arrays: needed for initChild hydration.
4446
- // Dynamic arrays with native root + component descendants: enables reconcileElements
4447
- // with composite rendering (placeholder + createComponent replacement).
4663
+ // Dynamic arrays with native root + component descendants: enables
4664
+ // mapArray/mapArrayAnchored with composite rendering (placeholder +
4665
+ // createComponent replacement).
4448
4666
  const nestedComponents = collectNestedComponents(children).filter(c => c.name !== childComponent?.name)
4449
4667
 
4450
4668
  return {
@@ -4483,6 +4701,7 @@ function transformMapCall(
4483
4701
  depth,
4484
4702
  clientOnly: isClientOnly || undefined,
4485
4703
  preamble,
4704
+ preambleRegions: preambleRegions && preambleRegions.length > 0 ? preambleRegions : undefined,
4486
4705
  paramType,
4487
4706
  indexType,
4488
4707
  paramBindings,
@@ -4527,15 +4746,19 @@ function containsJsx(node: ts.Node): boolean {
4527
4746
  }
4528
4747
 
4529
4748
  /**
4530
- * Build a FlatMapCallback for complex flatMap block bodies (conditional
4531
- * returns, variable-assigned JSX, etc.). Walks the callback body AST and
4532
- * carries it as structured segmentsJS text (types stripped) interleaved
4533
- * with compiled JSX-leaf IR never as a sentinel-bearing string (the
4534
- * write-side rule in CLAUDE.md; same shape as the map-preamble carrier).
4749
+ * Build a FlatMapCallback for complex flatMap bodies (conditional returns,
4750
+ * variable-assigned JSX, etc.). Accepts a block body OR an expression body
4751
+ * (`t => t.tags.map(tag => <li/>)`)segment reconstruction and the SSR
4752
+ * rawBody are position-based, so both shapes flow through identically (a
4753
+ * block's text keeps its braces; an expression's text is a valid arrow body
4754
+ * as-is). Walks the callback body AST and carries it as structured segments
4755
+ * — JS text (types stripped) interleaved with compiled JSX-leaf IR — never
4756
+ * as a sentinel-bearing string (the write-side rule in CLAUDE.md; same shape
4757
+ * as the map-preamble carrier).
4535
4758
  */
4536
4759
  function buildFlatMapCallback(
4537
4760
  callback: ts.ArrowFunction | ts.FunctionExpression,
4538
- body: ts.Block,
4761
+ body: ts.Block | ts.Expression,
4539
4762
  ctx: TransformContext,
4540
4763
  ): FlatMapCallback | undefined {
4541
4764
  if (!containsJsx(body)) return undefined
@@ -4577,6 +4800,66 @@ function buildFlatMapCallback(
4577
4800
  return undefined
4578
4801
  }
4579
4802
 
4803
+ // A leaf that carries wiring the descriptor render path can't honour —
4804
+ // an event handler, a component, a nested loop, or a spread — would be
4805
+ // silently dead DOM on the client (the leaf renders as a keyed HTML
4806
+ // string, patched wholesale on change, with no per-slot wiring). Refuse
4807
+ // loudly (sound-or-loud), mirroring `preambleFragmentNeedsWiring` for map
4808
+ // preambles — but deliberately NOT refusing reactive expressions: whole-
4809
+ // leaf patching covers those.
4810
+ for (const leafIr of leafIrs) {
4811
+ // The descriptor path renders each leaf as ONE keyed element — the
4812
+ // renderItem adopts `template.content.firstElementChild` and `patchLeaf`
4813
+ // patches a single element root. A fragment (or any non-element) root
4814
+ // would silently drop siblings client-side while SSR renders them all —
4815
+ // the exact divergence class this carrier forbids. Refuse loudly.
4816
+ if (leafIr.type !== 'element') {
4817
+ const loc = 'loc' in leafIr && leafIr.loc
4818
+ ? leafIr.loc
4819
+ : getSourceLocation(body, ctx.sourceFile, ctx.filePath)
4820
+ ctx.analyzer.errors.push(
4821
+ createError(
4822
+ ErrorCodes.UNSUPPORTED_JSX_PATTERN,
4823
+ loc,
4824
+ {
4825
+ message:
4826
+ 'A JSX leaf produced by a .flatMap() callback must be a single ' +
4827
+ 'element — a fragment or non-element root cannot ride the keyed ' +
4828
+ 'descriptor path (each leaf hydrates and patches as one element).',
4829
+ suggestion: {
4830
+ message: 'Wrap the leaf content in a single keyed element.',
4831
+ },
4832
+ }
4833
+ )
4834
+ )
4835
+ return undefined
4836
+ }
4837
+ if (flatMapLeafNeedsWiring(leafIr)) {
4838
+ const loc = 'loc' in leafIr && leafIr.loc
4839
+ ? leafIr.loc
4840
+ : getSourceLocation(body, ctx.sourceFile, ctx.filePath)
4841
+ ctx.analyzer.errors.push(
4842
+ createError(
4843
+ ErrorCodes.UNSUPPORTED_JSX_PATTERN,
4844
+ loc,
4845
+ {
4846
+ message:
4847
+ 'A JSX element produced by a .flatMap() callback cannot carry ' +
4848
+ 'event handlers, components, nested loops, or spreads — the ' +
4849
+ 'leaf renders as a keyed HTML string with no per-element wiring.',
4850
+ suggestion: {
4851
+ message:
4852
+ 'Restructure so the interactive element lives in a .map() body — ' +
4853
+ 'the descriptor path has no per-element wiring on any backend, ' +
4854
+ 'so /* @client */ does not lift this.',
4855
+ },
4856
+ }
4857
+ )
4858
+ )
4859
+ return undefined
4860
+ }
4861
+ }
4862
+
4580
4863
  const pieces = reconstructAsSegments(body, ctx.sourceFile, ctx.analyzer.typeExcludeRanges, leafSpans)
4581
4864
  const segments: PreambleSegment[] = pieces.map((piece) => {
4582
4865
  if ('marker' in piece) return { kind: 'jsx', ir: leafIrs[piece.marker] }
@@ -4610,6 +4893,35 @@ interface PreambleCollection {
4610
4893
  refusalNode?: ts.Node
4611
4894
  }
4612
4895
 
4896
+ /**
4897
+ * Does a flatMap segment leaf carry wiring the descriptor render path can't
4898
+ * honour? Narrower than {@link preambleFragmentNeedsWiring}: reactive
4899
+ * expressions are ALLOWED (the client renders each leaf as a keyed
4900
+ * `{ k, h }` descriptor and patches the whole leaf when its HTML changes),
4901
+ * but events, components, nested loops, and spreads have no wiring on that
4902
+ * path and would be silently dead.
4903
+ */
4904
+ function flatMapLeafNeedsWiring(ir: IRNode): boolean {
4905
+ switch (ir.type) {
4906
+ case 'component':
4907
+ case 'loop':
4908
+ return true
4909
+ case 'element':
4910
+ if (ir.events.length > 0) return true
4911
+ if (ir.attrs.some((a) => a.name.startsWith('...'))) return true
4912
+ return ir.children.some(flatMapLeafNeedsWiring)
4913
+ case 'conditional':
4914
+ return (
4915
+ flatMapLeafNeedsWiring(ir.whenTrue) ||
4916
+ (ir.whenFalse ? flatMapLeafNeedsWiring(ir.whenFalse) : false)
4917
+ )
4918
+ case 'fragment':
4919
+ return ir.children.some(flatMapLeafNeedsWiring)
4920
+ default:
4921
+ return false
4922
+ }
4923
+ }
4924
+
4613
4925
  /**
4614
4926
  * Does a compiled preamble JSX leaf carry wiring the verbatim render path can't
4615
4927
  * honour? The path builds each leaf once as an interpolated HTML string with no
@@ -4677,6 +4989,61 @@ function flagArrayChildExpressions(nodes: IRNode[], declared: ReadonlySet<string
4677
4989
  }
4678
4990
  }
4679
4991
 
4992
+ /**
4993
+ * #2389 patch-on-update — walk loop-body children and classify each
4994
+ * `IRExpression` whose free identifiers intersect `declared` (the enclosing
4995
+ * `.map()` preamble's `declaredNames`) as a preamble-patched region. Unlike
4996
+ * {@link flagArrayChildExpressions} (which only recognizes the exact bare
4997
+ * `{out}` shape for the array-join coercion), this is deliberately broader —
4998
+ * ANY expression reading a preamble local needs the region-patch effect,
4999
+ * whether it's a builder array (`{cells}`, `joinArrayChild` already set by
5000
+ * `flagArrayChildExpressions`, called earlier for the same `children`) or a
5001
+ * plain value (`{stateLabel}`).
5002
+ *
5003
+ * Reuses an existing `slotId` when the node already has one (e.g. it also
5004
+ * reads the loop param and so already qualified via the ordinary reactive-
5005
+ * text path) rather than allocating a second slot — `node.preambleRegion`
5006
+ * is the single source of truth downstream, so `collectLoopChildReactiveTexts`
5007
+ * can unconditionally defer to it.
5008
+ */
5009
+ function collectPreambleRegions(
5010
+ nodes: IRNode[],
5011
+ declared: ReadonlySet<string>,
5012
+ ctx: TransformContext,
5013
+ ): PreambleRegionSource[] {
5014
+ const regions: PreambleRegionSource[] = []
5015
+ const visit = (list: IRNode[]): void => {
5016
+ for (const node of list) {
5017
+ switch (node.type) {
5018
+ case 'expression': {
5019
+ const refs = extractFreeIdentifiersFromText(node.expr)
5020
+ const usesPreambleLocal = [...refs].some((r) => declared.has(r))
5021
+ if (usesPreambleLocal) {
5022
+ if (!node.slotId) node.slotId = generateSlotId(ctx)
5023
+ node.preambleRegion = true
5024
+ node.reactive = true
5025
+ regions.push({
5026
+ slotId: node.slotId,
5027
+ expr: node.expr,
5028
+ joinArrayChild: node.joinArrayChild || undefined,
5029
+ })
5030
+ }
5031
+ break
5032
+ }
5033
+ case 'element':
5034
+ case 'fragment':
5035
+ visit(node.children)
5036
+ break
5037
+ case 'conditional':
5038
+ visit([node.whenTrue, ...(node.whenFalse ? [node.whenFalse] : [])])
5039
+ break
5040
+ }
5041
+ }
5042
+ }
5043
+ visit(nodes)
5044
+ return regions
5045
+ }
5046
+
4680
5047
  /**
4681
5048
  * Collect the names a binding introduces, recursing through object/array
4682
5049
  * destructuring patterns (`const { id: k } = r`, `const [k, ...rest] = xs`) so
@@ -6198,7 +6565,7 @@ function isArrayExprDirectPropRef(arrayExpr: ts.Expression, ctx: TransformContex
6198
6565
 
6199
6566
  /**
6200
6567
  * Check if array expression is a signal or memo getter call.
6201
- * Used to determine if a loop needs reconcileList for dynamic DOM updates.
6568
+ * Used to determine if a loop needs mapArray for dynamic DOM updates.
6202
6569
  * Props and local constants are considered static (don't change at runtime).
6203
6570
  */
6204
6571
  function isSignalOrMemoArray(array: string, ctx: TransformContext): boolean {
@@ -6401,7 +6768,7 @@ function attrValueReactivityProbe(value: AttrValue): string | null {
6401
6768
 
6402
6769
  /**
6403
6770
  * Propagate slotId to loop children that need it.
6404
- * Loops need to use their parent element's slotId for reconcileList.
6771
+ * Loops need to use their parent element's slotId for mapArray.
6405
6772
  * This handles loops directly in children or nested in fragments.
6406
6773
  */
6407
6774
  function propagateSlotIdToLoops(children: IRNode[], slotId: string): void {
package/src/types.ts CHANGED
@@ -380,6 +380,16 @@ export interface IRExpression {
380
380
  * flag (their JSX runtime renders an array child natively).
381
381
  */
382
382
  joinArrayChild?: boolean
383
+ /**
384
+ * True when this expression was classified as a loop's preamble-patched
385
+ * region (#2389 — see `IRLoop.preambleRegions`): its free identifiers
386
+ * intersect the enclosing loop's `preamble.declaredNames`. Excludes the
387
+ * node from `collectLoopChildReactiveTexts` (which patches via
388
+ * `.textContent`, wrong for markup) regardless of `reactive` / `slotId` —
389
+ * the region-patch effect (a claimed 'markup' slot writer, slot
390
+ * unification A3) is its only wiring.
391
+ */
392
+ preambleRegion?: boolean
383
393
  /** When true, expression calls signal getters or memos (has reactive `foo()` pattern). */
384
394
  callsReactiveGetters?: boolean
385
395
  /** When true, expression contains function call(s) — any `identifier()` pattern (computed from AST). */
@@ -391,6 +401,28 @@ export interface IRExpression {
391
401
  * source of truth for free-reference / reactivity classification.
392
402
  */
393
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[]
394
426
  }
395
427
 
396
428
  export interface IRConditional {
@@ -702,6 +734,29 @@ export interface IRLoop {
702
734
  */
703
735
  preamble?: MapCallbackPreamble
704
736
 
737
+ /**
738
+ * Expression children of the loop body whose free identifiers intersect
739
+ * `preamble.declaredNames` (#2389, patch-on-update follow-up to the Stage 3
740
+ * root cure) — e.g. `{cells}` in `arr.map(t => { const cells = []; ...;
741
+ * return <tr>{cells}<td>{t.name}</td></tr> })`. `mapArray` reuses the same
742
+ * DOM node on a same-key item update via per-item `setItem`, re-running
743
+ * only the wired text/attr slots — a preamble-derived child has NEITHER
744
+ * (it's a bare interpolation, `joinArrayChild` array-join or otherwise),
745
+ * so without this it freezes at its mount-time value forever. Each entry's
746
+ * `slotId` is emitted as the usual `<!--bf:sN-->...<!--/-->` marker pair
747
+ * (same door as a reactive text slot — `irToHtmlTemplate` / Hono's
748
+ * `renderExpression` key off `slotId` alone), but the CLIENT wiring is a
749
+ * distinct claimed 'markup'-slot region-patch effect (`preambleRegions`
750
+ * in the client-JS loop plan), NOT a `reactiveTexts` entry — patching via
751
+ * `.textContent` would escape markup that a `joinArrayChild` region must
752
+ * render raw. `collectLoopChildReactiveTexts` excludes any node collected
753
+ * here (`IRExpression.preambleRegion`) so a qualifying expression is never
754
+ * double-wired. Populated only when `preamble` is set and the loop is NOT
755
+ * `isStaticArray` — a static array's SSR-rendered items are never
756
+ * recreated via signals, so there is nothing to patch.
757
+ */
758
+ preambleRegions?: PreambleRegionSource[]
759
+
705
760
  /**
706
761
  * When `.map(callback)` destructures its item parameter (array or object
707
762
  * pattern), this captures each destructured binding's name and the
@@ -809,6 +864,23 @@ export interface MapCallbackPreamble {
809
864
  builderNames: string[]
810
865
  }
811
866
 
867
+ /**
868
+ * One loop-body expression child classified as a preamble-patched region
869
+ * (#2389 — see `IRLoop.preambleRegions`). `expr` is the raw (unwrapped)
870
+ * expression text, exactly as carried on the source `IRExpression` node;
871
+ * emitters wrap it with the loop-param accessor at codegen time, matching
872
+ * every other loop-body expression. `joinArrayChild` mirrors the
873
+ * `IRExpression` field of the same name — the client-JS plan builder uses it
874
+ * to decide between the array-join value expression and a plain (escaped)
875
+ * text value, keeping the region's re-render byte-identical to the row
876
+ * template's own `irToHtmlTemplate` rendering of the same node.
877
+ */
878
+ export interface PreambleRegionSource {
879
+ slotId: string
880
+ expr: string
881
+ joinArrayChild?: boolean
882
+ }
883
+
812
884
  /**
813
885
  * The preamble's JS text (js segments only, concatenated), for read-side
814
886
  * analysis — free-identifier scans, reachability edges, rest-misuse checks.