@barefootjs/jsx 0.33.2 → 0.33.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/analyzer.d.ts +17 -0
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/compiler.d.ts +21 -5
  4. package/dist/compiler.d.ts.map +1 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +804 -445
  8. package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
  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-component-loop.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +2 -4
  12. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/stringify/component-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/imports.d.ts +60 -2
  18. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/prop-handling.d.ts +4 -7
  20. package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/utils.d.ts +26 -2
  22. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  23. package/dist/jsx-to-ir.d.ts.map +1 -1
  24. package/dist/props-binding.d.ts +35 -0
  25. package/dist/props-binding.d.ts.map +1 -1
  26. package/dist/types.d.ts +63 -13
  27. package/dist/types.d.ts.map +1 -1
  28. package/package.json +2 -2
  29. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +145 -97
  30. package/src/__tests__/child-component-ref-not-mirrored.test.ts +90 -0
  31. package/src/__tests__/fragment-body-loop-key.test.ts +95 -0
  32. package/src/__tests__/ir-to-client-js/imports.test.ts +107 -0
  33. package/src/__tests__/ir-to-client-js/merge-compiled-client-js-imports.test.ts +138 -0
  34. package/src/__tests__/issue-2754-rest-spread-needs-slot.test.ts +85 -0
  35. package/src/__tests__/issue-2756-loop-row-honors-client-only.test.ts +173 -0
  36. package/src/__tests__/merge-template-imports.test.ts +41 -1
  37. package/src/__tests__/multi-component-shared-default-import.test.ts +55 -0
  38. package/src/__tests__/multi-root-loop-body.test.ts +7 -3
  39. package/src/__tests__/preamble-declarations.test.ts +42 -0
  40. package/src/__tests__/root-key-relay.test.ts +170 -0
  41. package/src/__tests__/signal-getter-not-called.test.ts +149 -0
  42. package/src/__tests__/state-only-file-default-import.test.ts +47 -0
  43. package/src/analyzer.ts +36 -0
  44. package/src/compiler.ts +94 -104
  45. package/src/index.ts +1 -1
  46. package/src/ir-to-client-js/build-references.ts +7 -0
  47. package/src/ir-to-client-js/collect-elements.ts +27 -5
  48. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +19 -1
  49. package/src/ir-to-client-js/control-flow/plan/loop.ts +2 -4
  50. package/src/ir-to-client-js/control-flow/stringify/component-loop.ts +7 -0
  51. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +6 -2
  52. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -2
  53. package/src/ir-to-client-js/control-flow.ts +12 -0
  54. package/src/ir-to-client-js/html-template.ts +174 -23
  55. package/src/ir-to-client-js/imports.ts +178 -5
  56. package/src/ir-to-client-js/index.ts +5 -0
  57. package/src/ir-to-client-js/prop-handling.ts +6 -17
  58. package/src/ir-to-client-js/utils.ts +30 -2
  59. package/src/jsx-to-ir.ts +592 -58
  60. package/src/props-binding.ts +51 -0
  61. package/src/types.ts +60 -13
package/src/jsx-to-ir.ts CHANGED
@@ -49,7 +49,7 @@ import {
49
49
  rewriteBarePropRefs as rewriteBarePropRefsCore,
50
50
  collectAstPropRefs,
51
51
  } from './prop-rewrite.ts'
52
- import { buildPropAliasMap } from './props-binding.ts'
52
+ import { buildPropAliasMap, resolveRestSpreadOriginCore } from './props-binding.ts'
53
53
  import { resolveFreeRefs, isNameBound as isNameBoundInEnv, type BindingEnvironment } from './free-refs.ts'
54
54
  import { computeFileScope } from './ir-to-client-js/component-scope.ts'
55
55
  import { createTemplateAwareStringProtector } from './ir-to-client-js/html-template.ts'
