@barefootjs/jsx 0.34.0 → 0.35.1

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 (148) hide show
  1. package/dist/analyzer-context.d.ts.map +1 -1
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/index.d.ts +2 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1072 -903
  6. package/dist/ir-to-client-js/collect-elements.d.ts +22 -2
  7. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts +14 -2
  12. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +11 -0
  14. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +6 -1
  16. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -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/build-plain-row.d.ts +52 -0
  19. package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts.map +1 -0
  20. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +5 -2
  21. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts +24 -44
  23. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +6 -7
  25. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +5 -7
  27. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +14 -0
  30. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +10 -6
  32. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/control-flow/shared.d.ts +11 -9
  34. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/control-flow/stringify/composite-loop.d.ts.map +1 -1
  36. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +6 -14
  37. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  38. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts +1 -3
  39. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  40. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +19 -3
  41. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  43. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +5 -1
  44. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  45. package/dist/ir-to-client-js/csr-substitute.d.ts +16 -0
  46. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  47. package/dist/ir-to-client-js/emit-reactive.d.ts +54 -0
  48. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  49. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  50. package/dist/ir-to-client-js/html-template.d.ts +2 -2
  51. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  52. package/dist/ir-to-client-js/imports.d.ts +2 -2
  53. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  54. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  55. package/dist/ir-to-client-js/markup-slots.d.ts +21 -0
  56. package/dist/ir-to-client-js/markup-slots.d.ts.map +1 -0
  57. package/dist/ir-to-client-js/reactivity.d.ts +62 -18
  58. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  59. package/dist/ir-to-client-js/safe-html.d.ts +158 -0
  60. package/dist/ir-to-client-js/safe-html.d.ts.map +1 -0
  61. package/dist/ir-to-client-js/types.d.ts +18 -2
  62. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  63. package/dist/ir-to-client-js/utils.d.ts +34 -1
  64. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  65. package/dist/jsx-to-ir.d.ts.map +1 -1
  66. package/dist/prop-rewrite.d.ts +8 -3
  67. package/dist/prop-rewrite.d.ts.map +1 -1
  68. package/dist/props-binding.d.ts +22 -1
  69. package/dist/props-binding.d.ts.map +1 -1
  70. package/dist/reactivity-checker.d.ts +18 -0
  71. package/dist/reactivity-checker.d.ts.map +1 -1
  72. package/dist/ssr-defaults.d.ts.map +1 -1
  73. package/dist/types.d.ts +5 -10
  74. package/dist/types.d.ts.map +1 -1
  75. package/package.json +2 -2
  76. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +138 -22
  77. package/src/__tests__/binding-scope-ratchet.test.ts +25 -2
  78. package/src/__tests__/build-plain-row-consolidation.test.ts +120 -0
  79. package/src/__tests__/child-prop-fallback-wrap.test.ts +4 -2
  80. package/src/__tests__/client-template-escape-soundness.test.ts +538 -0
  81. package/src/__tests__/compiler-stress-1244.test.ts +8 -8
  82. package/src/__tests__/csr-template-loop-shadowing.test.ts +4 -2
  83. package/src/__tests__/flatmap-segments.test.ts +1 -1
  84. package/src/__tests__/issue-2868-cond-arm-tag-collision.test.ts +185 -0
  85. package/src/__tests__/issue-2869-attr-dedup-guard.test.ts +141 -0
  86. package/src/__tests__/lazy-conditional.test.ts +52 -12
  87. package/src/__tests__/lazy-preamble.test.ts +11 -11
  88. package/src/__tests__/lazy-row-eligibility.test.ts +8 -3
  89. package/src/__tests__/loop-child-reactive-attr-const-shadow.test.ts +25 -3
  90. package/src/__tests__/loop-index-reactivity.test.ts +208 -0
  91. package/src/__tests__/loop-item-conditional-codegen.test.ts +4 -2
  92. package/src/__tests__/map-arbitrary-body.test.ts +1 -1
  93. package/src/__tests__/map-body-brand-checker.test.ts +176 -0
  94. package/src/__tests__/nested-loop-index-param.test.ts +41 -8
  95. package/src/__tests__/nested-loop-non-outer-reactivity.test.ts +177 -0
  96. package/src/__tests__/nested-loop-plain.test.ts +39 -25
  97. package/src/__tests__/nested-loop-reactive-attrs.test.ts +16 -6
  98. package/src/__tests__/preamble-branch-arm-rerun.test.ts +135 -0
  99. package/src/__tests__/reactive-attrs-in-map.test.ts +13 -10
  100. package/src/__tests__/rewrite-destructured-props.test.ts +37 -0
  101. package/src/__tests__/safe-html-splice-door.test.ts +61 -0
  102. package/src/__tests__/ssr-defaults.test.ts +59 -0
  103. package/src/__tests__/style-css-var-reactive.test.ts +4 -1
  104. package/src/analyzer-context.ts +12 -10
  105. package/src/analyzer.ts +4 -0
  106. package/src/index.ts +8 -2
  107. package/src/ir-to-client-js/collect-elements.ts +126 -32
  108. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +20 -28
  109. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +9 -7
  110. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +12 -6
  111. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +36 -59
  112. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +25 -4
  113. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +36 -9
  114. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +35 -35
  115. package/src/ir-to-client-js/control-flow/plan/build-plain-row.ts +116 -0
  116. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +23 -8
  117. package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +24 -45
  118. package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +11 -10
  119. package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +11 -13
  120. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +13 -3
  121. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +14 -0
  122. package/src/ir-to-client-js/control-flow/plan/loop.ts +10 -6
  123. package/src/ir-to-client-js/control-flow/shared.ts +40 -17
  124. package/src/ir-to-client-js/control-flow/stringify/composite-loop.ts +2 -1
  125. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +20 -66
  126. package/src/ir-to-client-js/control-flow/stringify/insert.ts +4 -2
  127. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +22 -15
  128. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +53 -11
  129. package/src/ir-to-client-js/control-flow/stringify/loop.ts +4 -2
  130. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +29 -18
  131. package/src/ir-to-client-js/csr-substitute.ts +29 -0
  132. package/src/ir-to-client-js/emit-reactive.ts +92 -6
  133. package/src/ir-to-client-js/emit-registration.ts +2 -4
  134. package/src/ir-to-client-js/html-template.ts +67 -245
  135. package/src/ir-to-client-js/imports.ts +3 -0
  136. package/src/ir-to-client-js/index.ts +4 -4
  137. package/src/ir-to-client-js/markup-slots.ts +25 -0
  138. package/src/ir-to-client-js/reactivity.ts +73 -27
  139. package/src/ir-to-client-js/safe-html.ts +243 -0
  140. package/src/ir-to-client-js/types.ts +19 -3
  141. package/src/ir-to-client-js/utils.ts +58 -10
  142. package/src/jsx-to-ir.ts +136 -24
  143. package/src/prop-rewrite.ts +8 -4
  144. package/src/props-binding.ts +37 -1
  145. package/src/reactivity-checker.ts +60 -0
  146. package/src/ssr-defaults.ts +45 -0
  147. package/src/types.ts +5 -10
  148. package/src/__tests__/text-slot-escaping.test.ts +0 -65
