@barefootjs/jsx 0.26.3 → 0.26.4

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 (54) hide show
  1. package/dist/index.js +433 -55
  2. package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
  3. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  4. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +12 -1
  5. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  7. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +16 -1
  10. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +27 -0
  12. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/shared.d.ts +12 -1
  14. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +19 -0
  17. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/html-template.d.ts +49 -1
  19. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/imports.d.ts +2 -2
  21. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/types.d.ts +26 -1
  24. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  25. package/dist/jsx-to-ir.d.ts.map +1 -1
  26. package/dist/types.d.ts +47 -0
  27. package/dist/types.d.ts.map +1 -1
  28. package/package.json +2 -2
  29. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +187 -37
  30. package/src/__tests__/client-js-generation.test.ts +8 -2
  31. package/src/__tests__/compiler-runtime-contract.test.ts +4 -4
  32. package/src/__tests__/delegated-handler-preamble.test.ts +153 -0
  33. package/src/__tests__/flatmap-segments.test.ts +182 -0
  34. package/src/__tests__/map-body-no-silent-divergence.test.ts +45 -0
  35. package/src/__tests__/preamble-region-patch.test.ts +150 -0
  36. package/src/__tests__/static-loop-csr-materialize.test.ts +3 -2
  37. package/src/ir-to-client-js/build-references.ts +16 -0
  38. package/src/ir-to-client-js/collect-elements.ts +66 -10
  39. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +12 -1
  40. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +11 -3
  41. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +19 -3
  42. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +36 -0
  43. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +16 -1
  44. package/src/ir-to-client-js/control-flow/plan/loop.ts +28 -0
  45. package/src/ir-to-client-js/control-flow/shared.ts +26 -1
  46. package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +25 -3
  47. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +67 -19
  48. package/src/ir-to-client-js/control-flow/stringify/loop.ts +65 -1
  49. package/src/ir-to-client-js/html-template.ts +115 -6
  50. package/src/ir-to-client-js/imports.ts +1 -1
  51. package/src/ir-to-client-js/reactivity.ts +6 -0
  52. package/src/ir-to-client-js/types.ts +24 -0
  53. package/src/jsx-to-ir.ts +374 -8
  54. package/src/types.ts +49 -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
 
@@ -4441,6 +4647,17 @@ 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
4663
  // Dynamic arrays with native root + component descendants: enables reconcileElements
