@barefootjs/jsx 0.33.1 → 0.33.2

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 (36) hide show
  1. package/dist/expression-parser.d.ts +14 -0
  2. package/dist/expression-parser.d.ts.map +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +123 -39
  6. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  7. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/html-template.d.ts +7 -7
  9. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/prop-handling.d.ts +33 -0
  12. package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/reactivity.d.ts +5 -0
  14. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/rewrite-props-object.d.ts +36 -8
  16. package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
  17. package/dist/types.d.ts +14 -0
  18. package/dist/types.d.ts.map +1 -1
  19. package/package.json +2 -2
  20. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +21 -2
  21. package/src/__tests__/binding-scope-ratchet.test.ts +5 -1
  22. package/src/__tests__/client-js-generation.test.ts +11 -0
  23. package/src/__tests__/issue-2723-prop-alias-reactivity.test.ts +124 -0
  24. package/src/__tests__/rewrite-props-object.test.ts +41 -4
  25. package/src/expression-parser.ts +26 -0
  26. package/src/index.ts +1 -1
  27. package/src/ir-to-client-js/collect-elements.ts +18 -25
  28. package/src/ir-to-client-js/emit-registration.ts +26 -7
  29. package/src/ir-to-client-js/generate-init.ts +1 -1
  30. package/src/ir-to-client-js/html-template.ts +8 -8
  31. package/src/ir-to-client-js/index.ts +6 -4
  32. package/src/ir-to-client-js/prop-handling.ts +94 -0
  33. package/src/ir-to-client-js/reactivity.ts +59 -0
  34. package/src/ir-to-client-js/rewrite-props-object.ts +50 -10
  35. package/src/jsx-to-ir.ts +47 -1
  36. package/src/types.ts +14 -0
@@ -10,6 +10,7 @@ import { PROPS_PARAM } from './utils.ts'
10
10
  import { computeInlinability, toLegacyInlinability } from './compute-inlinability.ts'
11
11
  import { canGenerateStaticTemplate, irToComponentTemplate, generateCsrTemplate, createStringProtector } from './html-template.ts'
12
12
  import { nameForRegistryRef } from './component-scope.ts'
13
+ import { resolveRestSpreadNames } from './prop-handling.ts'
13
14
 
