@barefootjs/jsx 0.34.0 → 0.35.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 (101) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/index.d.ts +2 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +344 -215
  5. package/dist/ir-to-client-js/collect-elements.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-component-loop.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +11 -0
  10. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts +52 -0
  13. package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts.map +1 -0
  14. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +2 -0
  15. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +6 -7
  17. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +5 -7
  19. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +10 -6
  22. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/shared.d.ts +11 -9
  24. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/stringify/composite-loop.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/csr-substitute.d.ts +16 -0
  28. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/html-template.d.ts +2 -2
  32. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/imports.d.ts +2 -2
  34. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  36. package/dist/ir-to-client-js/markup-slots.d.ts +21 -0
  37. package/dist/ir-to-client-js/markup-slots.d.ts.map +1 -0
  38. package/dist/ir-to-client-js/safe-html.d.ts +158 -0
  39. package/dist/ir-to-client-js/safe-html.d.ts.map +1 -0
  40. package/dist/ir-to-client-js/utils.d.ts +34 -1
  41. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  42. package/dist/jsx-to-ir.d.ts.map +1 -1
  43. package/dist/prop-rewrite.d.ts +8 -3
  44. package/dist/prop-rewrite.d.ts.map +1 -1
  45. package/dist/props-binding.d.ts +22 -1
  46. package/dist/props-binding.d.ts.map +1 -1
  47. package/dist/ssr-defaults.d.ts.map +1 -1
  48. package/dist/types.d.ts +5 -10
  49. package/dist/types.d.ts.map +1 -1
  50. package/package.json +2 -2
  51. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +10 -12
  52. package/src/__tests__/build-plain-row-consolidation.test.ts +120 -0
  53. package/src/__tests__/client-template-escape-soundness.test.ts +538 -0
  54. package/src/__tests__/csr-template-loop-shadowing.test.ts +4 -2
  55. package/src/__tests__/flatmap-segments.test.ts +1 -1
  56. package/src/__tests__/lazy-conditional.test.ts +52 -12
  57. package/src/__tests__/lazy-preamble.test.ts +11 -11
  58. package/src/__tests__/lazy-row-eligibility.test.ts +8 -3
  59. package/src/__tests__/loop-child-reactive-attr-const-shadow.test.ts +25 -3
  60. package/src/__tests__/loop-item-conditional-codegen.test.ts +4 -2
  61. package/src/__tests__/map-arbitrary-body.test.ts +1 -1
  62. package/src/__tests__/nested-loop-index-param.test.ts +14 -3
  63. package/src/__tests__/nested-loop-reactive-attrs.test.ts +4 -2
  64. package/src/__tests__/reactive-attrs-in-map.test.ts +13 -10
  65. package/src/__tests__/rewrite-destructured-props.test.ts +37 -0
  66. package/src/__tests__/safe-html-splice-door.test.ts +61 -0
  67. package/src/__tests__/ssr-defaults.test.ts +59 -0
  68. package/src/analyzer.ts +4 -0
  69. package/src/index.ts +8 -2
  70. package/src/ir-to-client-js/collect-elements.ts +18 -2
  71. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +20 -28
  72. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +9 -7
  73. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +10 -6
  74. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +8 -8
  75. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +18 -2
  76. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +3 -3
  77. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +35 -35
  78. package/src/ir-to-client-js/control-flow/plan/build-plain-row.ts +110 -0
  79. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +5 -2
  80. package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +11 -10
  81. package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +11 -13
  82. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +13 -3
  83. package/src/ir-to-client-js/control-flow/plan/loop.ts +10 -6
  84. package/src/ir-to-client-js/control-flow/shared.ts +40 -17
  85. package/src/ir-to-client-js/control-flow/stringify/composite-loop.ts +2 -1
  86. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +10 -0
  87. package/src/ir-to-client-js/csr-substitute.ts +29 -0
  88. package/src/ir-to-client-js/emit-reactive.ts +4 -1
  89. package/src/ir-to-client-js/emit-registration.ts +2 -4
  90. package/src/ir-to-client-js/html-template.ts +58 -244
  91. package/src/ir-to-client-js/imports.ts +3 -0
  92. package/src/ir-to-client-js/index.ts +4 -4
  93. package/src/ir-to-client-js/markup-slots.ts +25 -0
  94. package/src/ir-to-client-js/safe-html.ts +243 -0
  95. package/src/ir-to-client-js/utils.ts +58 -10
  96. package/src/jsx-to-ir.ts +115 -20
  97. package/src/prop-rewrite.ts +8 -4
  98. package/src/props-binding.ts +37 -1
  99. package/src/ssr-defaults.ts +45 -0
  100. package/src/types.ts +5 -10
  101. package/src/__tests__/text-slot-escaping.test.ts +0 -65
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Escape-by-default for client-JS HTML template strings (#2795).
3
+ *
4
+ * Every builder in `html-template.ts` assembles a JS template literal whose
5
+ * `${...}` holes are evaluated in the browser and parsed as HTML
6
+ * (`innerHTML`). Whether a hole's value is escaped used to be an OPT-IN each
7
+ * builder remembered separately — `escapeInClientTemplate` on the IR node, a
8
+ * pre-baked `escapeText(...)` string in `templateExpr`, an
9
+ * `escapeLeafTextExpressions` rewrite pass, and a hand-rolled `escapeText`
10
+ * in `buildPreambleRegionPlans` — and a forgotten opt-in shipped raw text
11
+ * twice (#1694, #2765/#2792). This module inverts that:
12
+ *
13
+ * - `SafeHtml` is a compile-time nominal brand (the `TsxSourceText`
14
+ * precedent, `types.ts`) on a JS EXPRESSION STRING meaning "when this
15
+ * evaluates in the client template, the result is HTML that is safe to
16
+ * concatenate in". Nothing outside this module can construct one, so a
17
+ * `SafeHtml` value is the compiler vouching for itself — unlike the
18
+ * runtime `bfMarkup()` brand (`@barefootjs/client/runtime`), which
19
+ * vouches for a value the compiler never saw.
20
+ * - `interp` is the ONLY way to turn a value into a `${...}` hole, and it
21
+ * accepts `SafeHtml` only. A builder holding a plain `string` cannot
22
+ * splice it: it must either go through a named producer below (each of
23
+ * which documents WHY its output is safe) or through `spliceChildValue`,
24
+ * the door for a child-position expression, whose default is
25
+ * `escapeText(...)`.
26
+ *
27
+ * The runtime dispatch that already existed for values the compiler cannot
28
+ * classify statically is unchanged: a branch-slot value still goes through
29
+ * `__bfSlot` (may be a live Node), a claim-plan `'markup'` slot still goes
30
+ * through `escapeTextOrMarkup` (may be a `bfMarkup()`-branded prop). Those
31
+ * are producers here too — they escape internally, so their output is safe
32
+ * by construction.
33
+ *
34
+ * Attribute-value holes (`escapeAttr`, `spreadAttrs`) are a separate,
35
+ * always-on path in `html-template.ts` and are not routed through this
36
+ * module; `client-template-escape-soundness.test.ts` pins their count.
37
+ */
38
+
39
+ import type { IRExpression } from '../types.ts'
40
+
41
+ /**
42
+ * A JS expression string that evaluates, in the client template, to HTML
43
+ * safe to splice raw. Branded at construction by the producers below; only
44
+ * `interp` consumes it.
45
+ */
46
+ export type SafeHtml = string & { readonly __safeHtmlBrand: unique symbol }
47
+
48
+ /** The single place the brand is applied. Module-private on purpose. */
49
+ function safeHtml(expr: string): SafeHtml {
50
+ return expr as SafeHtml
51
+ }
52
+
53
+ /**
54
+ * Emit a `${...}` hole. The parameter type is the whole mechanism: a builder
55
+ * that has only a `string` in hand cannot call this, so "forgot to escape"
56
+ * is a type error at the splice, not a runtime surprise in the browser.
57
+ */
58
+ export function interp(span: SafeHtml): string {
59
+ return `\${${span}}`
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Producers — escaping forms. Each wraps a runtime helper from
64
+ // `@barefootjs/client/runtime` (see `imports.ts`'s allowlist) that escapes
65
+ // its own input.
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /** `escapeText(expr)` — the default for any child-position value. */
69
+ export function escapedText(expr: string): SafeHtml {
70
+ return safeHtml(`escapeText(${expr})`)
71
+ }
72
+
73
+ /**
74
+ * `escapeTextOrMarkup(expr)` — `escapeText`'s strict superset that unwraps a
75
+ * `bfMarkup()`-branded value raw (#2651). Used only for a slot whose
76
+ * REACTIVE claim writer is `kind: 'markup'` (`markup-slots.ts`), so the
77
+ * initial render and the update agree on whether the slot may carry a
78
+ * compiler-built JSX prop value.
79
+ */
80
+ export function escapedTextOrMarkup(expr: string): SafeHtml {
81
+ return safeHtml(`escapeTextOrMarkup(${expr})`)
82
+ }
83
+
84
+ /**
85
+ * `__bfSlot(expr, slots)` — a conditional-branch `template()` value. The
86
+ * runtime returns raw `<!--bf-slot:N-->` markers for live Nodes (spliced
87
+ * back by `insert()`) and `escapeText`s every string itself
88
+ * (`branch-slot.ts`). Wrapping this in another escape corrupts the markers
89
+ * and drops slotted content (the #1694 regression) — hence a producer, not
90
+ * an input to `escapedText`.
91
+ */
92
+ export function branchSlotValue(expr: string, slotsVar: string): SafeHtml {
93
+ return safeHtml(`__bfSlot(${expr}, ${slotsVar})`)
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Producers — already-HTML forms. The value is markup the compiler or
98
+ // runtime built (and escaped piecewise) BEFORE it reaches the hole.
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * `markupOrEmpty(expr)` — a bare `{children}` passthrough (#2775). The value
103
+ * is the HTML string `materializeComponent` joined from the caller's
104
+ * children, or `undefined` when none were passed; the helper's entire job is
105
+ * the nullish case. Never escaped: escaping would render real child markup
106
+ * as visible `&lt;span&gt;` text.
107
+ */
108
+ export function childrenMarkup(expr: string): SafeHtml {
109
+ return safeHtml(`markupOrEmpty(${expr})`)
110
+ }
111
+
112
+ /**
113
+ * Stage 3 / D4 — an element-array child (`{out}`) built by an arbitrary
114
+ * `.map()` preamble is an array of compiled-leaf HTML strings; join it
115
+ * rather than let `${[...]}` `String`-comma-collapse it. Each leaf's own
116
+ * holes already went through `interp`, so the concatenation is exactly as
117
+ * safe as any other compiler-emitted fragment. Also used by
118
+ * `buildPreambleRegionPlans` for the region-patch effect's value so the
119
+ * re-render matches the row template byte-for-byte.
120
+ */
121
+ export function joinedMarkup(expr: string): SafeHtml {
122
+ return safeHtml(`Array.isArray(${expr}) ? ${expr}.join('') : (${expr} ?? '')`)
123
+ }
124
+
125
+ /**
126
+ * `renderChild('Name', props, ...scopeArgs)` — a child component rendered
127
+ * from its registered template at runtime. Its output is that component's
128
+ * own compiled template, whose holes went through this module in turn.
129
+ */
130
+ export function renderChildCall(registryName: string, propsExpr: string, tailArgs: string): SafeHtml {
131
+ return safeHtml(`renderChild('${registryName}', ${propsExpr}${tailArgs})`)
132
+ }
133
+
134
+ /**
135
+ * `dangerouslySetInnerHTML={{ __html: E }}` — the intentional, React-style,
136
+ * author-facing escape hatch, and the ONLY one: the element's content is
137
+ * whatever `E.__html` holds, unescaped by design (mirroring every SSR
138
+ * adapter's native handling). The name carries the warning so an audit of
139
+ * raw-markup producers (`grep dangerousInnerHtml`) finds it.
140
+ */
141
+ export function dangerousInnerHtml(expr: string): SafeHtml {
142
+ return safeHtml(`((${expr}) ?? {}).__html ?? ''`)
143
+ }
144
+
145
+ /**
146
+ * `cond ? \`whenTrue\` : \`whenFalse\`` — both branches are compiled
147
+ * template-literal BODIES (already-assembled HTML with `interp`ed holes),
148
+ * not values; the condition only selects between them.
149
+ */
150
+ export function conditionalMarkup(condition: string, whenTrue: string, whenFalse: string): SafeHtml {
151
+ return safeHtml(`${condition} ? \`${whenTrue}\` : \`${whenFalse}\``)
152
+ }
153
+
154
+ /**
155
+ * `array.method(params => body).join('')` — a loop rendered inline. `body`
156
+ * is either a compiled row template literal (\`...\`), a preamble block
157
+ * returning one, or a flatMap descriptor body rendered through
158
+ * `renderPreamble`; every hole inside it went through `interp`.
159
+ */
160
+ export function mappedRowsMarkup(arrayExpr: string, method: string, params: string, body: string): SafeHtml {
161
+ return safeHtml(`${arrayExpr}.${method}(${params} => ${body}).join('')`)
162
+ }
163
+
164
+ /** The empty string literal — a deferred placeholder the init effect fills (#1128). */
165
+ export const EMPTY_MARKUP: SafeHtml = safeHtml("''")
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // The door for a child-position expression node.
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /**
172
+ * Recognizes a JSX child-position expression that is exactly a reference to
173
+ * the reserved `children` prop — bare `children` (destructured) or
174
+ * `<receiver>.children` for any single-identifier receiver (`props.children`,
175
+ * a custom props-param name, a loop-scoped alias closing over props, ...).
176
+ *
177
+ * Deliberately LOOSER than `isTransparentFragment` (`jsx-to-ir.ts`), which
178
+ * runs on the TS AST with the analyzer-resolved props name in hand. This
179
+ * layer works on IR text with no analyzer, so any single-identifier receiver
180
+ * is the available approximation. The looseness costs nothing measurable: an
181
+ * unrelated `.children` member (a tree node's own `children` array, say) is a
182
+ * reactive member expression that gets a `slotId` and takes the slotted arm
183
+ * of `spliceChildValue` before this predicate is consulted.
184
+ */
185
+ function isChildrenPassthroughExpr(expr: string): boolean {
186
+ return /^([A-Za-z_$][\w$]*\.)?children$/.test(expr.trim())
187
+ }
188
+
189
+ export interface ChildSpliceContext {
190
+ /**
191
+ * Set while emitting inside a conditional-branch `template()` arrow
192
+ * (`irToHtmlTemplate`'s `branchSlotsVar`): every value in that context is
193
+ * routed through `__bfSlot`, which owns its own coercion.
194
+ */
195
+ readonly branchSlotsVar?: string
196
+ /**
197
+ * Slot ids whose reactive claim writer is `kind: 'markup'`
198
+ * (`markupSlotIdsOf(ctx)`, `markup-slots.ts`). Absent for the loop-row /
199
+ * branch-row builders, whose reactive twin is a `kind: 'text'` writer
200
+ * (`String(...)` into `nodeValue`) — plain `escapeText` is the consistent
201
+ * choice there, and it is also the choice that never unwraps a
202
+ * `bfMarkup()`-shaped object arriving in JSON-sourced loop data.
203
+ */
204
+ readonly markupSlotIds?: ReadonlySet<string>
205
+ }
206
+
207
+ /**
208
+ * THE door for a child-position `IRExpression` value entering a client HTML
209
+ * template. Takes the caller's already-wrapped/transformed expression text
210
+ * (`wrapExpr` / `transformExpr` output) and decides, in exactly one place,
211
+ * which producer it goes through. The last arm is the default, and it
212
+ * escapes: there is no arm that returns `valueExpr` unwrapped.
213
+ *
214
+ * Arms, first match wins:
215
+ * 1. `joinArrayChild` → `joinedMarkup` (array of compiled leaves)
216
+ * 2. `branchSlotsVar` → `branchSlotValue` (runtime coercion, may be a Node)
217
+ * 3. `slotId` ∈ markupSlotIds → `escapedTextOrMarkup`
218
+ * 4. `slotId` → `escapedText`
219
+ * 5. `{children}` passthrough → `childrenMarkup` (bare splice only, #2775/#2786)
220
+ * 6. otherwise → `escapedText`
221
+ *
222
+ * Arm 5 tests BOTH `node.expr` (the original source text, stable across
223
+ * builders) and the caller's resolved form (parens stripped): a
224
+ * destructured-and-renamed children (`const { children: kids } = props`)
225
+ * reads `kids` in the source and `(_p.children)` after substitution, and
226
+ * either alone misses one of the two shapes (#2786).
227
+ */
228
+ export function spliceChildValue(
229
+ node: Pick<IRExpression, 'expr' | 'slotId' | 'joinArrayChild'>,
230
+ valueExpr: string,
231
+ cx: ChildSpliceContext,
232
+ ): SafeHtml {
233
+ if (node.joinArrayChild) return joinedMarkup(valueExpr)
234
+ if (cx.branchSlotsVar) return branchSlotValue(valueExpr, cx.branchSlotsVar)
235
+ if (node.slotId) {
236
+ return cx.markupSlotIds?.has(node.slotId) ? escapedTextOrMarkup(valueExpr) : escapedText(valueExpr)
237
+ }
238
+ const resolved = valueExpr.trim().replace(/^\(+|\)+$/g, '')
239
+ if (isChildrenPassthroughExpr(node.expr) || isChildrenPassthroughExpr(resolved)) {
240
+ return childrenMarkup(valueExpr)
241
+ }
242
+ return escapedText(valueExpr)
243
+ }
@@ -581,17 +581,63 @@ function renderLoopBindingAccess(b: LoopParamBinding, base: string): string {
581
581
  * binding name is rewritten to `__bfItem()${path}` instead of wrapping the
582
582
  * raw pattern text. `paramName` is ignored in that case — destructured
583
583
  * callbacks never expose the pattern itself as a local.
584
- */
585
- export function wrapLoopParamAsAccessor(expr: string, paramName: string, bindings?: readonly LoopParamBinding[]): string {
584
+ *
585
+ * `indexParam` (#2859): the same `.map()` callback's index parameter
586
+ * (`(item, i) => ...`'s `i`), when the loop declares one. `mapArray`/
587
+ * `mapArrayAnchored` hand `renderItem` an INDEX ACCESSOR (mirroring the item
588
+ * accessor) precisely so a same-key reorder — which never re-invokes
589
+ * `renderItem`, only pushes fresh values through the item/index signals —
590
+ * can keep a row's index-derived output live. A bare reference to
591
+ * `indexParam` is therefore rewritten the same way as the item param; a
592
+ * caller that never threads an index through (e.g. no second callback
593
+ * param) simply omits it and behavior is unchanged. Rewritten as a SEPARATE
594
+ * pass after the item/bindings rewrite so an index name that happens to
595
+ * collide with a destructured binding name is a no-op here (the binding
596
+ * rewrite already consumed it; `!== paramName` below only guards the plain,
597
+ * non-destructured case, since `bindings` and `paramName` are mutually
598
+ * exclusive inputs to the item rewrite above).
599
+ */
600
+ export function wrapLoopParamAsAccessor(
601
+ expr: string,
602
+ paramName: string,
603
+ bindings?: readonly LoopParamBinding[],
604
+ indexParam?: string | null,
605
+ ): string {
606
+ let result: string
586
607
  if (bindings && bindings.length > 0) {
587
- return rewriteLoopBindingRefs(expr, bindings, '__bfItem()')
608
+ result = rewriteLoopBindingRefs(expr, bindings, '__bfItem()')
609
+ } else {
610
+ // `paramName` is a legal JS identifier and may itself start with `$`
611
+ // (`$1`, `$&`, …) — a plain string replacement would risk `String.replace`
612
+ // reading those as backreference/whole-match sequences, so use a replacer
613
+ // function to insert the accessor text literally (#2592).
614
+ const re = new RegExp(`${ID_BOUNDARY_BEFORE}${escapeIdentifierForRegex(paramName)}(?!\\s*\\()(?!-)${ID_BOUNDARY_AFTER}`, 'gu')
615
+ result = replaceInExprContexts(expr, re, () => `${paramName}()`)
616
+ }
617
+ if (indexParam && indexParam !== paramName) {
618
+ result = wrapIndexParamAsAccessor(result, indexParam)
588
619
  }
589
- // `paramName` is a legal JS identifier and may itself start with `$`
590
- // (`$1`, `$&`, …) — a plain string replacement would risk `String.replace`
591
- // reading those as backreference/whole-match sequences, so use a replacer
592
- // function to insert the accessor text literally (#2592).
593
- const re = new RegExp(`${ID_BOUNDARY_BEFORE}${escapeIdentifierForRegex(paramName)}(?!\\s*\\()(?!-)${ID_BOUNDARY_AFTER}`, 'gu')
594
- return replaceInExprContexts(expr, re, () => `${paramName}()`)
620
+ return result
621
+ }
622
+
623
+ /**
624
+ * The index-only half of `wrapLoopParamAsAccessor` — same regex shape, own
625
+ * identifier. Factored out so `wrapLoopParamAsAccessor` can run it as a
626
+ * second pass regardless of which item-rewrite branch (bindings vs. plain
627
+ * param) ran first (#2859).
628
+ *
629
+ * Also exported standalone for `build-loop.ts`/`build-branch-loop.ts`: a
630
+ * plain loop's `template`/`mapPreambleWrapped` are built ONCE, before lazy
631
+ * eligibility is known, because the lazy row plan reuses them verbatim when
632
+ * this loop turns out to be lazy-eligible (`mapArrayLazy`'s `createRow`
633
+ * still hands the row a plain index NUMBER, never an accessor). So those two
634
+ * builders wrap item references up front (safe either way) and apply this
635
+ * index-only pass afterward, ONLY once `buildLazyRowPlan` confirms the loop
636
+ * is NOT going lazy.
637
+ */
638
+ export function wrapIndexParamAsAccessor(expr: string, indexParam: string): string {
639
+ const re = new RegExp(`${ID_BOUNDARY_BEFORE}${escapeIdentifierForRegex(indexParam)}(?!\\s*\\()(?!-)${ID_BOUNDARY_AFTER}`, 'gu')
640
+ return replaceInExprContexts(expr, re, () => `${indexParam}()`)
595
641
  }
596
642
 
597
643
  /**
@@ -718,6 +764,8 @@ export function substituteLoopBindings(
718
764
  export interface LoopParamSpec {
719
765
  param: string
720
766
  bindings?: readonly LoopParamBinding[]
767
+ /** This loop's index parameter name, when it declares one (#2859). */
768
+ index?: string | null
721
769
  }
722
770
 
723
771
  /**
@@ -747,7 +795,7 @@ export function wrapExprWithLoopParams(expr: string, loopParams?: ReadonlyArray<
747
795
  let result = expr
748
796
  for (const p of loopParams) {
749
797
  const spec = typeof p === 'string' ? { param: p } : p
750
- result = wrapLoopParamAsAccessor(result, spec.param, spec.bindings)
798
+ result = wrapLoopParamAsAccessor(result, spec.param, spec.bindings, spec.index)
751
799
  }
752
800
  return result
753
801
  }
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,
@@ -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(
@@ -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
@@ -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