@@ -4483,6 +4700,7 @@ function transformMapCall(
4483
4700
  depth,
4484
4701
  clientOnly: isClientOnly || undefined,
4485
4702
  preamble,
4703
+ preambleRegions: preambleRegions && preambleRegions.length > 0 ? preambleRegions : undefined,
4486
4704
  paramType,
4487
4705
  indexType,
4488
4706
  paramBindings,
@@ -4527,15 +4745,19 @@ function containsJsx(node: ts.Node): boolean {
4527
4745
  }
4528
4746
 
4529
4747
  /**
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).
4748
+ * Build a FlatMapCallback for complex flatMap bodies (conditional returns,
4749
+ * variable-assigned JSX, etc.). Accepts a block body OR an expression body
4750
+ * (`t => t.tags.map(tag => <li/>)`)segment reconstruction and the SSR
4751
+ * rawBody are position-based, so both shapes flow through identically (a
4752
+ * block's text keeps its braces; an expression's text is a valid arrow body
4753
+ * as-is). Walks the callback body AST and carries it as structured segments
4754
+ * — JS text (types stripped) interleaved with compiled JSX-leaf IR — never
4755
+ * as a sentinel-bearing string (the write-side rule in CLAUDE.md; same shape
4756
+ * as the map-preamble carrier).
4535
4757
  */
4536
4758
  function buildFlatMapCallback(
4537
4759
  callback: ts.ArrowFunction | ts.FunctionExpression,
4538
- body: ts.Block,
4760
+ body: ts.Block | ts.Expression,
4539
4761
  ctx: TransformContext,
4540
4762
  ): FlatMapCallback | undefined {
4541
4763
  if (!containsJsx(body)) return undefined
@@ -4577,6 +4799,66 @@ function buildFlatMapCallback(
4577
4799
  return undefined
4578
4800
  }
4579
4801
 
4802
+ // A leaf that carries wiring the descriptor render path can't honour —
4803
+ // an event handler, a component, a nested loop, or a spread — would be
4804
+ // silently dead DOM on the client (the leaf renders as a keyed HTML
4805
+ // string, patched wholesale on change, with no per-slot wiring). Refuse
4806
+ // loudly (sound-or-loud), mirroring `preambleFragmentNeedsWiring` for map
4807
+ // preambles — but deliberately NOT refusing reactive expressions: whole-
4808
+ // leaf patching covers those.
4809
+ for (const leafIr of leafIrs) {
4810
+ // The descriptor path renders each leaf as ONE keyed element — the
4811
+ // renderItem adopts `template.content.firstElementChild` and `patchLeaf`
4812
+ // patches a single element root. A fragment (or any non-element) root
4813
+ // would silently drop siblings client-side while SSR renders them all —
4814
+ // the exact divergence class this carrier forbids. Refuse loudly.
4815
+ if (leafIr.type !== 'element') {
4816
+ const loc = 'loc' in leafIr && leafIr.loc
4817
+ ? leafIr.loc
4818
+ : getSourceLocation(body, ctx.sourceFile, ctx.filePath)
4819
+ ctx.analyzer.errors.push(
4820
+ createError(
4821
+ ErrorCodes.UNSUPPORTED_JSX_PATTERN,
4822
+ loc,
4823
+ {
4824
+ message:
4825
+ 'A JSX leaf produced by a .flatMap() callback must be a single ' +
4826
+ 'element — a fragment or non-element root cannot ride the keyed ' +
4827
+ 'descriptor path (each leaf hydrates and patches as one element).',
4828
+ suggestion: {
4829
+ message: 'Wrap the leaf content in a single keyed element.',
4830
+ },
4831
+ }
4832
+ )
4833
+ )
4834
+ return undefined
4835
+ }
4836
+ if (flatMapLeafNeedsWiring(leafIr)) {
4837
+ const loc = 'loc' in leafIr && leafIr.loc
4838
+ ? leafIr.loc
4839
+ : getSourceLocation(body, ctx.sourceFile, ctx.filePath)
4840
+ ctx.analyzer.errors.push(
4841
+ createError(
4842
+ ErrorCodes.UNSUPPORTED_JSX_PATTERN,
4843
+ loc,
4844
+ {
4845
+ message:
4846
+ 'A JSX element produced by a .flatMap() callback cannot carry ' +
4847
+ 'event handlers, components, nested loops, or spreads — the ' +
4848
+ 'leaf renders as a keyed HTML string with no per-element wiring.',
4849
+ suggestion: {
4850
+ message:
4851
+ 'Restructure so the interactive element lives in a .map() body — ' +
4852
+ 'the descriptor path has no per-element wiring on any backend, ' +
4853
+ 'so /* @client */ does not lift this.',
4854
+ },
4855
+ }
4856
+ )
4857
+ )
4858
+ return undefined
4859
+ }
4860
+ }
4861
+
4580
4862
  const pieces = reconstructAsSegments(body, ctx.sourceFile, ctx.analyzer.typeExcludeRanges, leafSpans)
4581
4863
  const segments: PreambleSegment[] = pieces.map((piece) => {
4582
4864
  if ('marker' in piece) return { kind: 'jsx', ir: leafIrs[piece.marker] }
@@ -4610,6 +4892,35 @@ interface PreambleCollection {
4610
4892
  refusalNode?: ts.Node
4611
4893
  }
4612
4894
 
4895
+ /**
4896
+ * Does a flatMap segment leaf carry wiring the descriptor render path can't
4897
+ * honour? Narrower than {@link preambleFragmentNeedsWiring}: reactive
4898
+ * expressions are ALLOWED (the client renders each leaf as a keyed
4899
+ * `{ k, h }` descriptor and patches the whole leaf when its HTML changes),
4900
+ * but events, components, nested loops, and spreads have no wiring on that
4901
+ * path and would be silently dead.
4902
+ */
4903
+ function flatMapLeafNeedsWiring(ir: IRNode): boolean {
4904
+ switch (ir.type) {
4905
+ case 'component':
4906
+ case 'loop':
4907
+ return true
4908
+ case 'element':
4909
+ if (ir.events.length > 0) return true
4910
+ if (ir.attrs.some((a) => a.name.startsWith('...'))) return true
4911
+ return ir.children.some(flatMapLeafNeedsWiring)
4912
+ case 'conditional':
4913
+ return (
4914
+ flatMapLeafNeedsWiring(ir.whenTrue) ||
4915
+ (ir.whenFalse ? flatMapLeafNeedsWiring(ir.whenFalse) : false)
4916
+ )
4917
+ case 'fragment':
4918
+ return ir.children.some(flatMapLeafNeedsWiring)
4919
+ default:
4920
+ return false
4921
+ }
4922
+ }
4923
+
4613
4924
  /**
4614
4925
  * Does a compiled preamble JSX leaf carry wiring the verbatim render path can't
4615
4926
  * honour? The path builds each leaf once as an interpolated HTML string with no
@@ -4677,6 +4988,61 @@ function flagArrayChildExpressions(nodes: IRNode[], declared: ReadonlySet<string
4677
4988
  }
4678
4989
  }
4679
4990
 
4991
+ /**
4992
+ * #2389 patch-on-update — walk loop-body children and classify each
4993
+ * `IRExpression` whose free identifiers intersect `declared` (the enclosing
4994
+ * `.map()` preamble's `declaredNames`) as a preamble-patched region. Unlike
4995
+ * {@link flagArrayChildExpressions} (which only recognizes the exact bare
4996
+ * `{out}` shape for the array-join coercion), this is deliberately broader —
4997
+ * ANY expression reading a preamble local needs the region-patch effect,
4998
+ * whether it's a builder array (`{cells}`, `joinArrayChild` already set by
4999
+ * `flagArrayChildExpressions`, called earlier for the same `children`) or a
5000
+ * plain value (`{stateLabel}`).
5001
+ *
5002
+ * Reuses an existing `slotId` when the node already has one (e.g. it also
5003
+ * reads the loop param and so already qualified via the ordinary reactive-
5004
+ * text path) rather than allocating a second slot — `node.preambleRegion`
5005
+ * is the single source of truth downstream, so `collectLoopChildReactiveTexts`
5006
+ * can unconditionally defer to it.
5007
+ */
5008
+ function collectPreambleRegions(
5009
+ nodes: IRNode[],
5010
+ declared: ReadonlySet<string>,
5011
+ ctx: TransformContext,
5012
+ ): PreambleRegionSource[] {
5013
+ const regions: PreambleRegionSource[] = []
5014
+ const visit = (list: IRNode[]): void => {
5015
+ for (const node of list) {
5016
+ switch (node.type) {
5017
+ case 'expression': {
5018
+ const refs = extractFreeIdentifiersFromText(node.expr)
5019
+ const usesPreambleLocal = [...refs].some((r) => declared.has(r))
5020
+ if (usesPreambleLocal) {
5021
+ if (!node.slotId) node.slotId = generateSlotId(ctx)
5022
+ node.preambleRegion = true
5023
+ node.reactive = true
5024
+ regions.push({
5025
+ slotId: node.slotId,
5026
+ expr: node.expr,
5027
+ joinArrayChild: node.joinArrayChild || undefined,
5028
+ })
5029
+ }
5030
+ break
5031
+ }
5032
+ case 'element':
5033
+ case 'fragment':
5034
+ visit(node.children)
5035
+ break
5036
+ case 'conditional':
5037
+ visit([node.whenTrue, ...(node.whenFalse ? [node.whenFalse] : [])])
5038
+ break
5039
+ }
5040
+ }
5041
+ }
5042
+ visit(nodes)
5043
+ return regions
5044
+ }
5045
+
4680
5046
  /**
4681
5047
  * Collect the names a binding introduces, recursing through object/array
4682
5048
  * destructuring patterns (`const { id: k } = r`, `const [k, ...rest] = xs`) so
package/src/types.ts CHANGED
@@ -380,6 +380,15 @@ 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 (`patchSlotRange`) is its only wiring.
390
+ */
391
+ preambleRegion?: boolean
383
392
  /** When true, expression calls signal getters or memos (has reactive `foo()` pattern). */
384
393
  callsReactiveGetters?: boolean
385
394
  /** When true, expression contains function call(s) — any `identifier()` pattern (computed from AST). */
@@ -702,6 +711,29 @@ export interface IRLoop {
702
711
  */
703
712
  preamble?: MapCallbackPreamble
704
713
 
714
+ /**
715
+ * Expression children of the loop body whose free identifiers intersect
716
+ * `preamble.declaredNames` (#2389, patch-on-update follow-up to the Stage 3
717
+ * root cure) — e.g. `{cells}` in `arr.map(t => { const cells = []; ...;
718
+ * return <tr>{cells}<td>{t.name}</td></tr> })`. `mapArray` reuses the same
719
+ * DOM node on a same-key item update via per-item `setItem`, re-running
720
+ * only the wired text/attr slots — a preamble-derived child has NEITHER
721
+ * (it's a bare interpolation, `joinArrayChild` array-join or otherwise),
722
+ * so without this it freezes at its mount-time value forever. Each entry's
723
+ * `slotId` is emitted as the usual `<!--bf:sN-->...<!--/-->` marker pair
724
+ * (same door as a reactive text slot — `irToHtmlTemplate` / Hono's
725
+ * `renderExpression` key off `slotId` alone), but the CLIENT wiring is a
726
+ * distinct `patchSlotRange`-based region-patch effect (`preambleRegions`
727
+ * in the client-JS loop plan), NOT a `reactiveTexts` entry — patching via
728
+ * `.textContent` would escape markup that a `joinArrayChild` region must
729
+ * render raw. `collectLoopChildReactiveTexts` excludes any node collected
730
+ * here (`IRExpression.preambleRegion`) so a qualifying expression is never
731
+ * double-wired. Populated only when `preamble` is set and the loop is NOT
732
+ * `isStaticArray` — a static array's SSR-rendered items are never
733
+ * recreated via signals, so there is nothing to patch.
734
+ */
735
+ preambleRegions?: PreambleRegionSource[]
736
+
705
737
  /**
706
738
  * When `.map(callback)` destructures its item parameter (array or object
707
739
  * pattern), this captures each destructured binding's name and the
@@ -809,6 +841,23 @@ export interface MapCallbackPreamble {
809
841
  builderNames: string[]
810
842
  }
811
843
 
844
+ /**
845
+ * One loop-body expression child classified as a preamble-patched region
846
+ * (#2389 — see `IRLoop.preambleRegions`). `expr` is the raw (unwrapped)
847
+ * expression text, exactly as carried on the source `IRExpression` node;
848
+ * emitters wrap it with the loop-param accessor at codegen time, matching
849
+ * every other loop-body expression. `joinArrayChild` mirrors the
850
+ * `IRExpression` field of the same name — the client-JS plan builder uses it
851
+ * to decide between the array-join value expression and a plain (escaped)
852
+ * text value, keeping the region's re-render byte-identical to the row
853
+ * template's own `irToHtmlTemplate` rendering of the same node.
854
+ */
855
+ export interface PreambleRegionSource {
856
+ slotId: string
857
+ expr: string
858
+ joinArrayChild?: boolean
859
+ }
860
+
812
861
  /**
813
862
  * The preamble's JS text (js segments only, concatenated), for read-side
814
863
  * analysis — free-identifier scans, reachability edges, rest-misuse checks.