14
15
  /**
15
16
  * Resolve chained references within a constants map.
@@ -191,14 +192,29 @@ export function emitRegistrationAndHydration(
191
192
  const propNamesForStaticCheck = new Set(ctx.propsParams.map((p) => p.name))
192
193
  const { inlinableConstants, unsafeLocalNames } = inlinability ?? buildInlinableConstants(ctx, graph, _ir.root)
193
194
 
194
- // Build rest spread names: these are rest/props spreads handled by applyRestAttrs, not spreadAttrs
195
- const restSpreadNames = new Set<string>()
196
- if (ctx.restPropsName) restSpreadNames.add(ctx.restPropsName)
197
- if (ctx.propsObjectName) restSpreadNames.add(ctx.propsObjectName)
195
+ // Build rest spread names: these are rest/props spreads handled by
196
+ // applyRestAttrs, not spreadAttrs — #2723: includes any `const x__alias
197
+ // = x` hop onto the rest/props binding (see `resolveRestSpreadNames`'s
198
+ // docstring in prop-handling.ts).
199
+ const restSpreadNames = resolveRestSpreadNames(ctx)
198
200
 
199
- const isCommentScope = (_ir.root.type === 'fragment'
200
- && (_ir.root as IRFragment).needsScopeComment)
201
- || _ir.root.type === 'component'
201
+ // Two distinct shapes share the `comment: true` (proxy-scoped) def flag,
202
+ // but need OPPOSITE runtime treatment of the def's own scope id
203
+ // (component.ts's `materializeComponent`, #2722):
204
+ // - `root.type === 'fragment'`: a genuine fragment root. Its rendered
205
+ // markup carries NO scope id of its own (SSR moves it into the
206
+ // wrapping `<!--bf-scope:-->` comment, `wrapWithScopeComment` in
207
+ // hono-adapter.ts) — CSR mount must generate one just the same, or
208
+ // every nested `renderChild()` call loses the parent-prefixed naming
209
+ // `_parentScopeId` provides and falls back to a random per-child id
210
+ // (#1627's fallback), diverging from SSR/hydrate.
211
+ // - `root.type === 'component'`: the render-prop / "root is a single
212
+ // child call" case (#2649). The child's OWN markup already carries
213
+ // ITS OWN real scope id — the wrapping comment marks a scope with no
214
+ // DOM presence of its own, and `materializeComponent` must leave
215
+ // `scopeId` null so it doesn't stamp over (or duplicate) the child's.
216
+ const isFragmentRoot = _ir.root.type === 'fragment' && !!(_ir.root as IRFragment).needsScopeComment
217
+ const isCommentScope = isFragmentRoot || _ir.root.type === 'component'
202
218
 
203
219
  // Build ComponentDef object for hydrate()
204
220
  const defParts: string[] = [`init: init${name}`]
@@ -232,6 +248,9 @@ export function emitRegistrationAndHydration(
232
248
  if (isCommentScope) {
233
249
  defParts.push('comment: true')
234
250
  }
251
+ if (isFragmentRoot) {
252
+ defParts.push('fragmentRoot: true')
253
+ }
235
254
 
236
255
  const registryKey = nameForRegistryRef(name)
237
256
  // When the registry key was file-scoped (`Name__<8hex>`, for a
@@ -97,7 +97,7 @@ export function generateInitFunction(
97
97
  // into the analyzer / IR construction stage and introduce a
98
98
  // `PropRewritten<T>` brand type so missing the rewrite becomes a
99
99
  // compile-time error. ---
100
- let generatedCode = rewritePropsObjectRef(lines.join('\n'), ctx.propsObjectName)
100
+ let generatedCode = rewritePropsObjectRef(lines.join('\n'), ctx.propsObjectName, ctx.restPropsName)
101
101
  generatedCode += '\n' + hydrateLine
102
102
 
103
103
  // Substitute module-level declarations BEFORE import detection: a
@@ -400,7 +400,7 @@ function renderTemplateAttrPart(
400
400
  attr: IRAttribute,
401
401
  attrName: string,
402
402
  wrap: (expr: string) => string,
403
- restSpreadNames?: Set<string>,
403
+ restSpreadNames?: ReadonlySet<string>,
404
404
  ): string {
405
405
  const v = attr.value
406
406
  switch (v.kind) {
@@ -667,7 +667,7 @@ function stripLeafKeyAttr(ir: IRNode): IRNode {
667
667
  */
