@barefootjs/jsx 0.33.0 → 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 (75) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/errors.d.ts +1 -0
  3. package/dist/errors.d.ts.map +1 -1
  4. package/dist/expression-parser.d.ts +14 -0
  5. package/dist/expression-parser.d.ts.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +268 -79
  9. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +8 -0
  12. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts +12 -0
  15. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +1 -0
  17. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/element-refs.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/html-template.d.ts +7 -7
  23. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/imports.d.ts +2 -2
  25. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/phases/provider-and-child-inits.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/prop-handling.d.ts +33 -0
  29. package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/reactivity.d.ts +5 -0
  31. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  32. package/dist/ir-to-client-js/rewrite-props-object.d.ts +36 -8
  33. package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
  34. package/dist/ir-to-client-js/types.d.ts +21 -0
  35. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  36. package/dist/types.d.ts +14 -0
  37. package/dist/types.d.ts.map +1 -1
  38. package/package.json +2 -2
  39. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +23 -6
  40. package/src/__tests__/binding-scope-ratchet.test.ts +5 -1
  41. package/src/__tests__/child-components-in-map.test.ts +11 -3
  42. package/src/__tests__/client-js-generation.test.ts +48 -1
  43. package/src/__tests__/inline-jsx-callback.test.ts +55 -0
  44. package/src/__tests__/ir-jsx-props.test.ts +148 -0
  45. package/src/__tests__/issue-2705-branch-inner-loop-container.test.ts +91 -0
  46. package/src/__tests__/issue-2723-prop-alias-reactivity.test.ts +124 -0
  47. package/src/__tests__/markup-prop-brand.test.ts +49 -0
  48. package/src/__tests__/nested-loop-conditional.test.ts +20 -11
  49. package/src/__tests__/return-through-local-var.test.ts +269 -0
  50. package/src/__tests__/rewrite-props-object.test.ts +41 -4
  51. package/src/analyzer.ts +71 -0
  52. package/src/errors.ts +17 -1
  53. package/src/expression-parser.ts +26 -0
  54. package/src/index.ts +1 -1
  55. package/src/ir-to-client-js/collect-elements.ts +49 -45
  56. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +19 -0
  57. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +22 -3
  58. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +5 -2
  59. package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +12 -0
  60. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +9 -0
  61. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +7 -1
  62. package/src/ir-to-client-js/element-refs.ts +8 -0
  63. package/src/ir-to-client-js/emit-reactive.ts +91 -23
  64. package/src/ir-to-client-js/emit-registration.ts +26 -7
  65. package/src/ir-to-client-js/generate-init.ts +1 -1
  66. package/src/ir-to-client-js/html-template.ts +8 -8
  67. package/src/ir-to-client-js/imports.ts +5 -0
  68. package/src/ir-to-client-js/index.ts +10 -4
  69. package/src/ir-to-client-js/phases/provider-and-child-inits.ts +5 -1
  70. package/src/ir-to-client-js/prop-handling.ts +94 -0
  71. package/src/ir-to-client-js/reactivity.ts +59 -0
  72. package/src/ir-to-client-js/rewrite-props-object.ts +50 -10
  73. package/src/ir-to-client-js/types.ts +21 -0
  74. package/src/jsx-to-ir.ts +204 -9
  75. package/src/types.ts +14 -0
@@ -28,6 +28,57 @@ function bindingIdArg(ctx: ClientJsContext, slotId: string | undefined): string
28
28
  return `, ${JSON.stringify(`${ctx.componentName}#binding:${slotId}`)}`
29
29
  }
30
30
 