package/src/jsx-to-ir.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  type IRIfStatement,
19
19
  type IRProvider,
20
20
  type IRAttribute,
21
+ type ExpressionAttr,
21
22
  type IREvent,
22
23
  type IRProp,
23
24
  type AttrValue,
@@ -44,7 +45,7 @@ import type { IRLoopSort, FunctionInfo, ConstantInfo } from './types.ts'
44
45
  import { formatParamWithType } from './module-exports.ts'
45
46
  import { createError, ErrorCodes, internalInvariant } from './errors.ts'
46
47
  import { CLIENT_BUILTIN_SOURCE, isClientBuiltinName, type ClientBuiltinTag } from './builtins.ts'
47
- import { containsReactiveExpression } from './reactivity-checker.ts'
48
+ import { containsReactiveExpression, collectReactiveBrandLeaves } from './reactivity-checker.ts'
48
49
  import {
49
50
  rewriteBarePropRefs as rewriteBarePropRefsCore,
50
51
  collectAstPropRefs,
@@ -606,12 +607,14 @@ function collectBranchLocalPropRefsViaSubstitution(
606
607
  let acc: Set<string> | undefined
607
608
  function visit(n: ts.Node, parent?: ts.Node) {
608
609
  if (ts.isIdentifier(n) && propDepsMap!.has(n.text)) {
609
- // Same skip rules as `collectAstPropRefs` — only value
610
- // positions count, not object keys / property-access names.
610
+ // Same skip rules as `collectAstPropRefs` — only value positions
611
+ // count, not object keys / property-access names. A shorthand
612
+ // property (`{ page }`) IS a value reference (#2828) — its name
613
+ // is simultaneously the key and the value — so it participates
614
+ // here too, same as `collectAstPropRefs`.
611
615
  const isObjectKey = parent && ts.isPropertyAssignment(parent) && parent.name === n
612
- const isShorthand = parent && ts.isShorthandPropertyAssignment(parent) && parent.name === n
613
616
  const isAccessName = parent && ts.isPropertyAccessExpression(parent) && parent.name === n
614
- if (!isObjectKey && !isShorthand && !isAccessName) {
617
+ if (!isObjectKey && !isAccessName) {
615
618
  const deps = propDepsMap!.get(n.text)
616
619
  if (deps && deps.size > 0) {
617
620
  if (!acc) acc = new Set()
@@ -1484,15 +1487,13 @@ function lowerFormControlValueSsr(
1484
1487
 
1485
1488
  if (tagName === 'textarea') {
1486
1489
  if (children.length > 0) return
1490
+ // A plain text child: SSR engines escape text children natively, and
1491
+ // the client builders escape every child-position value by default
1492
+ // (`ir-to-client-js/safe-html.ts`, #2795) — nothing to mark here.
1487
1493
  children.push({
1488
1494
  type: 'expression',
1489
1495
  expr,
1490
- // Escaped for client string-building; `expr` stays raw since SSR
1491
- // engines escape text children natively.
1492
- templateExpr: `escapeText(${templateExpr ?? expr})`,
1493
- // Init-scope builders can't just swap in `templateExpr` (its `_p.`
1494
- // binding differs) — see `escapeInClientTemplate`'s docstring.
1495
- escapeInClientTemplate: true,
1496
+ templateExpr,
1496
1497
  typeInfo: null,
1497
1498
  reactive: false,
1498
1499
  slotId: null,
@@ -1502,7 +1503,7 @@ function lowerFormControlValueSsr(
1502
1503
  return
1503
1504
  }
1504
1505
 
1505
- const selectedForLiteral = (optValue: string): AttrValue =>
1506
+ const selectedForLiteral = (optValue: string): ExpressionAttr =>
1506
1507
  AttrValueOf.expression(
1507
1508
  `(${expr}) === ${JSON.stringify(optValue)}`,
1508
1509
  templateExpr !== undefined
@@ -1519,35 +1520,129 @@ function lowerFormControlValueSsr(
1519
1520
  // `classifyLazyBinding` already know how to place into both `applyItem`
1520
1521
  // (row changed) and `applyOuter` (controlled value changed) — no new loop
1521
1522
  // machinery, just one more per-row reactive attribute (#2466).
1522
- const selectedForExpr = (optExpr: string, optTemplateExpr: string | undefined): AttrValue =>
1523
+ const selectedForExpr = (optExpr: string, optTemplateExpr: string | undefined): ExpressionAttr =>
1523
1524
  AttrValueOf.expression(
1524
1525
  `(${expr}) === (${optExpr})`,
1525
1526
  templateExpr !== undefined || optTemplateExpr !== undefined
1526
1527
  ? { templateExpr: `(${templateExpr ?? expr}) === (${optTemplateExpr ?? optExpr})` }
1527
1528
  : undefined,
1528
1529
  )
1530
+ // #2758: a bound value outside the option set makes SSR and hydration
1531
+ // disagree about what "no match" means. SSR (below) never marks any
1532
+ // `<option>` `selected`, so the browser falls back to its own default —
1533
+ // the FIRST option. Hydration's controlled-value effect then assigns
1534
+ // `select.value` directly (`emitValueUpdateStatements` in
1535
+ // `ir-to-client-js/emit-reactive.ts`), and a value matching no option
1536
+ // yields `selectedIndex = -1` (nothing selected). So the page renders
1537
+ // showing the first option, and the moment hydration runs the selection
1538
+ // visibly disappears.
1539
+ //
1540
+ // Resolution: treat the client's reading as correct ("no match" means no
1541
+ // selection) and make SSR able to express it. When every `<option>` here
1542
+ // is statically enumerable (no `.map()` loop, no conditional/component/
1543
+ // slot child, no author-supplied `selected`), collect each option's match
1544
+ // condition and, for a single-selection `<select>` (not `multiple`, not
1545
+ // `size` > 1 — a list-box already shows no implicit default, so SSR and
1546
+ // hydration already agree there), inject a hidden `disabled` placeholder
1547
+ // option whose own `selected` is the negation of every real option's
1548
+ // condition ORed together. It is only ever actually selected when nothing
1549
+ // else matched, so it changes nothing for the common case where the value
1550
+ // does match an option.
1551
+ //
1552
+ // Bails (leaves prior behaviour) whenever the option set isn't fully
1553
+ // statically known — a `.map()`-rendered list has an unknown number of
1554
+ // rows at compile time, so there is no finite OR to negate.
1555
+ const matchConditions: ExpressionAttr[] = []
1556
+ let optionSetIsDynamic = false
1557
+
1529
1558
  const distribute = (nodes: IRNode[]): void => {
1530
1559
  for (const n of nodes) {
1560
+ if (n.type === 'text') continue
1531
1561
  if (n.type === 'element' && n.tag === 'option') {
1532
- if (n.attrs.some(a => a.name === 'selected')) continue
1562
+ if (n.attrs.some(a => a.name === 'selected')) {
1563
+ optionSetIsDynamic = true
1564
+ continue
1565
+ }
1533
1566
  const optValue = n.attrs.find(a => a.name === 'value')
1534
- if (!optValue) continue
1567
+ if (!optValue) {
1568
+ // No `value` attr: the browser falls back to the option's text
1569
+ // content as its implicit value, which this pass can't compare
1570
+ // against without re-deriving that text — bail rather than
1571
+ // silently dropping this option out of the "no match" OR.
1572
+ optionSetIsDynamic = true
1573
+ continue
1574
+ }
1535
1575
  if (optValue.value.kind === 'literal') {
1536
- n.attrs.push({ name: 'selected', value: selectedForLiteral(optValue.value.value), loc: n.loc })
1576
+ const selected = selectedForLiteral(optValue.value.value)
1577
+ n.attrs.push({ name: 'selected', value: selected, loc: n.loc })
1578
+ matchConditions.push(selected)
1537
1579
  } else if (optValue.value.kind === 'expression') {
1538
1580
  const selected = selectedForExpr(optValue.value.expr, optValue.value.templateExpr)
1539
1581
  n.attrs.push({ name: 'selected', value: selected, loc: n.loc })
1582
+ matchConditions.push(selected)
1583
+ } else {
1584
+ // A `value` shape neither builder above can compare (e.g. a
1585
+ // structured `template` part) — same bail, not a silent drop.
1586
+ optionSetIsDynamic = true
1540
1587
  }
1541
- } else if (
1542
- n.type === 'fragment' ||
1543
- n.type === 'loop' ||
1544
- (n.type === 'element' && n.tag === 'optgroup')
1545
- ) {
1588
+ } else if (n.type === 'fragment' || (n.type === 'element' && n.tag === 'optgroup')) {
1589
+ distribute(n.children)
1590
+ } else if (n.type === 'loop') {
1591
+ // A `.map()`-rendered option list still needs its per-row `selected`
1592
+ // distributed (#2466, preserved below) — but its row count is
1593
+ // unknown at compile time, so it can't feed the "no match" OR.
1594
+ optionSetIsDynamic = true
1546
1595
  distribute(n.children)
1596
+ } else {
1597
+ // Conditionals/if-statements, components, slots — an option here
1598
+ // may or may not exist at runtime, so the option set can't be
1599
+ // exhaustively enumerated at compile time.
1600
+ optionSetIsDynamic = true
1547
1601
  }
1548
1602
  }
1549
1603
  }
1550
1604
  distribute(children)
1605
+
1606
+ if (optionSetIsDynamic || matchConditions.length === 0) return
1607
+ if (isMultiSelection(attrs)) return
1608
+
1609
+ const orExpr = matchConditions.map(c => `(${c.expr})`).join(' || ')
1610
+ const orTemplateExpr = matchConditions.some(c => c.templateExpr !== undefined)
1611
+ ? matchConditions.map(c => `(${c.templateExpr ?? c.expr})`).join(' || ')
1612
+ : undefined
1613
+
1614
+ children.unshift({
1615
+ type: 'element',
1616
+ tag: 'option',
1617
+ attrs: [
1618
+ { name: 'value', value: AttrValueOf.literal(''), loc: valueAttr.loc },
1619
+ { name: 'disabled', value: AttrValueOf.booleanAttr(), loc: valueAttr.loc },
1620
+ { name: 'hidden', value: AttrValueOf.booleanAttr(), loc: valueAttr.loc },
1621
+ {
1622
+ name: 'selected',
1623
+ value: AttrValueOf.expression(
1624
+ `!(${orExpr})`,
1625
+ orTemplateExpr !== undefined ? { templateExpr: `!(${orTemplateExpr})` } : undefined,
1626
+ ),
1627
+ loc: valueAttr.loc,
1628
+ },
1629
+ ],
1630
+ events: [],
1631
+ ref: null,
1632
+ children: [],
1633
+ slotId: null,
1634
+ needsScope: false,
1635
+ loc: valueAttr.loc,
1636
+ })
1637
+ }
1638
+
1639
+ /** `<select multiple>` or `size` > 1: a list box has no implicit default
1640
+ * selection, so SSR (no option `selected`) and hydration (`selectedIndex`
1641
+ * left at whatever it was) already agree without a placeholder option. */
1642
+ function isMultiSelection(attrs: IRAttribute[]): boolean {
1643
+ if (attrs.some(a => a.name === 'multiple')) return true
1644
+ const sizeAttr = attrs.find(a => a.name === 'size')
1645
+ return sizeAttr?.value.kind === 'literal' && Number(sizeAttr.value.value) > 1
1551
1646
  }
1552
1647
 
1553
1648
  function transformHtmlElement(
@@ -2334,7 +2429,13 @@ function transformExpressionInner(
2334
2429
  // A reactive brand-package condition (e.g. `form.field('x').error() &&
2335
2430
  // …`) can't be SSR-evaluated, so defer the whole conditional rather
2336
2431
  // than raising BF061 — same routing as a manual `/* @client */` (#1638).
2337
- if ((isClientOnly || shouldAutoDeferReactiveBrand(expr, ctx)) && ir.type === 'conditional') {
2432
+ // Check `ir.type` first: `shouldAutoDeferReactiveBrand` only matters for
2433
+ // IRConditional, but walks the checker over `expr` — for every other
2434
+ // shape (in particular IRLoop, whose `expr` is a `.map()` callback body
2435
+ // full of JSX) that walk is both wasted work and, historically, exactly
2436
+ // what tripped the getJS trust-boundary assertion on a JSX-bearing node
2437
+ // (#2867/#2830).
2438
+ if (ir.type === 'conditional' && (isClientOnly || shouldAutoDeferReactiveBrand(expr, ctx))) {
2338
2439
  ir.clientOnly = true
2339
2440
  if (!ir.slotId) {
2340
2441
  ir.slotId = generateSlotId(ctx)
@@ -8115,10 +8216,21 @@ function isReactiveExpression(expr: string, ctx: TransformContext, astNode?: ts.
8115
8216
  function shouldAutoDeferReactiveBrand(expr: ts.Expression, ctx: TransformContext): boolean {
8116
8217
  const checker = ctx.analyzer.checker
8117
8218
  if (!checker) return false
8118
- if (!containsReactiveExpression(expr, checker)) return false
8219
+ // Read only the brand-carrying leaf(s), never `expr` itself: `expr` can be
8220
+ // a `.map()` callback or a ternary/`&&` with JSX arms, and getJS() refuses
8221
+ // to stringify a JSX-bearing node (the write-side trust boundary — JSX
8222
+ // must travel as structured segments, never raw text). Each leaf is an
8223
+ // identifier / property access / call callee, none of which can contain
8224
+ // JSX, so getJS() is safe on them regardless of what the rest of `expr`
8225
+ // contains (#2867/#2830 — this used to call getJS(expr) directly and
8226
+ // throw whenever the checker was active and expr had JSX in it).
8227
+ const leaves = collectReactiveBrandLeaves(expr, checker)
8228
+ if (leaves.length === 0) return false
8119
8229
  // Native signals/memos (incl. chained-const aliases) are SSR-derivable —
8120
8230
  // leave them to the normal template path so their initial value renders.
8121
- if (isSignalOrMemoReference(ctx.getJS(expr), ctx)) return false
8231
+ // Preserves the original "any native ref anywhere defers to non-deferred"
8232
+ // semantics by checking every reactive-brand leaf, not just the first.
8233
+ if (leaves.some((leaf) => isSignalOrMemoReference(ctx.getJS(leaf), ctx))) return false
8122
8234
  return true
8123
8235
  }
8124
8236
 
@@ -105,9 +105,14 @@ function isNonValuePosition(n: ts.Identifier, parent: ts.Node | undefined): bool
105
105
  /**
106
106
  * Walk an AST node for destructured-prop value references and add
107
107
  * each found name to `out`. Same skip rules as the rewrite path —
108
- * object-literal keys, shorthand properties, property-access names,
109
- * and names shadowed by a binding inside `node` are excluded so only
110
- * true value references get picked up. Exported for callers that need
108
+ * object-literal keys, property-access names, and names shadowed by a
109
+ * binding inside `node` are excluded so only true value references get
110
+ * picked up. A shorthand property (`{ page }`) IS a value reference —
111
+ * its name is simultaneously the key and the value, and
112
+ * `applyScopedPropRefRewrite` below already expands it correctly
113
+ * (`{ page }` → `{ page: _p.page }`) — so it must be discovered here
114
+ * too, or the rewrite is never even attempted for text whose only prop
115
+ * reference is a shorthand one (#2828). Exported for callers that need
111
116
  * the raw discovery set (e.g. the branch-local prop-dep cache from
112
117
  * #1425).
113
118
  */
@@ -118,7 +123,6 @@ export function collectAstPropRefs(
118
123
  ): void {
119
124
  walkWithScope(node, (n, parent, shadowed) => {
120
125
  if (shadowed || !propNames.has(n.text)) return
121
- if (parent && ts.isShorthandPropertyAssignment(parent) && parent.name === n) return
122
126
  if (isNonValuePosition(n, parent)) return
123
127
  out.add(n.text)
124
128
  })
@@ -1,5 +1,5 @@
1
1
  import ts from 'typescript'
2
- import type { ParamInfo } from './types.ts'
2
+ import type { ConstantInfo, ParamInfo } from './types.ts'
3
3
 
4
4
  /**
5
5
  * Authoritative IdentifierName classification for a destructure-pattern
@@ -69,6 +69,42 @@ export function buildPropAliasMap(params: readonly ParamInfo[]): Map<string, str
69
69
  return map
70
70
  }
71
71
 
72
+ /**
73
+ * Local-name → caller-facing-key map for a BARE-PROPS-form component
74
+ * (`function Foo(props: Props)`) whose BODY destructures a prop under a
75
+ * different local name (`const { children: kids } = props`) — the
76
+ * body-level twin of `buildPropAliasMap` above, which only sees
77
+ * PARAMETER-destructuring aliases (`{ n: count }`, tracked via
78
+ * `ParamInfo.sourceName`). For the bare-props form, `propsParams` comes
79
+ * from the TYPE annotation (`extractPropsFromTypeMembers`) and has no
80
+ * notion of a body-level rename at all — but the analyzer already
81
+ * resolves such a destructuring statement into an ordinary
82
+ * `localConstants` entry whose `parsed` is a plain `props.<key>` member
83
+ * read (`const { children: kids } = props` → a `kids` local const valued
84
+ * `props.children`). Recognizing that shape here is enough to answer
85
+ * "what caller-facing prop key does this local alias" without a second,
86
+ * dedicated AST walk of the destructuring pattern itself (#2788).
87
+ *
88
+ * Returns an empty map for a parameter-destructuring component
89
+ * (`propsObjectName === null`) — that shape's aliasing is already fully
90
+ * covered by `buildPropAliasMap`.
91
+ */
92
+ export function resolveBodyDestructuredPropAliases(
93
+ localConstants: readonly ConstantInfo[],
94
+ propsObjectName: string | null,
95
+ ): Map<string, string> {
96
+ const aliases = new Map<string, string>()
97
+ if (propsObjectName === null) return aliases
98
+ for (const c of localConstants) {
99
+ if (c.isModule) continue
100
+ const m = c.parsed
101
+ if (m?.kind === 'member' && !m.computed && m.object.kind === 'identifier' && m.object.name === propsObjectName) {
102
+ aliases.set(c.name, m.property)
103
+ }
104
+ }
105
+ return aliases
106
+ }
107
+
72
108
  /**
73
109
  * The props-parameter shape needed to answer "is `name` an actual local
74
110
  * binding introduced by the props parameter" — as opposed to
@@ -191,3 +191,63 @@ export function containsReactiveExpression(node: ts.Node, checker: ts.TypeChecke
191
191
  incrementCounter('reactivityChecks')
192
192
  return brandTypeReactivityAnalyzer.analyze(node, checker).isReactive
193
193
  }
194
+
195
+ /** Subtree JSX check — also used by analyzer-context.ts's getJS() guard. */
196
+ export function nodeContainsJsx(node: ts.Node): boolean {
197
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) return true
198
+ return ts.forEachChild(node, nodeContainsJsx) ?? false
199
+ }
200
+
201
+ /**
202
+ * Every node that itself carries the `Reactive<T>` brand within `node`,
203
+ * without stopping at the first match (unlike `analyze`, which only needs
204
+ * one hit to answer "is this reactive"). A caller that needs to read the
205
+ * *text* of the reactive part(s) — e.g. to tell a brand-package wrapper
206
+ * apart from a plain native signal/memo call (#2867) — must not stringify
207
+ * `node` itself: for a `.map()` callback or a ternary with JSX arms, `node`
208
+ * is JSX-bearing, and the write-side trust boundary (`getJS()`,
209
+ * `analyzer-context.ts`) rejects producing text for a JSX-bearing node.
210
+ * Each returned node is JSX-free: property access and identifier leaves
211
+ * cannot contain JSX by construction; a call leaf is the whole call
212
+ * (preserving the `name(...)` shape native-signal/memo regex patterns
213
+ * match against) unless its arguments happen to contain JSX, in which case
214
+ * only the callee is returned instead.
215
+ */
216
+ export function collectReactiveBrandLeaves(node: ts.Node, checker: ts.TypeChecker): ts.Node[] {
217
+ const leaves: ts.Node[] = []
218
+ const visit = (n: ts.Node): void => {
219
+ if (ts.isPropertyAccessExpression(n)) {
220
+ try {
221
+ if (isReactiveType(queryType(checker, n))) {
222
+ leaves.push(n)
223
+ return
224
+ }
225
+ } catch {
226
+ // fall through to walking the object part
227
+ }
228
+ visit(n.expression)
229
+ return
230
+ }
231
+ if (ts.isIdentifier(n)) {
232
+ try {
233
+ if (isReactiveType(queryType(checker, n))) leaves.push(n)
234
+ } catch {
235
+ // not reactive
236
+ }
237
+ return
238
+ }
239
+ if (ts.isCallExpression(n)) {
240
+ try {
241
+ if (isReactiveType(queryType(checker, n.expression))) {
242
+ leaves.push(nodeContainsJsx(n) ? n.expression : n)
243
+ return
244
+ }
245
+ } catch {
246
+ // fall through to walking children
247
+ }
248
+ }
249
+ ts.forEachChild(n, visit)
250
+ }
251
+ visit(node)
252
+ return leaves
253
+ }
@@ -31,6 +31,8 @@
31
31
 
32
32
  import ts from 'typescript'
33
33
  import { extractFreeIdentifiersFromNode } from './analyzer.ts'
34
+ import { resolveGetterAliases, collectAliasableGetterNames } from './ir-to-client-js/csr-substitute.ts'
35
+ import { resolveBodyDestructuredPropAliases } from './props-binding.ts'
34
36
  import type { IRMetadata } from './types.ts'
35
37
 
36
38
  /**
@@ -311,6 +313,49 @@ export function extractSsrDefaults(metadata: IRMetadata): Record<string, SsrDefa
311
313
  bindings[memo.name] = value
312
314
  }
313
315
 
316
+ // #2813: a bare local-const alias of a signal/memo getter
317
+ // (`const items__alias = items`) is a DIFFERENT identifier from the
318
+ // getter it aliases, but a template-stash adapter's `.map()` loop over
319
+ // `items__alias()` looks up the stash entry BY THAT NAME — which the
320
+ // loops above never create, since they only seed the getter's own
321
+ // name. Reuses `resolveGetterAliases` (the same alias-hop walker
322
+ // #2778's CSR-template fix introduced) rather than a third alias-hop
323
+ // walker: every alias of a getter seeded above gets the SAME entry
324
+ // under its own name, so the stash has both `items` and `items__alias`
325
+ // pointing at one value. Env-signal getters are excluded from the
326
+ // getter-name set (they're never given an `out` entry above — an
327
+ // adapter's own env-signal binding seeds them instead).
328
+ {
329
+ const getterNames = collectAliasableGetterNames(metadata.signals, metadata.memos)
330
+ for (const [alias, origin] of resolveGetterAliases(metadata.localConstants ?? [], (n) => getterNames.has(n))) {
331
+ if (alias in out) continue
332
+ out[alias] = out[origin]
333
+ }
334
+ }
335
+
336
+ // #2788: a bare-props-form component (`function Foo(props: Props)`)
337
+ // whose BODY destructures a prop under a different local name
338
+ // (`const { children: kids } = props`) has no `ParamInfo.sourceName` to
339
+ // key off above — `propsParams` here comes from the TYPE annotation
340
+ // (`extractPropsFromTypeMembers`), which has no notion of a body-level
341
+ // rename. `resolveBodyDestructuredPropAliases` recognizes such a
342
+ // destructuring statement's `localConstants` shape; seeding the LOCAL
343
+ // name too (alongside the caller-facing key already seeded above) is
344
+ // enough to fix what a template-stash adapter's compiled output
345
+ // actually reads (`v[:kids]` / `$kids`). Without this, a stash-based
346
+ // adapter reading the renamed local sees an undefined/nil value instead
347
+ // of the prop — fatal under Perl strict mode (Mojolicious), a
348
+ // nonexistent Go struct field, or (on an adapter whose runtime
349
+ // tolerates a missing key) a silent wrong render the moment a caller
350
+ // actually supplies a value. Parameter-destructuring aliases (`{ n:
351
+ // count }`) need no such entry — `sourceName`/`name` already handle
352
+ // that shape via `callerPropName` above.
353
+ for (const [local, callerKey] of resolveBodyDestructuredPropAliases(metadata.localConstants ?? [], metadata.propsObjectName)) {
354
+ if (local in out) continue
355
+ const origin = out[callerKey]
356
+ if (origin) out[local] = origin
357
+ }
358
+
314
359
  // Bare-props-arg safety net: the prop block above covers every prop
315
360
  // *declared* on the props type, but `propsParams` can miss props read
316
361
  // through an untyped / inline-typed `props` object. A signal / memo
package/src/types.ts CHANGED
@@ -404,18 +404,13 @@ export interface IRText {
404
404
  export interface IRExpression {
405
405
  type: 'expression'
406
406
  expr: string
407
- /** Pre-transformed expr with destructured prop refs rewritten to _p.xxx (for client JS templates). */
408
- templateExpr?: string
409
407
  /**
410
- * Marks a VALUE moved into element content by a lowering (today only
411
- * `lowerFormControlValueSsr`'s `<textarea>`, #2765) — client builders
412
- * must escape it, unlike the normal pre-rendered-HTML `${...}` case.
413
- *
414
- * Not read via `templateExpr` instead: that field also rebinds to
415
- * `_p.xxx`, which drops a destructured prop's `?? {}` default when the
416
- * builder's output lives in init scope (`client-js-generation.test.ts`).
408
+ * Pre-transformed expr with destructured prop refs rewritten to _p.xxx
409
+ * (for client JS templates). Carries ONLY that rewrite never an
410
+ * escaping wrapper: client-side escaping is decided at the splice
411
+ * (`ir-to-client-js/safe-html.ts`), not baked into IR text (#2795).
417
412
  */
418
- escapeInClientTemplate?: boolean
413
+ templateExpr?: string
419
414
  /**
420
415
  * Structured parse of `expr` (`parseExpression(expr.trim())`), attached once
421
416
  * during IR construction so SSR adapters emit from the tree instead of each
@@ -1,65 +0,0 @@
1
- /**
2
- * Text-slot HTML-escaping emit shape (#1694 + follow-up, #2651).
3
- *
4
- * Pins which interpolations the client template wraps in an escape call:
5
- * - a plain, non-conditional dynamic text slot (`{stringValue}`) IS
6
- * escaped — it becomes the slot's text content under `innerHTML`. Since
7
- * #2651 this goes through `escapeTextOrMarkup`, not bare `escapeText`:
8
- * the slot is claim-plan `kind: 'markup'` (the value may be a live
9
- * `Node`, or a `bfMarkup()`-branded JSX-element-prop value), and
10
- * `escapeTextOrMarkup` is a strict superset of `escapeText` for every
11
- * non-branded value — this pin's actual escaping behaviour for a plain
12
- * string is unchanged, only the call name changed to match the
13
- * REACTIVE side's existing `escapeTextOrNode` classification;
14
- * - a branch-slot expression (Child-position value inside a conditional
15
- * `template()` arrow) is routed through `__bfSlot` and must NOT be
16
- * wrapped in either escape call. `__bfSlot` returns raw
17
- * `<!--bf-slot:N-->` markers for live nodes; escaping the whole call
18
- * corrupts them and drops slotted content (the regression that broke
19
- * `e2e-site-ui`). `__bfSlot` escapes its own plain-string path
20
- * internally instead.
21
- */
22
-
23
- import { describe, test, expect } from 'bun:test'
24
- import { compileJSX } from '../compiler'
25
- import { TestAdapter } from '../adapters/test-adapter'
26
-
27
- const adapter = new TestAdapter()
28
-
29
- function getClientJs(source: string, filename: string): string {
30
- const result = compileJSX(source, filename, { adapter })
31
- expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
32
- const clientJs = result.files.find(f => f.type === 'clientJs')
33
- expect(clientJs).toBeDefined()
34
- return clientJs!.content
35
- }
36
-
37
- describe('text-slot escaping', () => {
38
- test('a plain text slot is wrapped in escapeTextOrMarkup (#2651)', () => {
39
- const clientJs = getClientJs(
40
- `'use client'
41
- export function Label({ text }: { text: string }) {
42
- return <span>{text}</span>
43
- }`,
44
- 'Label.tsx',
45
- )
46
- expect(clientJs).toMatch(/<!--bf:\w+-->\$\{escapeTextOrMarkup\(_p\.text\)\}<!--\/-->/)
47
- })
48
-
49
- test('a branch-slot expression is NOT wrapped in escapeTextOrMarkup or escapeText', () => {
50
- const clientJs = getClientJs(
51
- `'use client'
52
- import { createSignal } from '@barefootjs/client'
53
- export function Branch({ show }: { show: boolean }) {
54
- const [t] = createSignal('hi')
55
- return <div>{show ? <span>{t()}</span> : null}</div>
56
- }`,
57
- 'Branch.tsx',
58
- )
59
- // The branch value goes through __bfSlot (raw markers preserved)…
60
- expect(clientJs).toMatch(/\$\{__bfSlot\(/)
61
- // …and must never be double-wrapped by either text-escape call.
62
- expect(clientJs).not.toMatch(/escapeTextOrMarkup\(\s*__bfSlot/)
63
- expect(clientJs).not.toMatch(/escapeText\(\s*__bfSlot/)
64
- })
65
- })