@@ -61,7 +61,7 @@ import { extractFreeIdentifiersFromNode, initializerShapeContainsJsx, extractMul
61
61
  import { iterateJsTokens, replaceInExprContexts } from './scanner/js-scanner.ts'
62
62
  import { reconstructAsSegments } from './strip-types.ts'
63
63
  import { templatePartsToJsExpr } from './template-parts.ts'
64
- import { toHTMLAttrName, decodeEntities } from '@barefootjs/shared'
64
+ import { toHTMLAttrName, decodeEntities, BF_KEY, keyAttrName } from '@barefootjs/shared'
65
65
  import { BindingScope } from './scope/binding-scope.ts'
66
66
  import { identifierPattern, identifierCallPattern } from './identifier-pattern.ts'
67
67
 
@@ -93,6 +93,8 @@ interface TransformContext {
93
93
  _reactiveGetterNames?: Set<string>
94
94
  /** Cached set of module-scope @client signal/memo names. */
95
95
  _moduleClientSignalNames?: Set<string>
96
+ /** Cached `localConstants` index for `forwardsCallerRestProps`'s alias walk */
97
+ _restSpreadConstantValues?: ReadonlyMap<string, string | undefined>
96
98
  /** Cached set of destructured prop names for AST-based rewriting */
97
99
  _destructuredPropNames?: Set<string> | null
98
100
  /**
@@ -982,9 +984,82 @@ function attachParsedExpressions(node: IRNode, analyzer: AnalyzerContext, bound:
982
984
  }
983
985
  }
984
986
 
987
+ /**
988
+ * Resolve #2753's "mechanism 2" `keyAttr`: the component's own possible
989
+ * render root(s) relay a key value an external caller supplies at runtime
990
+ * (a `data-key` prop / Rust `bf.data_key` field / client `createComponent`
991
+ * key argument) when THIS component is itself invoked as a caller's keyed
992
+ * loop row. Ported, once, from the `element.needsScope` test each of the 10
993
+ * SSR adapters previously applied at EMIT time, on every compile. Resolving
994
+ * it here means every adapter instead reads one IR field.
995
+ *
996
+ * The relay belongs on exactly the elements carrying `needsScope` — the
997
+ * elements that also carry `bf-s`, i.e. this component's rendered root(s).
998
+ * That is the same predicate, evaluated at the same set of elements, as the
999
+ * reference (Hono) adapter applied before #2762, and it matches what the
1000
+ * client runtime does for the CSR half of the contract: `renderChild` /
1001
+ * `materializeComponent` splice `data-key` onto the rendered markup's FIRST
1002
+ * element (component.ts), whatever wrapper nodes stand above it. A walk
1003
+ * that stops at the first non-element node instead of testing `needsScope`
1004
+ * throughout answers differently for every component whose root is a
1005
+ * `<Ctx.Provider>` — `transformProviderElement` passes `ctx.isRoot` through
1006
+ * to its children, so the provider's inner element is a rendered root and
1007
+ * gets `needsScope`, but the walk never reaches it (#2753 regression).
1008
+ *
1009
+ * `needsScope` is `ctx.isRoot` at element-transform time, and `ctx.isRoot`
1010
+ * is consumed by the first element/component/scope-comment fragment on the
1011
+ * way down, so nothing inside a loop body or a child component's slot
1012
+ * children can be true here. Visiting the whole tree therefore costs
1013
+ * nothing in reach but cannot go stale the way an enumeration of
1014
+ * "`isRoot`-transparent constructs" can.
1015
+ *
1016
+ * A `needsScopeComment` fragment root (#2732) is handled separately, at
1017
+ * `transformFragment` build time (`markDataKeyCarrier`): `ctx.isRoot` can be
1018
+ * true for at most one node in a component's whole tree, so that one shape
1019
+ * is fully resolved the moment the fragment itself is built and does not
1020
+ * need a second, tree-wide pass to find it again. Its children carry
1021
+ * `needsScope: false` by construction, so this walk's `n.needsScope` guard
1022
+ * naturally skips them without double-marking.
1023
+ *
1024
+ * The `!keyAttr` guard keeps mechanism 1 (`applyLoopKeyAttr`, an inline
1025
+ * `.map()` row root's own concretely-known key expression, resolved during
1026
+ * the transform) — strictly more useful than a relay marker — from being
1027
+ * overwritten.
1028
+ */
1029
+ function resolveRootKeyAttr(node: IRNode | null): void {
1030
+ if (!node) return
1031
+ switch (node.type) {
1032
+ case 'element':
1033
+ if (node.needsScope && !node.keyAttr) node.keyAttr = { name: BF_KEY }
1034
+ for (const c of node.children) resolveRootKeyAttr(c)
1035
+ return
1036
+ case 'fragment':
1037
+ case 'component':
1038
+ case 'provider':
1039
+ case 'loop':
1040
+ for (const c of node.children) resolveRootKeyAttr(c)
1041
+ return
1042
+ case 'async':
1043
+ resolveRootKeyAttr(node.fallback)
1044
+ for (const c of node.children) resolveRootKeyAttr(c)
1045
+ return
1046
+ case 'conditional':
1047
+ resolveRootKeyAttr(node.whenTrue)
1048
+ resolveRootKeyAttr(node.whenFalse)
1049
+ return
1050
+ case 'if-statement':
1051
+ resolveRootKeyAttr(node.consequent)
1052
+ resolveRootKeyAttr(node.alternate)
1053
+ return
1054
+ }
1055
+ }
1056
+
985
1057
  export function jsxToIR(analyzer: AnalyzerContext): IRNode | null {
986
1058
  const root = buildIRRoot(analyzer)
987
- if (root) attachParsedExpressions(root, analyzer)
1059
+ if (root) {
1060
+ attachParsedExpressions(root, analyzer)
1061
+ resolveRootKeyAttr(root)
1062
+ }
988
1063
  return root
989
1064
  }
990
1065
 
@@ -1388,13 +1463,12 @@ function lowerFormControlValueSsr(
1388
1463
  children.push({
1389
1464
  type: 'expression',
1390
1465
  expr,
1391
- // The client-side registration template interpolates slotless
1392
- // expression children RAW (no text-slot `escapeText` wrapper), so a
1393
- // value containing `</textarea>` would break out of the element on
1394
- // CSR mount. Escape in the client-only template variant; `expr`
1395
- // stays clean for the SSR adapters, whose template engines (and
1396
- // hono/jsx) already escape text children natively.
1466
+ // Escaped for client string-building; `expr` stays raw since SSR
1467
+ // engines escape text children natively.
1397
1468
  templateExpr: `escapeText(${templateExpr ?? expr})`,
1469
+ // Init-scope builders can't just swap in `templateExpr` (its `_p.`
1470
+ // binding differs) — see `escapeInClientTemplate`'s docstring.
1471
+ escapeInClientTemplate: true,
1398
1472
  typeInfo: null,
1399
1473
  reactive: false,
1400
1474
  slotId: null,
@@ -1471,7 +1545,7 @@ function transformHtmlElement(
1471
1545
 
1472
1546
  // Determine if this element needs a slot ID
1473
1547
  // Elements need slotIds if they have: events, dynamic children, reactive attributes, or refs
1474
- const needsSlot = events.length > 0 || hasDynamicContent(children) || hasReactiveAttributes(attrs, ctx) || ref !== null
1548
+ const needsSlot = events.length > 0 || hasDynamicContent(children) || hasReactiveAttributes(attrs, ctx) || ref !== null || forwardsCallerRestProps(attrs, ctx)
1475
1549
  const slotId = needsSlot ? generateSlotId(ctx) : null
1476
1550
 
1477
1551
  // Propagate slotId to loop children (they need to use parent's marker)
@@ -1527,7 +1601,7 @@ function transformSelfClosingElement(
1527
1601
  lowerFormControlValueSsr(tagName, attrs, selfClosingChildren)
1528
1602
 
1529
1603
  // Elements need slotIds if they have events, reactive attributes, or refs
1530
- const needsSlot = events.length > 0 || hasReactiveAttributes(attrs, ctx) || ref !== null
1604
+ const needsSlot = events.length > 0 || hasReactiveAttributes(attrs, ctx) || ref !== null || forwardsCallerRestProps(attrs, ctx)
1531
1605
  const slotId = needsSlot ? generateSlotId(ctx) : null
1532
1606
 
1533
1607
  const needsScope = ctx.isRoot
@@ -1946,10 +2020,12 @@ function unwrapHoistedFragment(node: IRNode): IRNode {
1946
2020
 
1947
2021
  // #2732: a `needsScopeComment` fragment's five hydration markers move to
1948
2022
  // 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
2023
+ // `IRElement.keyAttr`'s docstring for why). Mark the first ELEMENT among
2024
+ // `children` — immutably (`{ ...el, keyAttr: { name: BF_KEY } }`), matching
1951
2025
  // the rest of this file's post-hoc IR tagging (e.g. `unwrapHoistedFragment`)
1952
- // rather than mutating the child in place.
2026
+ // rather than mutating the child in place. No `value`: this fragment relays
2027
+ // whatever key its OWN caller supplies at runtime (#2753's "mechanism 2" —
2028
+ // see `IRElement.keyAttr`), not a locally-known expression.
1953
2029
  function markDataKeyCarrier(children: IRNode[]): IRNode[] {
1954
2030
  for (let i = 0; i < children.length; i++) {
1955
2031
  const marked = markCarrierIn(children[i])
@@ -1978,7 +2054,13 @@ function markDataKeyCarrier(children: IRNode[]): IRNode[] {
1978
2054
  */
1979
2055
  function markCarrierIn(node: IRNode): IRNode | null {
1980
2056
  if (node.type === 'element') {
1981
- return { ...(node as IRElement), carriesDataKey: true }
2057
+ // Never overwrite an element that already resolved a LOCAL loop key
2058
+ // (mechanism 1, `resolveLoopKeyAttr` below) — that can only happen if
2059
+ // this same element is simultaneously a `.map()` row root AND this
2060
+ // fragment's relay carrier, and the local, concretely-known expression
2061
+ // is strictly more useful than the relay marker.
2062
+ if ((node as IRElement).keyAttr) return null
2063
+ return { ...(node as IRElement), keyAttr: { name: BF_KEY } }
1982
2064
  }
1983
2065
  if (node.type === 'conditional') {
1984
2066
  const cond = node as IRConditional
@@ -2171,8 +2253,11 @@ function transformExpressionInner(
2171
2253
  return null
2172
2254
  }
2173
2255
 
2174
- // Check for bare signal/memo identifier (BF044)
2175
- checkBareSignalOrMemoIdentifier(expr, ctx)
2256
+ // Check for bare signal/memo identifier (BF044). A JSX text child is
2257
+ // inherently a RENDERED position — unlike a component prop, `{expr}`
2258
+ // here always becomes literal rendered output, never an opaque value
2259
+ // handed to a consumer to call later — so nested descent is always on.
2260
+ checkBareSignalOrMemoIdentifier(expr, ctx, { descendNested: true })
2176
2261
 
2177
2262
  // #547: Inline a JSX constant referenced by identifier. Unique to JSX-child
2178
2263
  // position — conditional branches and return position don't resolve
@@ -3708,6 +3793,19 @@ function extractLoopKey(node: IRNode): string | null {
3708
3793
  if (b === null) return null
3709
3794
  return normalizeKeyExpr(a) === normalizeKeyExpr(b) ? a : null
3710
3795
  }
3796
+ if (node.type === 'fragment') {
3797
+ // #2763: a `.map()` body returning a fragment (`<><li key={..}/><li/></>`)
3798
+ // reads the key off its FIRST element, not first node — same "first
3799
+ // element, not first node" rule as `IRElement.keyAttr`'s docstring and
3800
+ // `markCarrierIn`'s relay case, skipping whitespace-only text the way
3801
+ // `branchHasNoElement` does. Recursing through `extractLoopKey` (rather
3802
+ // than requiring `type === 'element'` directly) lets a nested
3803
+ // conditional/fragment first child resolve too.
3804
+ const first = node.children.find(
3805
+ (c) => !(c.type === 'text' && typeof c.value === 'string' && !c.value.trim())
3806
+ )
3807
+ return first ? extractLoopKey(first) : null
3808
+ }
3711
3809
  return null
3712
3810
  }
3713
3811
 
@@ -4023,7 +4121,7 @@ function leafIsWirelessElement(el: ts.JsxElement | ts.JsxSelfClosingElement): bo
4023
4121
  if (ts.isJsxSpreadAttribute(attr)) { ok = false; return }
4024
4122
  if (ts.isJsxAttribute(attr)) {
4025
4123
  const name = attr.name.getText()
4026
- if (/^on[A-Z]/.test(name)) { ok = false; return }
4124
+ if (isEventHandlerAttrName(name)) { ok = false; return }
4027
4125
  }
4028
4126
  }
4029
4127
  }
@@ -4104,6 +4202,56 @@ function tagLoopItemRootComponents(nodes: readonly IRNode[]): void {
4104
4202
  }
4105
4203
  }
4106
4204
 
4205
+ /**
4206
+ * Attach the loop's resolved key ATTRIBUTE (name + value) to the element(s)
4207
+ * that will actually render it — the write-side twin of `extractLoopKey`,
4208
+ * which reads the SAME `key={}` JSX attribute (#2753: the SSR-side row-key
4209
+ * decision is resolved once here, so the 9 SSR adapters stop re-deriving the
4210
+ * attribute name from their own depth tracking, and stop disagreeing about
4211
+ * whether an unkeyed row gets one at all).
4212
+ *
4213
+ * Mutates in place — `tagLoopItemRootComponents`'s style, run immediately
4214
+ * after `key`/`depth` resolve, on the same node objects `children` already
4215
+ * owns — rather than rebuilding the tree immutably: unlike `markCarrierIn`
4216
+ * (which hands a fresh tree back to a caller holding the old one), this runs
4217
+ * on IR this same function just built and is about to return, so there is
4218
+ * no stale external reference to protect.
4219
+ *
4220
+ * Deliberately does NOT remove the literal `key` entry from `.attrs`: the
4221
+ * CSR/client-JS codegen path (`ir-to-client-js/html-template.ts`'s
4222
+ * `resolvedKeyAttrName` helper, #2763) reads it back off `.attrs` too, but
4223
+ * only emits `data-key` when THIS field — `node.keyAttr` — is actually set;
4224
+ * a raw `key` attribute the SSR side didn't recognize as a row key (e.g. a
4225
+ * fragment-bodied row before #2763's fix) no longer gets baked client-side
4226
+ * either. Adapters skip the raw `key` attr in their generic `renderAttributes`
4227
+ * loop instead (Hono's own JSX runtime already drops it silently either way).
4228
+ *
4229
+ * `component` bodies are left untouched: a `<Comp key={x}/>` row root's key
4230
+ * is a PROP (`renderComponentProps`'s own `data-key` prop-forwarding), not
4231
+ * an `IRElement` — a separate mechanism #2753 did not measure as broken and
4232
+ * this change does not touch.
4233
+ */
4234
+ function applyLoopKeyAttr(node: IRNode, name: string, value: string): void {
4235
+ if (node.type === 'element') {
4236
+ node.keyAttr = { name, value }
4237
+ return
4238
+ }
4239
+ if (node.type === 'conditional') {
4240
+ applyLoopKeyAttr(node.whenTrue, name, value)
4241
+ applyLoopKeyAttr(node.whenFalse, name, value)
4242
+ return
4243
+ }
4244
+ if (node.type === 'fragment') {
4245
+ // Write-side twin of `extractLoopKey`'s fragment case: stamp the SAME
4246
+ // first-real-child this key was read from, not every child, matching
4247
+ // the "first element, not first node" rule.
4248
+ const first = node.children.find(
4249
+ (c) => !(c.type === 'text' && typeof c.value === 'string' && !c.value.trim())
4250
+ )
4251
+ if (first) applyLoopKeyAttr(first, name, value)
4252
+ }
4253
+ }
4254
+
4107
4255
  function loopBodyItemConditional(children: IRNode[]): IRConditional | null {
4108
4256
  const real = children.filter(
4109
4257
  (c) => !(c.type === 'text' && typeof c.value === 'string' && !c.value.trim())
@@ -4542,7 +4690,7 @@ function transformMapCall(
4542
4690
  // the conditional — the same carrier the single-return path uses. A
4543
4691
  // JS-runtime adapter (and the browser under /* @client */) runs it
4544
4692
  // verbatim.
4545
- preamble = preambleFromValueStatements(pre, ctx)
4693
+ preamble = preambleFromValueStatements(pre, ctx, body.statements)
4546
4694
 
4547
4695
  // Stage 2 of spec/callback-fidelity.md, narrowed by #2447. This used
4548
4696
  // to refuse EVERY branch preamble on a DSL target, on the premise
@@ -4749,7 +4897,7 @@ function transformMapCall(
4749
4897
  valueStmts.push(stmt)
4750
4898
  }
4751
4899
  if (valueStmts.length > 0) {
4752
- preamble = preambleFromValueStatements(valueStmts, ctx)
4900
+ preamble = preambleFromValueStatements(valueStmts, ctx, body.statements)
4753
4901
 
4754
4902
  // #2447. A DSL adapter cannot execute `preamble.segments` (JS
4755
4903
  // text), so it lowers `declarations` — one per-row local each —
@@ -4946,6 +5094,20 @@ function transformMapCall(
4946
5094
  ? extractItemConditionalKey(itemConditional!)
4947
5095
  : (children.length > 0 ? extractLoopKey(children[0]) : null)
4948
5096
 
5097
+ // #2753: resolve this row's key ATTRIBUTE (name + value) here, once, and
5098
+ // stamp it onto the element(s) that render it — `null` (unkeyed) means no
5099
+ // `IRElement.keyAttr` at all, which is the correct final answer for every
5100
+ // adapter and the client runtime alike, not a hint they still have to
5101
+ // gate. See `IRElement.keyAttr` / `applyLoopKeyAttr`'s docstrings.
5102
+ if (key !== null) {
5103
+ const resolvedKeyAttrName = keyAttrName(depth)
5104
+ if (bodyIsItemConditional) {
5105
+ applyLoopKeyAttr(itemConditional!, resolvedKeyAttrName, key)
5106
+ } else if (children.length > 0) {
5107
+ applyLoopKeyAttr(children[0], resolvedKeyAttrName, key)
5108
+ }
5109
+ }
5110
+
4949
5111
  // Stage 3 / D5 (spec/callback-fidelity.md) — the keyFn is hoisted: `mapArray`
4950
5112
  // computes it from the raw item BEFORE the callback body runs, so the key must
4951
5113
  // be derivable from the item (and index), never from a value the preamble
@@ -5705,6 +5867,7 @@ function computePreambleReactiveNames(
5705
5867
  function preambleFromValueStatements(
5706
5868
  statements: readonly ts.Statement[],
5707
5869
  ctx: TransformContext,
5870
+ bodyStatements: readonly ts.Statement[],
5708
5871
  ): MapCallbackPreamble {
5709
5872
  const segments: PreambleSegment[] = []
5710
5873
  const typedParts: string[] = []
@@ -5726,7 +5889,7 @@ function preambleFromValueStatements(
5726
5889
  declaredNames: [...declared],
5727
5890
  // Value-only preambles accumulate no JSX, so no child needs the array join.
5728
5891
  builderNames: [],
5729
- declarations: neutralPreambleDeclarations(statements, ctx) ?? undefined,
5892
+ declarations: neutralPreambleDeclarations(statements, ctx, bodyStatements) ?? undefined,
5730
5893
  reactiveNames: reactiveNames.size > 0 ? [...reactiveNames] : undefined,
5731
5894
  }
5732
5895
  }
@@ -5743,6 +5906,7 @@ function preambleFromValueStatements(
5743
5906
  function neutralPreambleDeclarations(
5744
5907
  statements: readonly ts.Statement[],
5745
5908
  ctx: TransformContext,
5909
+ bodyStatements: readonly ts.Statement[],
5746
5910
  ): PreambleValueDeclaration[] | null {
5747
5911
  const out: PreambleValueDeclaration[] = []
5748
5912
  for (const stmt of statements) {
@@ -5754,6 +5918,20 @@ function neutralPreambleDeclarations(
5754
5918
  // no adapter has a single per-row local form for that.
5755
5919
  if (!ts.isIdentifier(decl.name)) return null
5756
5920
  if (!decl.initializer) return null
5921
+ // #2797: a function-valued local (an event handler hoisted out of the
5922
+ // JSX attribute position, e.g. `const handleClick = () => {...}`) has
5923
+ // no template-expression form on any backend — no DSL here has a
5924
+ // callable value. But every adapter's own SSR component-prop builder
5925
+ // already skips an event-handler prop (`isEventHandlerAttrName`)
5926
+ // outright, so a name read ONLY that way needs no SSR representation
5927
+ // at all — elide the declaration instead of refusing the whole
5928
+ // preamble. The client-JS preamble (`segments`, not `declarations`)
5929
+ // still carries the real closure; this only concerns what the DSL
5930
+ // SSR template sees.
5931
+ if (isFunctionLiteral(decl.initializer)) {
5932
+ if (everyReadIsEventHandlerPropValue(decl.name.text, bodyStatements)) continue
5933
+ return null
5934
+ }
5757
5935
  const valueParsed = tsNodeToParsedExpr(decl.initializer)
5758
5936
  // The same subset gate every other DSL-lowered expression passes
5759
5937
  // through. An adapter that additionally cannot emit a supported shape
@@ -5768,7 +5946,74 @@ function neutralPreambleDeclarations(
5768
5946
  })
5769
5947
  }
5770
5948
  }
5771
- return out.length > 0 ? out : null
5949
+ // Not `out.length > 0 ? out : null` — an all-elided preamble legitimately
5950
+ // has zero declarations to emit (see MapCallbackPreamble.declarations'
5951
+ // docstring); `[]` is what tells the Phase-1 gate "fully declared."
5952
+ return out
5953
+ }
5954
+
5955
+ /** `const x = () => {...}` / `const x = function () {...}` — a callable value, never a template-expression shape. */
5956
+ function isFunctionLiteral(node: ts.Expression): boolean {
5957
+ return ts.isArrowFunction(node) || ts.isFunctionExpression(node)
5958
+ }
5959
+
5960
+ /**
5961
+ * Whether every read of `name` in `bodyStatements`, other than its own
5962
+ * binding site, sits inside an event-handler `JsxAttribute`'s
5963
+ * (`onClick`/`onInput`/...) value — i.e. `name={<ident>}` where the
5964
+ * attribute's own name passes {@link isEventHandlerAttrName}, the exact
5965
+ * predicate every DSL adapter's own SSR prop-builder already uses to skip an
5966
+ * event-handler prop (e.g. `packages/adapter-jinja/src/adapter/
5967
+ * jinja-adapter.ts`'s `renderComponent`, `packages/adapter-go-template/...`'s
5968
+ * `prop.isEventHandler` check).
5969
+ *
5970
+ * Walks the raw preamble+return syntax rather than the already-built row IR
5971
+ * (`children`) deliberately: `neutralPreambleDeclarations` runs on the
5972
+ * PREAMBLE statements specifically, and the IR built from the return
5973
+ * expression doesn't retain a per-occurrence link back to which SOURCE
5974
+ * identifier fed a given `IREvent`/`IRProp` — re-deriving from syntax here
5975
+ * keeps the safety check self-contained rather than threading that link
5976
+ * through the IR builder for one caller.
5977
+ *
5978
+ * Conservative: a property-access name (`x.name`), a nested declaration's own
5979
+ * binding, or any other read shape (a text/attr expression, a NON-event
5980
+ * child prop, a call) fails this and falls through to the ordinary refusal —
5981
+ * this only widens the shapes that are SAFE to elide, never narrows what was
5982
+ * already accepted. Uses `ts.forEachChild`'s own short-circuit (a truthy
5983
+ * return stops the walk) rather than a separate mutable flag.
5984
+ */
5985
+ function everyReadIsEventHandlerPropValue(name: string, bodyStatements: readonly ts.Statement[]): boolean {
5986
+ const findDisallowedRead = (node: ts.Node): true | undefined => {
5987
+ if (ts.isIdentifier(node) && node.text === name) {
5988
+ const isBindingName = ts.isVariableDeclaration(node.parent) && node.parent.name === node
5989
+ const isPropertyName = ts.isPropertyAccessExpression(node.parent) && node.parent.name === node
5990
+ if (!isBindingName && !isPropertyName) {
5991
+ const jsxExpr = node.parent
5992
+ const attr = jsxExpr.parent
5993
+ const isEventHandlerAttrValue =
5994
+ ts.isJsxExpression(jsxExpr) &&
5995
+ ts.isJsxAttribute(attr) &&
5996
+ attr.initializer === jsxExpr &&
5997
+ ts.isIdentifier(attr.name) &&
5998
+ isEventHandlerAttrName(attr.name.text)
5999
+ if (!isEventHandlerAttrValue) return true
6000
+ }
6001
+ }
6002
+ return ts.forEachChild(node, findDisallowedRead)
6003
+ }
6004
+ return !bodyStatements.some(findDisallowedRead)
6005
+ }
6006
+
6007
+ /**
6008
+ * Whether a JSX attribute name is an event handler (`onClick`, `onInput`,
6009
+ * ...) rather than a plain attribute/prop. Shared by every site in this file
6010
+ * that needs the same call: native-element attribute extraction (routes into
6011
+ * `IREvent`, never rendered HTML), the preamble-verbatim-leaf wiring guard,
6012
+ * and {@link everyReadIsEventHandlerPropValue}'s elision safety check — one
6013
+ * decision, one implementation, per CLAUDE.md.
6014
+ */
6015
+ function isEventHandlerAttrName(name: string): boolean {
6016
+ return /^on[A-Z]/.test(name)
5772
6017
  }
5773
6018
 
5774
6019
  /** Drop the trailing separator space from the final js segment. */
@@ -6122,7 +6367,7 @@ function processAttributes(
6122
6367
  // they're wired up at hydration time (delegated event registration) and
6123
6368
  // must not leak into rendered HTML. The DOM event name is lowercase
6124
6369
  // (`click`, not `Click`), so strip the `on` prefix and downcase.
6125
- if (/^on[A-Z]/.test(rawName)) {
6370
+ if (isEventHandlerAttrName(rawName)) {
6126
6371
  if (attr.initializer && ts.isJsxExpression(attr.initializer) && attr.initializer.expression) {
6127
6372
  const eventName = rawName.slice(2).toLowerCase()
6128
6373
  reportJsxBranchLocalInCallback(attr.initializer.expression, ctx)
@@ -6244,8 +6489,20 @@ function getAttributeValue(attr: ts.JsxAttribute, ctx: TransformContext): AttrVa
6244
6489
  return AttrValueOf.expression('undefined')
6245
6490
  }
6246
6491
 
6247
- // Check for bare signal/memo identifier (BF044)
6248
- checkBareSignalOrMemoIdentifier(expr, ctx)
6492
+ // Check for bare signal/memo identifier (BF044). `getAttributeValue`
6493
+ // serves BOTH a DOM element's attributes and a component's props
6494
+ // (two call sites above, in `processElementAttributes` and
6495
+ // `processComponentProps`), and only the element-attribute case is
6496
+ // a RENDERED position — a component prop is an opaque value handed
6497
+ // to the component, and this codebase's Context-Provider idiom
6498
+ // depends on that value being an uncalled accessor (`value={{
6499
+ // open }}`, ui/components/ui/select/index.tsx — calling it eagerly
6500
+ // there would freeze the value at provider-render time and break
6501
+ // every consumer's reactivity). So nested descent is gated on
6502
+ // element-vs-component, derived from the tag this attribute lives
6503
+ // on; the top-level bare-identifier check stays unconditional
6504
+ // either way, unchanged from this diagnostic's original behaviour.
6505
+ checkBareSignalOrMemoIdentifier(expr, ctx, { descendNested: isRenderedElementAttribute(attr, ctx) })
6249
6506
 
6250
6507
  // Static style object: style={{ key: 'value', ... }} → CSS string at compile time
6251
6508
  if (attr.name.getText(ctx.sourceFile) === 'style' && ts.isObjectLiteralExpression(expr)) {
@@ -7300,53 +7557,287 @@ function processComponentProps(
7300
7557
  // =============================================================================
7301
7558
 
7302
7559
  /**
7303
- * Check if a bare identifier is a signal getter or memo name.
7304
- * Emits BF044 error when a signal/memo getter is passed without calling it.
7560
+ * Is `attr` an attribute on a DOM/SVG element's opening tag, as opposed
7561
+ * to a prop on a component? `JsxAttributes.parent` is always the
7562
+ * `JsxOpeningElement`/`JsxSelfClosingElement` the attribute list belongs
7563
+ * to (`JsxOpeningLikeElement`); its `tagName` follows the same
7564
+ * uppercase-means-component convention `transformSelfClosingElement`
7565
+ * already uses (`isComponent = /^[A-Z]/.test(tagName)`) — lowercase or
7566
+ * hyphenated (a custom element) is a real DOM node, capitalised or
7567
+ * dotted (`Select.Provider`) is a component.
7568
+ *
7569
+ * Feeds `checkBareSignalOrMemoIdentifier`'s `descendNested` gate: a
7570
+ * DOM attribute value is a RENDERED position (this is genuinely what
7571
+ * ends up in the DOM), so a bare getter buried inside it — `style={{
7572
+ * color: val }}` — is exactly as much a forgotten `()` as a top-level
7573
+ * one. A component prop is not rendered — it's an opaque value handed
7574
+ * to the component — and this codebase's Context-Provider idiom
7575
+ * depends on that value being an UNCALLED accessor
7576
+ * (`ui/components/ui/select/index.tsx`'s `value={{ open, ... }}`), so
7577
+ * nested descent must not fire there.
7578
+ */
7579
+ function isRenderedElementAttribute(attr: ts.JsxAttribute, ctx: TransformContext): boolean {
7580
+ const tagName = attr.parent.parent.tagName.getText(ctx.sourceFile)
7581
+ return !/^[A-Z]/.test(tagName)
7582
+ }
7583
+
7584
+ /**
7585
+ * Check for a bare signal/memo getter reference in `expr`.
7586
+ * Emits BF044 when a signal/memo getter is passed without calling it.
7305
7587
  * e.g., value={count} instead of value={count()}
7588
+ *
7589
+ * The top-level check — is `expr` itself a bare reactive identifier —
7590
+ * always runs, unchanged from this diagnostic's original behaviour
7591
+ * (`value={count}`, `<Foo x={count} />` keep firing regardless of
7592
+ * position). `descendNested` additionally opts into walking INSIDE a
7593
+ * composite expression — call arguments, template-literal spans,
7594
+ * ternary branches (condition + both arms), array/object literal
7595
+ * members, binary/logical operands, parenthesized wrappers — to catch
7596
+ * a bare reference buried anywhere in that reachable set (#2755,
7597
+ * #2751: this is the single upstream gate whose shallow top-level-only
7598
+ * check let both downstream bugs through). Callers opt in only for
7599
+ * RENDERED positions (a JSX text child; a DOM element's attribute value
7600
+ * via `isRenderedElementAttribute`) — never for a component prop, where
7601
+ * a bare getter is this codebase's deliberate Context-Provider idiom
7602
+ * (measured: 66 such uses across `ui/components/**`/`site/**`, all
7603
+ * legitimate, none a forgotten `()` — widening the gate to fire there
7604
+ * would be a correctness regression, not a fix).
7605
+ *
7606
+ * Two structural exclusions apply whenever descending, both required
7607
+ * so the walk doesn't false-positive on shapes that read the name
7608
+ * without evaluating its signal value:
7609
+ * - the callee of a zero-arg call (`val()`) is the CORRECT form and
7610
+ * is never visited as a bare reference — only the call's arguments
7611
+ * (there are none) would be descended into.
7612
+ * - a property-access chain's object is visited but its property
7613
+ * NAME never is, so `ctx.val` never fires when a signal `val`
7614
+ * exists — the exact bug class documented in this file's
7615
+ * `csr-substitute.ts` sibling walker, #1100.
7616
+ * JSX embedded inside the checked expression (a `.map()`/`.filter()`
7617
+ * callback returning JSX is the common shape) is NOT this walk's
7618
+ * territory either: `transformNode` independently descends into that
7619
+ * element's own attributes and children, each re-entering this same
7620
+ * check on its own real value expression. Descending into it here
7621
+ * would be actively wrong, not just redundant — a JSX element's `name`
7622
+ * nodes (an attribute's NAME, a tag name) are plain `ts.Identifier`s
7623
+ * with no marker distinguishing them from a value read, so without
7624
+ * this guard an attribute like `checked={x}` gets its own NAME flagged
7625
+ * whenever a same-named signal is in scope (measured directly against
7626
+ * `checkbox-demo.tsx` while building this walk).
7627
+ * A nested function/arrow body that rebinds the name shadows it for
7628
+ * everything under that body — modeled with a local bound-name stack,
7629
+ * same shape as `csrSubstituteOnce`'s `boundStack`
7630
+ * (`ir-to-client-js/csr-substitute.ts`), which solves this exact
7631
+ * member-access/shadowing pair of problems for the CSR-substitution
7632
+ * walk. Deliberately NOT threaded through the shared `BindingScope`
7633
+ * service (`scope/binding-scope.ts`) — that scope answers "what does
7634
+ * the surrounding loop/callback bind"; this walk only needs shadowing
7635
+ * introduced by nested function literals inside the single expression
7636
+ * being checked, the same reasoning `csrSubstituteOnce` already
7637
+ * documents for its own local `boundStack`.
7306
7638
  */
7307
7639
  function checkBareSignalOrMemoIdentifier(
7308
7640
  expr: ts.Expression,
7309
- ctx: TransformContext
7641
+ ctx: TransformContext,
7642
+ options?: { descendNested?: boolean }
7310
7643
  ): void {
7311
- if (!ts.isIdentifier(expr)) return
7312
-
7313
- const name = expr.text
7644
+ const reactiveNames = new Map<string, 'signal' | 'memo'>()
7645
+ for (const signal of ctx.analyzer.signals) reactiveNames.set(signal.getter, 'signal')
7646
+ for (const memo of ctx.analyzer.memos) reactiveNames.set(memo.name, 'memo')
7647
+ if (reactiveNames.size === 0) return
7314
7648
 
7315
- for (const signal of ctx.analyzer.signals) {
7316
- if (signal.getter === name) {
7317
- ctx.analyzer.errors.push(
7318
- createError(ErrorCodes.SIGNAL_GETTER_NOT_CALLED,
7319
- getSourceLocation(expr, ctx.sourceFile, ctx.filePath),
7320
- {
7321
- message: `Signal getter '${name}' passed without calling it`,
7322
- suggestion: {
7323
- message: `Signal getters must be called to read the value. Use \`${name}()\` instead of \`${name}\`.`,
7324
- replacement: `${name}()`,
7325
- },
7326
- }
7327
- )
7649
+ const report = (id: ts.Identifier, name: string, kind: 'signal' | 'memo'): void => {
7650
+ const label = kind === 'signal' ? 'Signal' : 'Memo'
7651
+ ctx.analyzer.errors.push(
7652
+ createError(ErrorCodes.SIGNAL_GETTER_NOT_CALLED,
7653
+ getSourceLocation(id, ctx.sourceFile, ctx.filePath),
7654
+ {
7655
+ message: `${label} getter '${name}' passed without calling it`,
7656
+ suggestion: {
7657
+ message: `${label} getters must be called to read the value. Use \`${name}()\` instead of \`${name}\`.`,
7658
+ replacement: `${name}()`,
7659
+ },
7660
+ }
7328
7661
  )
7662
+ )
7663
+ }
7664
+
7665
+ // Top-level check — unchanged from this diagnostic's original
7666
+ // position-gating (fires unconditionally, everywhere), but NOW also
7667
+ // respects the ambient loop/callback `BindingScope` a shadowed name
7668
+ // needs — a pre-existing gap (the original single-line
7669
+ // `if (!ts.isIdentifier(expr)) return` never consulted scope either)
7670
+ // that stayed invisible only because a bare top-level identifier
7671
+ // shadowed by a `.map(name => ...)` row param never happened to be
7672
+ // exercised by any prior test. `ctx.scope` is the LIVE scope at this
7673
+ // JSX node — already entered for the current loop row/callback frame
7674
+ // by the time an attribute/child expression under it is checked
7675
+ // (`enterLoopRow`/`enterCallback`, `scope/binding-scope.ts`) — so
7676
+ // `isBound` here is a shadow-guard read in exactly the sense
7677
+ // `spec/compiler.md`'s `BindingScope` section describes: is a signal
7678
+ // getter of this name shadowed HERE, not "does this expression need
7679
+ // its own reactive slot".
7680
+ if (ts.isIdentifier(expr)) {
7681
+ const kind = reactiveNames.get(expr.text)
7682
+ if (kind && !ctx.scope.isBound(expr.text)) report(expr, expr.text, kind)
7683
+ return
7684
+ }
7685
+
7686
+ if (!options?.descendNested) return
7687
+
7688
+ const boundStack: Array<Set<string>> = []
7689
+ const isBound = (name: string): boolean => {
7690
+ for (let i = boundStack.length - 1; i >= 0; i--) {
7691
+ if (boundStack[i].has(name)) return true
7692
+ }
7693
+ return ctx.scope.isBound(name)
7694
+ }
7695
+
7696
+ /**
7697
+ * Visit the default VALUES in a binding pattern (`const { x = count } = obj`,
7698
+ * `(f = count) => …`), binding each name into `bound` as it is introduced.
7699
+ *
7700
+ * `collectBindingNames` deliberately walks only NAMES — a binding name is
7701
+ * not a reference — but a default value is an ordinary expression, so a bare
7702
+ * getter can hide there. Not hypothetical: measured, such a shape emits a
7703
+ * module-scope `template` thunk that references a component-scope binding
7704
+ * and throws `ReferenceError` on CSR mount, which is #2751's mechanism —
7705
+ * the class this check exists to close.
7706
+ *
7707
+ * Binds as it goes because JS binds left to right WITHIN a pattern too:
7708
+ * `({ a, b = a })` reads the already-initialized `a`, so `b`'s default must
7709
+ * be checked against a scope that already contains `a`. Visiting every
7710
+ * default first and binding afterwards would flag `a` as a bare signal read
7711
+ * whenever a pattern name happens to collide with a real signal — a false
7712
+ * positive, which is the direction that breaks working code.
7713
+ */
7714
+ const visitBindingDefaults = (name: ts.BindingName, bound: Set<string>): void => {
7715
+ if (ts.isIdentifier(name)) {
7716
+ bound.add(name.text)
7329
7717
  return
7330
7718
  }
7719
+ for (const el of name.elements) {
7720
+ if (ts.isOmittedExpression(el)) continue
7721
+ if (el.initializer) visit(el.initializer)
7722
+ visitBindingDefaults(el.name, bound)
7723
+ }
7331
7724
  }
7332
7725
 
7333
- for (const memo of ctx.analyzer.memos) {
7334
- if (memo.name === name) {
7335
- ctx.analyzer.errors.push(
7336
- createError(ErrorCodes.SIGNAL_GETTER_NOT_CALLED,
7337
- getSourceLocation(expr, ctx.sourceFile, ctx.filePath),
7338
- {
7339
- message: `Memo getter '${name}' passed without calling it`,
7340
- suggestion: {
7341
- message: `Memo getters must be called to read the value. Use \`${name}()\` instead of \`${name}\`.`,
7342
- replacement: `${name}()`,
7343
- },
7344
- }
7345
- )
7346
- )
7726
+ const collectBindingNames = (name: ts.BindingName, out: Set<string>): void => {
7727
+ if (ts.isIdentifier(name)) out.add(name.text)
7728
+ else if (ts.isObjectBindingPattern(name)) {
7729
+ for (const el of name.elements) collectBindingNames(el.name, out)
7730
+ } else if (ts.isArrayBindingPattern(name)) {
7731
+ for (const el of name.elements) {
7732
+ if (!ts.isOmittedExpression(el)) collectBindingNames(el.name, out)
7733
+ }
7734
+ }
7735
+ }
7736
+
7737
+ const collectBlockDeclarations = (block: ts.Block, out: Set<string>): void => {
7738
+ for (const stmt of block.statements) {
7739
+ if (ts.isVariableStatement(stmt)) {
7740
+ for (const decl of stmt.declarationList.declarations) collectBindingNames(decl.name, out)
7741
+ } else if (ts.isFunctionDeclaration(stmt) && stmt.name) {
7742
+ out.add(stmt.name.text)
7743
+ }
7744
+ }
7745
+ }
7746
+
7747
+ function visit(node: ts.Node): void {
7748
+ // JSX embedded inside the checked expression — see docstring.
7749
+ if (
7750
+ ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)
7751
+ ) {
7347
7752
  return
7348
7753
  }
7754
+
7755
+ // Type positions are not value positions. `({} as { count?: unknown })`
7756
+ // in a rendered position would otherwise read the type literal's property
7757
+ // name `count` as a bare reference to a signal of the same name and refuse
7758
+ // valid code — a false positive, the direction that breaks working code.
7759
+ // Covers every type node reachable from an expression: `as`/`satisfies`
7760
+ // types, type assertions, and generic type arguments.
7761
+ if (ts.isTypeNode(node)) return
7762
+
7763
+ // Zero-arg call with bare-identifier callee (`val()`): the correct
7764
+ // form. The callee is never visited as a bare reference; there are
7765
+ // no arguments to descend into either.
7766
+ if (ts.isCallExpression(node) && node.arguments.length === 0 && ts.isIdentifier(node.expression)) {
7767
+ return
7768
+ }
7769
+
7770
+ // Property access: `obj.prop` — visit `obj` (a real reference),
7771
+ // never `prop` (a member-access tail, not a value read). #1100.
7772
+ if (ts.isPropertyAccessExpression(node)) {
7773
+ visit(node.expression)
7774
+ return
7775
+ }
7776
+
7777
+ // Object literal key: `{ X: value }` — `X` is a key, not a read.
7778
+ if (ts.isPropertyAssignment(node)) {
7779
+ if (ts.isComputedPropertyName(node.name)) visit(node.name.expression)
7780
+ visit(node.initializer)
7781
+ return
7782
+ }
7783
+
7784
+ // Shorthand property: `{ X }` — `X` is both key and value, so the
7785
+ // value side IS a bare reference.
7786
+ if (ts.isShorthandPropertyAssignment(node)) {
7787
+ if (ts.isIdentifier(node.name) && !isBound(node.name.text)) {
7788
+ const kind = reactiveNames.get(node.name.text)
7789
+ if (kind) report(node.name, node.name.text, kind)
7790
+ }
7791
+ return
7792
+ }
7793
+
7794
+ // Nested function/arrow: bind params + block-scoped locals declared
7795
+ // in its body, recurse, unbind. A name shadowed here is a different
7796
+ // binding, not the signal/memo of the same name.
7797
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
7798
+ const bound = new Set<string>()
7799
+ // Parameters bind LEFT TO RIGHT, and each default is evaluated against
7800
+ // the scope as it stands at that point: `(a, b = a) => …` reads the
7801
+ // already-bound parameter `a`, never an outer binding of the same name
7802
+ // (verified against V8). So `bound` is pushed FIRST and grows as the
7803
+ // loop walks — each default sees only the parameters before it.
7804
+ boundStack.push(bound)
7805
+ for (const p of node.parameters) {
7806
+ if (p.initializer) visit(p.initializer)
7807
+ visitBindingDefaults(p.name, bound)
7808
+ }
7809
+ if (node.body && ts.isBlock(node.body)) collectBlockDeclarations(node.body, bound)
7810
+ if (node.body) visit(node.body)
7811
+ boundStack.pop()
7812
+ return
7813
+ }
7814
+
7815
+ // Variable declaration: the binding name itself isn't a reference;
7816
+ // only its initializer can be.
7817
+ if (ts.isVariableDeclaration(node)) {
7818
+ if (node.initializer) visit(node.initializer)
7819
+ // The names this declaration introduces shadow outer ones for any LATER
7820
+ // default in the same pattern (`const { a, b = a } = obj`), so they bind
7821
+ // into a scope of their own rather than leaking into the enclosing one.
7822
+ const declared = new Set<string>()
7823
+ boundStack.push(declared)
7824
+ visitBindingDefaults(node.name, declared)
7825
+ boundStack.pop()
7826
+ return
7827
+ }
7828
+
7829
+ // Bare identifier reference — the case this check exists for.
7830
+ if (ts.isIdentifier(node)) {
7831
+ if (isBound(node.text)) return
7832
+ const kind = reactiveNames.get(node.text)
7833
+ if (kind) report(node, node.text, kind)
7834
+ return
7835
+ }
7836
+
7837
+ ts.forEachChild(node, visit)
7349
7838
  }
7839
+
7840
+ visit(expr)
7350
7841
  }
7351
7842
 
7352
7843
  /**
@@ -7532,6 +8023,49 @@ function isPropsReference(expr: string, ctx: TransformContext, visited?: Set<str
7532
8023
  return false
7533
8024
  }
7534
8025
 
8026
+ /**
8027
+ * Does this element carry a `{...spread}` that forwards the caller's
8028
+ * leftover props (the destructured `...rest` binding or a whole `(props)`
8029
+ * parameter, through any alias hop)?
8030
+ *
8031
+ * Such a spread is the one attribute source whose keys are unknowable at
8032
+ * compile time, so neither template can carry it: the SSR adapters render
8033
+ * it through their own spread helper against real request data, and the
8034
+ * client's registration template drops it entirely, leaving the runtime's
8035
+ * `applyRestAttrs` as the only thing that can apply it after a pure CSR
8036
+ * mount. `applyRestAttrs` addresses its element by slot id, so an element
8037
+ * whose only dynamic attribute source is this spread still needs one —
8038
+ * otherwise `init` has no patch point, and every caller-supplied attribute
8039
+ * is silently absent under `createComponent` while SSR and hydration look
8040
+ * correct (#2754).
8041
+ *
8042
+ * Deliberately narrower than "has any spread": a spread of an ordinary
8043
+ * object (`{...extra}`) IS statically emitted into both templates, needs
8044
+ * no runtime application, and must not start allocating slot ids.
8045
+ */
8046
+ function forwardsCallerRestProps(attrs: IRAttribute[], ctx: TransformContext): boolean {
8047
+ for (const attr of attrs) {
8048
+ if (attr.value.kind !== 'spread') continue
8049
+ const constants = restSpreadConstantValues(ctx)
8050
+ for (const name of new Set([attr.value.expr, attr.value.templateExpr])) {
8051
+ if (!name) continue
8052
+ if (resolveRestSpreadOriginCore(ctx.analyzer, constants, name) !== null) return true
8053
+ }
8054
+ }
8055
+ return false
8056
+ }
8057
+
8058
+ /** `ctx.analyzer.localConstants` indexed by name, memoized per component walk. */
8059
+ function restSpreadConstantValues(ctx: TransformContext): ReadonlyMap<string, string | undefined> {
8060
+ if (ctx._restSpreadConstantValues) return ctx._restSpreadConstantValues
8061
+ const byName = new Map<string, string | undefined>()
8062
+ for (const constant of ctx.analyzer.localConstants) {
8063
+ if (!byName.has(constant.name)) byName.set(constant.name, constant.value)
8064
+ }
8065
+ ctx._restSpreadConstantValues = byName
8066
+ return byName
8067
+ }
8068
+
7535
8069
  /**
7536
8070
  * Check if any attributes in the list are reactive (depend on signals/memos).
7537
8071
  * Reactive attributes need a slotId so the client JS can update them.