31
+ /**
32
+ * Generate statements that write a `value` HTML ATTRIBUTE the developer
33
+ * wrote directly on an element (`<div value={x}>`, a loop row's `<li
34
+ * value={x}>`, …) — SSR renders that same attribute, so hydration keeping
35
+ * it in sync is exactly the contract. Gated at runtime to elements that
36
+ * ALREADY expose a native `.value` IDL property (`'value' in target` —
37
+ * form controls, but also e.g. `<li value>`; the same duck-type check
38
+ * `applyRestAttrs` uses, deliberately not a tag-name allowlist), because
39
+ * for those `setAttribute('value', x)` only sets the INITIAL HTML
40
+ * attribute and the live property is required after user interaction; any
41
+ * other element falls back to a plain attribute write, which still matches
42
+ * what SSR rendered there. Writing the live property unconditionally would
43
+ * plant an expando SSR never had — a hydrated/SSR DOM-state divergence and a
44
+ * hazard for anything that duck-types form controls via `'value' in el`
45
+ * (#2716).
46
+ *
47
+ * NOT for the child-component-root `value` MIRROR (`emitReactivePropBindings`
48
+ * / `emitReactiveChildProps` reflecting a named prop onto a child's root
49
+ * element) — that mechanism has no SSR-rendered counterpart at all
50
+ * regardless of prop name, so an attribute fallback there would itself
51
+ * plant a fresh SSR/hydrate divergence; see `emitChildValueMirrorStatements`.
52
+ */
53
+ function emitValueUpdateStatements(target: string, expression: string): string[] {
54
+ return [
55
+ `const __val = String(${expression})`,
56
+ `if ('value' in ${target}) { if (${target}.value !== __val) ${target}.value = __val } else { ${target}.setAttribute('value', __val) }`,
57
+ ]
58
+ }
59
+
60
+ /**
61
+ * `value`-prop write for the CHILD-ROOT MIRROR mechanism
62
+ * (`emitReactivePropBindings` / `emitReactiveChildProps`, both reactively
63
+ * reflect a parent-passed NAMED PROP onto a child component's root DOM
64
+ * element). Unlike a developer-authored `value=` attribute
65
+ * (`emitValueUpdateStatements`), this mirror has NO SSR-rendered
66
+ * counterpart at all — SSR never puts a `value` attribute on a child's
67
+ * root just because the parent passed a `value` prop. So a root WITHOUT a
68
+ * native `.value` property gets NOTHING written — not even an attribute
69
+ * (confirmed against the oracle's structural-HTML comparison, #2716: an
70
+ * attribute fallback here reintroduced a fresh SSR/hydrate divergence one
71
+ * layer down from the IDL-property expando this replaces). A root that
72
+ * already exposes `.value` (`'value' in target`, same duck-type gate as
73
+ * the direct-attribute case) still gets the live controlled-value
74
+ * property.
75
+ */
76
+ function emitChildValueMirrorStatements(target: string, expression: string): string[] {
77
+ return [
78
+ `if ('value' in ${target}) { const __val = String(${expression}); if (${target}.value !== __val) ${target}.value = __val }`,
79
+ ]
80
+ }
81
+
31
82
  /**
32
83
  * Generate JS statements to update a DOM attribute reactively.
33
84
  * Centralizes the attribute-type dispatch (value, class, boolean, presence, generic)
@@ -55,10 +106,7 @@ export function emitAttrUpdate(target: string, attrName: string, expression: str
55
106
  ]
56
107
  }
57
108
  if (htmlName === 'value') {
58
- return [
59
- `const __val = String(${expression})`,
60
- `if (${target}.value !== __val) ${target}.value = __val`,
61
- ]
109
+ return emitValueUpdateStatements(target, expression)
62
110
  }
63
111
  if (isBooleanAttr(htmlName)) {
64
112
  return [`${target}.${htmlName} = !!(${expression})`]
@@ -462,37 +510,43 @@ export function emitReactivePropBindings(lines: string[], ctx: ClientJsContext):
462
510
  }
463
511
 
464
512
  for (const [slotId, props] of propsBySlot) {
465
- const v = varSlotId(slotId)
466
- lines.push(` if (_${v}) {`)
513
+ // The component's own `comment: true` root child IS `__scope` — no
514
+ // `$c` ref was declared for it (element-refs.ts), so reference
515
+ // `__scope` directly instead of a `_sN` var that doesn't exist
516
+ // (#2649, see `ClientJsContext.commentScopeRootSlotId`'s docstring).
517
+ const ref = slotId === ctx.commentScopeRootSlotId ? '__scope' : `_${varSlotId(slotId)}`
518
+ lines.push(` if (${ref}) {`)
467
519
  for (const prop of props) {
468
520
  const value = `${prop.expression}()`
469
521
  if (prop.propName === 'selected') {
470
522
  if (prop.componentName === 'TabsContent') {
471
- lines.push(` _${v}.setAttribute('data-state', ${value} ? 'active' : 'inactive')`)
523
+ lines.push(` ${ref}.setAttribute('data-state', ${value} ? 'active' : 'inactive')`)
472
524
  lines.push(` if (${value}) {`)
473
- lines.push(` _${v}.classList.remove('hidden')`)
525
+ lines.push(` ${ref}.classList.remove('hidden')`)
474
526
  lines.push(` } else {`)
475
- lines.push(` _${v}.classList.add('hidden')`)
527
+ lines.push(` ${ref}.classList.add('hidden')`)
476
528
  lines.push(` }`)
477
529
  } else {
478
530
  // Update data-state and aria-selected attributes.
479
531
  // Visual styling is driven by CSS data-[state=active/inactive]: selectors.
480
- lines.push(` _${v}.setAttribute('aria-selected', String(${value}))`)
481
- lines.push(` _${v}.setAttribute('data-state', ${value} ? 'active' : 'inactive')`)
482
- lines.push(` _${v}.setAttribute('tabindex', ${value} ? '0' : '-1')`)
532
+ lines.push(` ${ref}.setAttribute('aria-selected', String(${value}))`)
533
+ lines.push(` ${ref}.setAttribute('data-state', ${value} ? 'active' : 'inactive')`)
534
+ lines.push(` ${ref}.setAttribute('tabindex', ${value} ? '0' : '-1')`)
483
535
  }
484
- // Use DOM property assignment for value and boolean attrs.
485
- // setAttribute('value', x) only sets the initial HTML attribute; after user
486
- // interaction the DOM property diverges, so .value = x is required.
536
+ // Use DOM property assignment for value and boolean attrs, but only
537
+ // on genuine form controls see `emitChildValueMirrorStatements`'s
538
+ // docstring (#2716). `ref` here is a named prop's MIRROR target
539
+ // element (a child component's arbitrary root), not necessarily a
540
+ // form control, and this mirror has no SSR-rendered counterpart to
541
+ // fall back to.
487
542
  // Boolean attrs (disabled, checked, etc.) treat any attribute presence as
488
543
  // truthy, so setAttribute('disabled', 'false') still disables the element.
489
544
  } else if (prop.propName === 'value') {
490
- lines.push(` const __val = String(${value})`)
491
- lines.push(` if (_${v}.value !== __val) _${v}.value = __val`)
545
+ for (const stmt of emitChildValueMirrorStatements(ref, value)) lines.push(` ${stmt}`)
492
546
  } else if (isBooleanAttr(prop.propName)) {
493
- lines.push(` _${v}.${prop.propName} = !!(${value})`)
547
+ lines.push(` ${ref}.${prop.propName} = !!(${value})`)
494
548
  } else {
495
- lines.push(` _${v}.setAttribute('${prop.propName}', String(${value}))`)
549
+ lines.push(` ${ref}.setAttribute('${prop.propName}', String(${value}))`)
496
550
  }
497
551
  }
498
552
  lines.push(` }`)
@@ -520,13 +574,27 @@ export function emitReactiveChildProps(lines: string[], ctx: ClientJsContext): v
520
574
 
521
575
  for (const [, props] of propsByComponent) {
522
576
  const first = props[0]
577
+ // The component's own `comment: true` root child IS `__scope` — query
578
+ // it directly rather than through `$c`, which cannot tell "I already
579
+ // am this slot" apart from a coincidentally-matching descendant
580
+ // (#2649, see `ClientJsContext.commentScopeRootSlotId`'s docstring).
581
+ const isCommentRoot = first.slotId !== null && first.slotId === ctx.commentScopeRootSlotId
523
582
  const varSuffix = first.slotId ? varSlotId(first.slotId).replace(/-/g, '_') : first.componentName
524
- const varName = `__${first.componentName}_${varSuffix}El`
525
- const selectorArg = first.slotId ? first.slotId : first.componentName
526
- lines.push(` const [${varName}] = $c(__scope, '${selectorArg}')`)
583
+ const varName = isCommentRoot ? '__scope' : `__${first.componentName}_${varSuffix}El`
584
+ if (!isCommentRoot) {
585
+ const selectorArg = first.slotId ? first.slotId : first.componentName
586
+ lines.push(` const [${varName}] = $c(__scope, '${selectorArg}')`)
587
+ }
527
588
  lines.push(` if (${varName}) {`)
528
589
  for (const prop of props) {
529
- for (const stmt of emitAttrUpdate(varName, prop.attrName, prop.expression, prop)) {
590
+ // `value` is the CHILD-ROOT MIRROR case, not a developer-authored
591
+ // attribute — route it through the no-SSR-fallback helper instead
592
+ // of `emitAttrUpdate`'s generic (attribute-fallback) dispatch;
593
+ // see `emitChildValueMirrorStatements`'s docstring (#2716).
594
+ const stmts = toHtmlAttrName(prop.attrName) === 'value'
595
+ ? emitChildValueMirrorStatements(varName, prop.expression)
596
+ : emitAttrUpdate(varName, prop.attrName, prop.expression, prop)
597
+ for (const stmt of stmts) {
530
598
  lines.push(` ${stmt}`)
531
599
  }
532
600
  }
@@ -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>,
@@ -11,6 +11,11 @@ import { identifierCallPattern } from '../identifier-pattern.ts'
11
11
  export const RUNTIME_IMPORT_CANDIDATES = [
12
12
  'createSignal', 'createMemo', 'createEffect', 'onCleanup', 'onMount',
13
13
  'hydrate', 'insert', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'mapArrayAnchored', 'mapArrayLazy', 'patchLeaf', 'createDisposableEffect',
14
+ // Resolves the real DOM container for a loop nested inside a loop-row
15
+ // conditional's branch when the conditional's wrapper element carries no
16
+ // `bf="<slot>"` marker of its own (#2705) — see `findCondContainer`'s
17
+ // docstring (runtime/insert.ts) for why the marker collector can't see it.
18
+ 'findCondContainer',
14
19
  'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild',
15
20
  // Connects a template-clone loop row before the body's tail runs, so a child
16
21
  // that inits inside it resolves context against real ancestors rather than
@@ -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'
@@ -185,6 +186,10 @@ function createContext(
185
186
  refElements: [],
186
187
  childInits: [],
187
188
  deferredChildSlots: new Set(),
189
+ // Mirrors `emit-registration.ts`'s `isCommentScope` component-root
190
+ // disjunct (not the fragment disjunct — that case is disambiguated at
191
+ // runtime by `commentScopeRegistry`, not by this slot-id shortcut).
192
+ commentScopeRootSlotId: ir.root.type === 'component' ? ir.root.slotId : null,
188
193
  reactiveProps: [],
189
194
  reactiveChildProps: [],
190
195
  reactiveAttrs: [],
@@ -251,10 +256,11 @@ function generateTemplateOnlyMount(ir: ComponentIR, ctx: ClientJsContext): strin
251
256
  const graph = buildReferencesGraph(ctx, ir.root)
252
257
  const { inlinableConstants, unsafeLocalNames } = buildInlinableConstants(ctx, graph, ir.root)
253
258
 
254
- // Build rest spread names: these are rest/props spreads handled by applyRestAttrs, not spreadAttrs
255
- const restSpreadNames = new Set<string>()
256
- if (ctx.restPropsName) restSpreadNames.add(ctx.restPropsName)
257
- 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)
258
264
 
259
265
  let templateHtml: string | undefined
260
266
 
@@ -39,7 +39,11 @@ export function emitProviderAndChildInits(lines: string[], ctx: ClientJsContext)
39
39
  lines.push(` upsertChild(__scope, '${registryName}', '${child.slotId}', ${child.propsExpr})`)
40
40
  continue
41
41
  }
42
- const scopeRef = child.slotId ? `_${varSlotId(child.slotId)}` : '__scope'
42
+ // The component's own `comment: true` root child IS `__scope` (no
43
+ // separate DOM node exists for it to be looked up at) — see
44
+ // `ClientJsContext.commentScopeRootSlotId`'s docstring (#2649).
45
+ const isCommentRoot = child.slotId !== null && child.slotId === ctx.commentScopeRootSlotId
46
+ const scopeRef = !child.slotId || isCommentRoot ? '__scope' : `_${varSlotId(child.slotId)}`
43
47
  lines.push(` initChild('${registryName}', ${scopeRef}, ${child.propsExpr})`)
44
48
  }
45
49
  }
@@ -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
 
@@ -102,6 +102,27 @@ export interface ClientJsContext {
102
102
  * registration-template emit so both agree on which children defer.
103
103
  */