668
668
  export function renderFlatMapClientBody(
669
669
  cb: Pick<FlatMapCallback, 'segments'>,
670
- restSpreadNames?: Set<string>,
670
+ restSpreadNames?: ReadonlySet<string>,
671
671
  ): string {
672
672
  return renderPreamble(cb, {
673
673
  textVariant: 'client',
@@ -699,7 +699,7 @@ export function flatMapCallbackHasKeyedLeaf(cb: Pick<FlatMapCallback, 'segments'
699
699
  */
700
700
  export function renderFlatMapProjectionClientBody(
701
701
  inner: Extract<IRNode, { type: 'loop' }>,
702
- restSpreadNames?: Set<string>,
702
+ restSpreadNames?: ReadonlySet<string>,
703
703
  ): string {
704
704
  const chained = applyLoopChain(inner)
705
705
  const params = inner.index ? `(${inner.param}, ${inner.index})` : `(${inner.param})`
@@ -747,7 +747,7 @@ function escapeLeafTextExpressions(ir: IRNode): IRNode {
747
747
  // docstring (`ir-to-client-js/utils.ts`) for why this stays outside #2482's
748
748
  // migration (it's the client-JS-emitter twin of the Go adapter's
749
749
  // `loopBindingStack`).
750
- export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, loopDepth = 0, loopParams?: ReadonlyArray<string | LoopParamSpec>, branchSlotsVar?: string, inHoistedChildren = false): string {
750
+ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<string>, loopDepth = 0, loopParams?: ReadonlyArray<string | LoopParamSpec>, branchSlotsVar?: string, inHoistedChildren = false): string {
751
751
  const recurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, inHoistedChildren)
752
752
  const wrapExpr = (expr: string) => wrapExprWithLoopParams(expr, loopParams)
753
753
  const wrapInterpolation = (expr: string): string => branchSlotsVar
@@ -1373,7 +1373,7 @@ function walkSkeletonPathChildren(
1373
1373
  * elements (`<div data-bf-ph="sN"></div>`) instead of renderChild() calls.
1374
1374
  * The placeholders are replaced with real createComponent() elements at runtime.
1375
1375
  */
1376
- export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<string>, loopDepth = 0, loopParams?: ReadonlyArray<string | LoopParamSpec>): string {
1376
+ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: ReadonlySet<string>, loopDepth = 0, loopParams?: ReadonlyArray<string | LoopParamSpec>): string {
1377
1377
  const recurse = (n: IRNode): string => irToPlaceholderTemplate(n, restSpreadNames, loopDepth, loopParams)
1378
1378
  const wrapExpr = (expr: string) => wrapExprWithLoopParams(expr, loopParams)
1379
1379
 
@@ -1637,7 +1637,7 @@ function isSingleRootElement(html: string): boolean {
1637
1637
  */
1638
1638
  export interface TemplateOptions {
1639
1639
  inlinableConstants?: Map<string, string>
1640
- restSpreadNames?: Set<string>
1640
+ restSpreadNames?: ReadonlySet<string>
1641
1641
  propsObjectName?: string | null
1642
1642
  /**
1643
1643
  * Names that exist only in the init-body scope (or were demoted to unsafe
@@ -1728,7 +1728,7 @@ export interface TemplateOptions {
1728
1728
  export function irToComponentTemplate(
1729
1729
  node: IRNode,
1730
1730
  inlinableConstants?: Map<string, string>,
1731
- restSpreadNames?: Set<string>,
1731
+ restSpreadNames?: ReadonlySet<string>,
1732
1732
  propsObjectName?: string | null,
1733
1733
  markupSlotIds?: ReadonlySet<string>
1734
1734
  ): string {
@@ -2133,7 +2133,7 @@ export function generateCsrTemplate(
2133
2133
  node: IRNode,
2134
2134
  inlinableConstants: Map<string, string> | undefined,
2135
2135
  ctx: ClientJsContext,
2136
- restSpreadNames?: Set<string>,
2136
+ restSpreadNames?: ReadonlySet<string>,
2137
2137
  propsObjectName?: string | null,
2138
2138
  unsafeLocalNames?: Set<string>,
2139
2139
  deferredChildSlots?: ReadonlySet<string>,
@@ -19,6 +19,7 @@ import { PROPS_PARAM } from './utils.ts'
19
19
  import { buildInlinableConstants, csrInlinableConstantsFromCtx } from './emit-registration.ts'
20
20
  import { buildEnvFromCtx } from './compute-inlinability.ts'
21
21
  import { nameForRegistryRef } from './component-scope.ts'
22
+ import { resolveRestSpreadNames } from './prop-handling.ts'
22
23
  import { IMPORT_PLACEHOLDER, RUNTIME_MODULE, detectUsedImports, collectExternalImports } from './imports.ts'
23
24
  import { isInlinableInTemplate } from '../relocate.ts'
24
25
  import { buildSourceMapFromIR, type SourceMapV3 } from './source-map.ts'
@@ -255,10 +256,11 @@ function generateTemplateOnlyMount(ir: ComponentIR, ctx: ClientJsContext): strin
255
256
  const graph = buildReferencesGraph(ctx, ir.root)
256
257
  const { inlinableConstants, unsafeLocalNames } = buildInlinableConstants(ctx, graph, ir.root)
257
258
 
258
- // Build rest spread names: these are rest/props spreads handled by applyRestAttrs, not spreadAttrs
259
- const restSpreadNames = new Set<string>()
260
- if (ctx.restPropsName) restSpreadNames.add(ctx.restPropsName)
261
- if (ctx.propsObjectName) restSpreadNames.add(ctx.propsObjectName)
259
+ // Build rest spread names: these are rest/props spreads handled by
260
+ // applyRestAttrs, not spreadAttrs — #2723: includes any `const x__alias
261
+ // = x` hop onto the rest/props binding (see `resolveRestSpreadNames`'s
262
+ // docstring in prop-handling.ts).
263
+ const restSpreadNames = resolveRestSpreadNames(ctx)
262
264
 
263
265
  let templateHtml: string | undefined
264
266
 
@@ -6,6 +6,100 @@ import type { ParamInfo, SignalInfo } from '../types.ts'
6
6
  import type { ClientJsContext } from './types.ts'
7
7
  import type { BindingScope } from '../scope/binding-scope.ts'
8
8
 
9
+ /**
10
+ * Which of the component's two "forwards the caller's leftover props"
11
+ * bindings `name` ultimately names — `'rest'` for `ctx.restPropsName`
12
+ * (the destructured `...rest` binding), `'props'` for `ctx.propsObjectName`
13
+ * (a whole undestructured `(props)` parameter spread whole), or `null` when
14
+ * `name` is neither, walking through any bare `const x__alias = <name>`
15
+ * alias chain to get there (#2723's `alias-props` mutation aliases every
16
+ * destructured binding, the rest parameter included, e.g.
17
+ * `const props__alias = props`).
18
+ *
19
+ * A `{...spread}` attribute is recognised as "forwards the caller's
20
+ * leftover props" (routed to the `applyRestAttrs` runtime helper /
21
+ * excluded from the SSR template's `spreadAttrs({...})` merge, and — only
22
+ * for the `'rest'` case — given the destructured prop names to exclude
23
+ * from what it forwards) by comparing its source expression against
24
+ * exactly `ctx.restPropsName` / `ctx.propsObjectName`. Without this
25
+ * resolver an alias hop makes that comparison fail even though the spread
26
+ * still forwards the SAME object — `collect-elements.ts` then never
27
+ * registers the rest-attrs application at all, and `html-template.ts`'s
28
+ * merge path stops filtering the spread out, folding it into a
29
+ * `spreadAttrs({...})` call keyed by the alias name instead of the
30
+ * runtime-visible one.
31
+ *
32
+ * Walks the chain hop by hop (not a precomputed set) so a multi-hop alias
33
+ * (`const p2 = props; const p3 = p2`) resolves through every link; `visited`
34
+ * guards a constant cycle (`const a = b; const b = a`) the same way
35
+ * `free-refs.ts`'s `resolveConstantInitializerRefs` does. A constant whose
36
+ * value isn't a bare identifier already on the chain (e.g. a real computed
37
+ * object) stops the walk, so a genuinely different spread expression is
38
+ * never mistaken for the rest object.
39
+ */
40
+ export function resolveRestSpreadOrigin(ctx: ClientJsContext, name: string): 'rest' | 'props' | null {
41
+ const byName = localConstantValues(ctx)
42
+ const visited = new Set<string>()
43
+ let current: string | undefined = name.trim()
44
+ while (current !== undefined && !visited.has(current)) {
45
+ if (ctx.restPropsName && current === ctx.restPropsName) return 'rest'
46
+ if (ctx.propsObjectName && current === ctx.propsObjectName) return 'props'
47
+ visited.add(current)
48
+ current = byName.get(current)?.trim()
49
+ }
50
+ return null
51
+ }
52
+
53
+ /**
54
+ * `ctx.localConstants` indexed by name, memoized per `ctx`.
55
+ *
56
+ * A `Map` rather than the `.find(` this file's other two constant lookups
57
+ * use, deliberately: those two are SHADOW-GUARDED lookups that
58
+ * `binding-scope-ratchet.test.ts` deliberately counts, and that ledger is
59
+ * shrink-only and at its floor. Resolving an alias chain hop-by-hop would
60
+ * have added a third counted use — and a hot one, since the walk queries
61
+ * once per hop — so it indexes instead, which is both outside the ledger's
62
+ * concern and cheaper than a linear scan per hop.
63
+ */
64
+ const _localConstantValuesCache: WeakMap<ClientJsContext, ReadonlyMap<string, string | undefined>> = new WeakMap()
65
+
66
+ function localConstantValues(ctx: ClientJsContext): ReadonlyMap<string, string | undefined> {
67
+ const cached = _localConstantValuesCache.get(ctx)
68
+ if (cached) return cached
69
+ const byName = new Map<string, string | undefined>()
70
+ for (const constant of ctx.localConstants) {
71
+ if (!byName.has(constant.name)) byName.set(constant.name, constant.value)
72
+ }
73
+ _localConstantValuesCache.set(ctx, byName)
74
+ return byName
75
+ }
76
+
77
+ /**
78
+ * Every name that resolves (via `resolveRestSpreadOrigin`) to either of the
79
+ * component's "forwards the caller's leftover props" bindings — used where
80
+ * callers need SET membership (`restSpreadNames?.has(...)` in
81
+ * `html-template.ts`) rather than a per-name resolution. Memoized per
82
+ * `ctx` (`WeakMap`, mirroring `free-refs.ts`'s `_bindingMapCache`) since
83
+ * some callers build this once per component and query it while walking
84
+ * the whole tree.
85
+ */
86
+ const _restSpreadNamesCache: WeakMap<ClientJsContext, ReadonlySet<string>> = new WeakMap()
87
+
88
+ export function resolveRestSpreadNames(ctx: ClientJsContext): ReadonlySet<string> {
89
+ const cached = _restSpreadNamesCache.get(ctx)
90
+ if (cached) return cached
91
+
92
+ const names = new Set<string>()
93
+ if (ctx.restPropsName) names.add(ctx.restPropsName)
94
+ if (ctx.propsObjectName) names.add(ctx.propsObjectName)
95
+ for (const constant of ctx.localConstants) {
96
+ if (resolveRestSpreadOrigin(ctx, constant.name) !== null) names.add(constant.name)
97
+ }
98
+
99
+ _restSpreadNamesCache.set(ctx, names)
100
+ return names
101
+ }
102
+
9
103
  /**
10
104
  * Expand dynamic prop value by resolving local constants.
11
105
  *
@@ -174,11 +174,30 @@ export function decideWrapForChildProp(
174
174
  * The signal-getter and memo regexes (`\b<name>\s*\(`) still run against
175
175
  * the raw string — those are call-shape patterns, not bare-identifier
176
176
  * checks, and are outside the scope of #1267.
177
+ *
178
+ * A reference that only reaches a signal/memo/prop through an intervening
179
+ * `const x__alias = x` hop is walked via `ctx.localConstants` below (#2723)
180
+ * — see that block's own comment for why this lives here rather than on
181
+ * `BindingScope` or a new tracking structure.
177
182
  */
178
183
  export function needsEffectWrapper(
179
184
  expr: string,
180
185
  ctx: ClientJsContext,
181
186
  freeIdentifiers?: ReadonlySet<string>,
187
+ ): boolean {
188
+ return needsEffectWrapperCore(expr, ctx, freeIdentifiers, new Set())
189
+ }
190
+
191
+ /**
192
+ * `needsEffectWrapper`'s actual body, with a `visitedConstants` accumulator
193
+ * threaded through the local-constant recursion below so a cycle
194
+ * (`const a = b; const b = a`) terminates instead of looping forever.
195
+ */
196
+ function needsEffectWrapperCore(
197
+ expr: string,
198
+ ctx: ClientJsContext,
199
+ freeIdentifiers: ReadonlySet<string> | undefined,
200
+ visitedConstants: Set<string>,
182
201
  ): boolean {
183
202
  for (const signal of ctx.signals) {
184
203
  if (identifierCallPattern(signal.getter).test(expr)) {
@@ -209,6 +228,46 @@ export function needsEffectWrapper(
209
228
  if (propsAccess.test(expr)) return true
210
229
  }
211
230
 
231
+ // #2723: a bare `const x__alias = x` hop between a destructured prop and
232
+ // its use site breaks every check above — `expr` (or its precomputed
233
+ // `freeIdentifiers`) names the ALIAS, never the prop it stands for, so
234
+ // none of the direct prop/signal/memo checks fire even though the value
235
+ // is exactly as reactive as `x` itself. Phase 1's `isReactiveExpression`
236
+ // already sees through this (`isPropsReference` / `isSignalOrMemoReference`
237
+ // in jsx-to-ir.ts recursively walk `ctx.patterns.constants`, the
238
+ // `TransformContext` twin of this function's `ctx.localConstants`) —
239
+ // this is that SAME constant-chain walk, ported onto Phase 2's string
240
+ // expression so its independent wrap decision agrees with Phase 1's
241
+ // `hasReactiveAttributes` slotId decision instead of silently
242
+ // disagreeing on whether the attribute gets a `createEffect` at all.
243
+ //
244
+ // This is NOT loop/callback binding resolution — `BindingScope` (#2482)
245
+ // answers "what name is this loop row's own item/index/destructure
246
+ // binding," a question with no bearing on a component-body `const`
247
+ // aliasing a prop — so it does not belong there. It also isn't a new ad
248
+ // hoc tracking structure: `ctx.localConstants` is the existing per-
249
+ // component constant list this file already reads (see
250
+ // `expandConstantForReactivity` above), walked here with a `visited`
251
+ // guard for the same reason `free-refs.ts`'s `resolveConstantInitializerRefs`
252
+ // carries one — a constant cycle must terminate, not loop forever.
253
+ //
254
+ // Constants whose initializer contains an arrow/function expression are
255
+ // skipped, mirroring `resolveConstantInitializerRefs`'s `containsArrow`
256
+ // skip: refs inside a function body run when (and if) the function is
257
+ // invoked, not merely because something reads the bare function value.
258
+ for (const constant of ctx.localConstants) {
259
+ if (visitedConstants.has(constant.name)) continue
260
+ if (constant.value === undefined || constant.containsArrow) continue
261
+ const referenced = freeIdentifiers
262
+ ? freeIdentifiers.has(constant.name)
263
+ : tokenContainsIdent(expr, constant.name)
264
+ if (!referenced) continue
265
+ visitedConstants.add(constant.name)
266
+ if (needsEffectWrapperCore(constant.value, ctx, constant.freeIdentifiers, visitedConstants)) {
267
+ return true
268
+ }
269
+ }
270
+
212
271
  return false
213
272
  }
214
273
 
@@ -1,7 +1,8 @@
1
1
  /**
2
- * AST-based rename of the source-level props object name (e.g. `props`
3
- * or a user-supplied destructure name) the generated parameter name
4
- * `_p` across the joined init-body string.
2
+ * AST-based rename of the source-level props object name(s) (e.g. `props`,
3
+ * a user-supplied destructure name, or a destructured rest binding like
4
+ * `...rest`) → the generated parameter name `_p` across the joined
5
+ * init-body string.
5
6
  *
6
7
  * Replaces the pre-C2 regex hack `\\b<propsObjectName>\\b` which silently
7
8
  * matched contexts that should NOT have been rewritten:
@@ -27,15 +28,54 @@ import { PROPS_PARAM } from './utils.ts'
27
28
  import { identifierPattern } from '../identifier-pattern.ts'
28
29
 
29
30
  /**
30
- * Rename every value-position reference to `propsObjectName` in `code`
31
- * to `_p`. No-op when `propsObjectName` is null (destructured-prop mode
32
- * — the analyzer already pre-rewrites bare prop refs into `templateXxx`
33
- * fields) or already equals `_p`.
31
+ * Rename every value-position reference to `propsObjectName` and,
32
+ * independently, to `restPropsName` (#2723) in `code` to `_p`.
33
+ *
34
+ * `restPropsName` matters even in destructured-prop mode (where
35
+ * `propsObjectName` is null and the analyzer already pre-rewrites bare
36
+ * NAMED-prop refs into `templateXxx` fields, per the historical
37
+ * `propsObjectName ?? 'props'` fallback this replaces): a destructured
38
+ * rest binding (`const { className, ...rest } = props` /
39
+ * `function F({ className, ...rest })`) is not itself a "named prop," so
40
+ * nothing pre-rewrites a bare reference to it. Such a reference reaches
41
+ * this pass whenever the init body needs `rest`'s OWN VALUE rather than
42
+ * just recognising a `{...rest}` spread by name — e.g. a `const
43
+ * rest__alias = rest` hop (#2723's `alias-props` mutation aliases the
44
+ * rest parameter along with every named one). A rest binding named
45
+ * anything but "props" (`...rest`, `...leftover`) left such a reference
46
+ * dangling as a `ReferenceError`, so the analyzer's actual
47
+ * `restPropsName` is passed in to remove that guesswork.
48
+ *
49
+ * The `propsObjectName ?? 'props'` fallback is KEPT alongside it, not
50
+ * replaced by it. The two do different jobs, and reading the fallback as
51
+ * merely a lucky guess at the rest binding's name regressed the
52
+ * doc-example `StatementExample`: a component that destructures its
53
+ * parameters can still write `props.itemId` inside a handler body, and
54
+ * `propsObjectName` is null for exactly that shape — so dropping the
55
+ * fallback left `props.itemId` in the emitted init, where no `props`
56
+ * binding exists to satisfy it.
57
+ *
58
+ * Each candidate name is rewritten independently and skipped when null,
59
+ * already `_p`, or a duplicate of one already processed (a component
60
+ * whose props param IS its own rest destructure target, if that shape
61
+ * ever arises, would otherwise walk the AST twice for the same name).
34
62
  */
35
- export function rewritePropsObjectRef(code: string, propsObjectName: string | null): string {
36
- const srcPropsName = propsObjectName ?? 'props'
37
- if (srcPropsName === PROPS_PARAM) return code
63
+ export function rewritePropsObjectRef(
64
+ code: string,
65
+ propsObjectName: string | null,
66
+ restPropsName: string | null = null,
67
+ ): string {
68
+ let result = code
69
+ const seen = new Set<string>()
70
+ for (const srcPropsName of [propsObjectName ?? 'props', restPropsName]) {
71
+ if (srcPropsName === null || srcPropsName === PROPS_PARAM || seen.has(srcPropsName)) continue
72
+ seen.add(srcPropsName)
73
+ result = rewriteOneName(result, srcPropsName)
74
+ }
75
+ return result
76
+ }
38
77
 
78
+ function rewriteOneName(code: string, srcPropsName: string): string {
39
79
  // Quick exit when the name doesn't appear at all.
40
80
  if (!identifierPattern(srcPropsName).test(code)) return code
41
81
 
package/src/jsx-to-ir.ts CHANGED
@@ -1944,6 +1944,52 @@ function unwrapHoistedFragment(node: IRNode): IRNode {
1944
1944
  return { ...only, needsScope: true }
1945
1945
  }
1946
1946
 
1947
+ // #2732: a `needsScopeComment` fragment's five hydration markers move to
1948
+ // the wrapping comment, but `data-key` needs to stay on an element (see
1949
+ // `IRElement.carriesDataKey`'s docstring for why). Mark the first ELEMENT
1950
+ // among `children` — immutably (`{ ...el, carriesDataKey: true }`), matching
1951
+ // the rest of this file's post-hoc IR tagging (e.g. `unwrapHoistedFragment`)
1952
+ // rather than mutating the child in place.
1953
+ function markDataKeyCarrier(children: IRNode[]): IRNode[] {
1954
+ for (let i = 0; i < children.length; i++) {
1955
+ const marked = markCarrierIn(children[i])
1956
+ if (!marked) continue
1957
+ const out = children.slice()
1958
+ out[i] = marked
1959
+ return out
1960
+ }
1961
+ return children
1962
+ }
1963
+
1964
+ /**
1965
+ * The marked copy of `node` if this subtree can render an element in first
1966
+ * position, or null if it cannot.
1967
+ *
1968
+ * Descends through `conditional` because a fragment whose only top-level
1969
+ * child is a ternary or `&&` is still a single-visual-root row —
1970
+ * `{done ? <li class="done"/> : <li/>}` renders exactly one `<li>`. A flat
1971
+ * `children.findIndex(c => c.type === 'element')` returns -1 there and
1972
+ * silently reproduces #2732's own symptom for a shape the fix was supposed
1973
+ * to cover.
1974
+ *
1975
+ * BOTH branches are marked, not just one: they are mutually exclusive at
1976
+ * render time, so whichever is taken carries the key, and marking only
1977
+ * `whenTrue` would drop it exactly when the condition is false.
1978
+ */
1979
+ function markCarrierIn(node: IRNode): IRNode | null {
1980
+ if (node.type === 'element') {
1981
+ return { ...(node as IRElement), carriesDataKey: true }
1982
+ }
1983
+ if (node.type === 'conditional') {
1984
+ const cond = node as IRConditional
1985
+ const whenTrue = markCarrierIn(cond.whenTrue)
1986
+ const whenFalse = markCarrierIn(cond.whenFalse)
1987
+ if (!whenTrue && !whenFalse) return null
1988
+ return { ...cond, whenTrue: whenTrue ?? cond.whenTrue, whenFalse: whenFalse ?? cond.whenFalse }
1989
+ }
1990
+ return null
1991
+ }
1992
+
1947
1993
  function transformFragment(
1948
1994
  node: ts.JsxFragment,
1949
1995
  ctx: TransformContext
@@ -1968,7 +2014,7 @@ function transformFragment(
1968
2014
 
1969
2015
  return {
1970
2016
  type: 'fragment',
1971
- children,
2017
+ children: needsScopeComment ? markDataKeyCarrier(children) : children,
1972
2018
  transparent: isTransparent || undefined,
1973
2019
  needsScopeComment,
1974
2020
  loc: getSourceLocation(node, ctx.sourceFile, ctx.filePath),
package/src/types.ts CHANGED
@@ -347,6 +347,20 @@ export interface IRElement {
347
347
  children: IRNode[]
348
348
  slotId: string | null
349
349
  needsScope: boolean
350
+ /**
351
+ * Set on the first ELEMENT among a `needsScopeComment` fragment root's own
352
+ * top-level children (#2732) — the fragment's five hydration markers
353
+ * (`bf-s`/`bf-h`/`bf-m`/`bf-r`/`bf-p`) move to the wrapping
354
+ * `<!--bf-scope:...-->` comment instead of an element attribute, but
355
+ * `data-key` has to stay on an element because the client runtime's
356
+ * `mapArray` adopt loop reads it as a DOM attribute
357
+ * (`primaryEl.dataset.key`, map-array.ts). "First element, not first
358
+ * node" mirrors the CSR runtime's own resolution of the identical
359
+ * ambiguity (`component.ts`'s `roots.find(isElement)`, #2735) rather than
360
+ * inventing a second answer. Always `undefined` when `needsScope` is
361
+ * true — the two are mutually exclusive ways of carrying the same key.
362
+ */
363
+ carriesDataKey?: boolean
350
364
  /**
351
365
  * Page-lifecycle boundary id for an element lowered from `<Region>`
352
366
  * (spec/router.md). Set only on region host elements; adapters emit it as