104
104
  deferredChildSlots: Set<string>
105
+ /**
106
+ * Slot id of the component's own root, when the ENTIRE render is a single
107
+ * child component call (`ir.root.type === 'component'` — the IR shape is
108
+ * the sole source of truth; `emit-registration.ts`'s `isCommentScope`
109
+ * derives the def's `comment: true` from the same shape). Such a child
110
+ * never gets its own DOM node: `materializeComponent`/`renderChild` leave
111
+ * `bf-s` unset and the child's markup becomes `__scope` itself (#2649).
112
+ * A generic `$c(__scope, slotId)` lookup for this one slot is therefore
113
+ * not just redundant but actively ambiguous — a genuine grandchild
114
+ * nested inside it can derive a `bf-s` whose suffix collides with this
115
+ * same slot id (see `component.ts`'s `_parentScopeId` push), and
116
+ * `$cSingle` cannot tell "I already am this slot" apart from "a
117
+ * coincidentally-matching descendant is this slot" from the DOM alone.
118
+ * Emission sites that would otherwise query this slot via `$c`
119
+ * (`element-refs.ts`, `provider-and-child-inits.ts`,
120
+ * `emit-reactive.ts`) use `__scope` directly instead, sidestepping the
121
+ * ambiguity entirely rather than trying to make the query precise
122
+ * enough to resolve it. `null` for a component whose root is a regular
123
+ * element/fragment (the overwhelmingly common case).
124
+ */
125
+ commentScopeRootSlotId: string | null
105
126
  reactiveProps: ReactiveComponentProp[]
106
127
  reactiveChildProps: ReactiveChildProp[]
107
128
  reactiveAttrs: ReactiveAttribute[]