@barefootjs/jsx 0.33.2 → 0.33.3

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 (47) 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 +707 -431
  8. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/imports.d.ts +60 -2
  12. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/prop-handling.d.ts +4 -7
  14. package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/utils.d.ts +26 -2
  16. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  17. package/dist/jsx-to-ir.d.ts.map +1 -1
  18. package/dist/props-binding.d.ts +35 -0
  19. package/dist/props-binding.d.ts.map +1 -1
  20. package/dist/types.d.ts +50 -13
  21. package/dist/types.d.ts.map +1 -1
  22. package/package.json +2 -2
  23. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +145 -97
  24. package/src/__tests__/child-component-ref-not-mirrored.test.ts +90 -0
  25. package/src/__tests__/ir-to-client-js/imports.test.ts +107 -0
  26. package/src/__tests__/ir-to-client-js/merge-compiled-client-js-imports.test.ts +138 -0
  27. package/src/__tests__/issue-2754-rest-spread-needs-slot.test.ts +85 -0
  28. package/src/__tests__/issue-2756-loop-row-honors-client-only.test.ts +173 -0
  29. package/src/__tests__/merge-template-imports.test.ts +41 -1
  30. package/src/__tests__/multi-component-shared-default-import.test.ts +55 -0
  31. package/src/__tests__/root-key-relay.test.ts +170 -0
  32. package/src/__tests__/signal-getter-not-called.test.ts +149 -0
  33. package/src/__tests__/state-only-file-default-import.test.ts +47 -0
  34. package/src/analyzer.ts +36 -0
  35. package/src/compiler.ts +94 -104
  36. package/src/index.ts +1 -1
  37. package/src/ir-to-client-js/collect-elements.ts +27 -5
  38. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +6 -2
  39. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -2
  40. package/src/ir-to-client-js/html-template.ts +122 -12
  41. package/src/ir-to-client-js/imports.ts +178 -5
  42. package/src/ir-to-client-js/index.ts +5 -0
  43. package/src/ir-to-client-js/prop-handling.ts +6 -17
  44. package/src/ir-to-client-js/utils.ts +30 -2
  45. package/src/jsx-to-ir.ts +480 -52
  46. package/src/props-binding.ts +51 -0
  47. package/src/types.ts +47 -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
@@ -4104,6 +4189,46 @@ function tagLoopItemRootComponents(nodes: readonly IRNode[]): void {
4104
4189
  }
4105
4190
  }
4106
4191
 
4192
+ /**
4193
+ * Attach the loop's resolved key ATTRIBUTE (name + value) to the element(s)
4194
+ * that will actually render it — the write-side twin of `extractLoopKey`,
4195
+ * which reads the SAME `key={}` JSX attribute (#2753: the SSR-side row-key
4196
+ * decision is resolved once here, so the 9 SSR adapters stop re-deriving the
4197
+ * attribute name from their own depth tracking, and stop disagreeing about
4198
+ * whether an unkeyed row gets one at all).
4199
+ *
4200
+ * Mutates in place — `tagLoopItemRootComponents`'s style, run immediately
4201
+ * after `key`/`depth` resolve, on the same node objects `children` already
4202
+ * owns — rather than rebuilding the tree immutably: unlike `markCarrierIn`
4203
+ * (which hands a fresh tree back to a caller holding the old one), this runs
4204
+ * on IR this same function just built and is about to return, so there is
4205
+ * no stale external reference to protect.
4206
+ *
4207
+ * Deliberately does NOT remove the literal `key` entry from `.attrs`: the
4208
+ * CSR/client-JS codegen path (`ir-to-client-js/html-template.ts`'s
4209
+ * `a.name === 'key' ? keyAttrName(loopDepth) : ...`) already reads it
4210
+ * directly and is not part of #2753's 16-site duplication — it derives the
4211
+ * same name from the same `keyAttrName()` single source of truth, just at a
4212
+ * different phase (client-template bake time, not SSR emit time). Adapters
4213
+ * skip the raw `key` attr in their generic `renderAttributes` loop instead
4214
+ * (Hono's own JSX runtime already drops it silently either way).
4215
+ *
4216
+ * `component` bodies are left untouched: a `<Comp key={x}/>` row root's key
4217
+ * is a PROP (`renderComponentProps`'s own `data-key` prop-forwarding), not
4218
+ * an `IRElement` — a separate mechanism #2753 did not measure as broken and
4219
+ * this change does not touch.
4220
+ */
4221
+ function applyLoopKeyAttr(node: IRNode, name: string, value: string): void {
4222
+ if (node.type === 'element') {
4223
+ node.keyAttr = { name, value }
4224
+ return
4225
+ }
4226
+ if (node.type === 'conditional') {
4227
+ applyLoopKeyAttr(node.whenTrue, name, value)
4228
+ applyLoopKeyAttr(node.whenFalse, name, value)
4229
+ }
4230
+ }
4231
+
4107
4232
  function loopBodyItemConditional(children: IRNode[]): IRConditional | null {
4108
4233
  const real = children.filter(
4109
4234
  (c) => !(c.type === 'text' && typeof c.value === 'string' && !c.value.trim())
@@ -4946,6 +5071,20 @@ function transformMapCall(
4946
5071
  ? extractItemConditionalKey(itemConditional!)
4947
5072
  : (children.length > 0 ? extractLoopKey(children[0]) : null)
4948
5073
 
5074
+ // #2753: resolve this row's key ATTRIBUTE (name + value) here, once, and
5075
+ // stamp it onto the element(s) that render it — `null` (unkeyed) means no
5076
+ // `IRElement.keyAttr` at all, which is the correct final answer for every
5077
+ // adapter and the client runtime alike, not a hint they still have to
5078
+ // gate. See `IRElement.keyAttr` / `applyLoopKeyAttr`'s docstrings.
5079
+ if (key !== null) {
5080
+ const resolvedKeyAttrName = keyAttrName(depth)
5081
+ if (bodyIsItemConditional) {
5082
+ applyLoopKeyAttr(itemConditional!, resolvedKeyAttrName, key)
5083
+ } else if (children.length > 0) {
5084
+ applyLoopKeyAttr(children[0], resolvedKeyAttrName, key)
5085
+ }
5086
+ }
5087
+
4949
5088
  // Stage 3 / D5 (spec/callback-fidelity.md) — the keyFn is hoisted: `mapArray`
4950
5089
  // computes it from the raw item BEFORE the callback body runs, so the key must
4951
5090
  // be derivable from the item (and index), never from a value the preamble
@@ -6244,8 +6383,20 @@ function getAttributeValue(attr: ts.JsxAttribute, ctx: TransformContext): AttrVa
6244
6383
  return AttrValueOf.expression('undefined')
6245
6384
  }
6246
6385
 
6247
- // Check for bare signal/memo identifier (BF044)
6248
- checkBareSignalOrMemoIdentifier(expr, ctx)
6386
+ // Check for bare signal/memo identifier (BF044). `getAttributeValue`
6387
+ // serves BOTH a DOM element's attributes and a component's props
6388
+ // (two call sites above, in `processElementAttributes` and
6389
+ // `processComponentProps`), and only the element-attribute case is
6390
+ // a RENDERED position — a component prop is an opaque value handed
6391
+ // to the component, and this codebase's Context-Provider idiom
6392
+ // depends on that value being an uncalled accessor (`value={{
6393
+ // open }}`, ui/components/ui/select/index.tsx — calling it eagerly
6394
+ // there would freeze the value at provider-render time and break
6395
+ // every consumer's reactivity). So nested descent is gated on
6396
+ // element-vs-component, derived from the tag this attribute lives
6397
+ // on; the top-level bare-identifier check stays unconditional
6398
+ // either way, unchanged from this diagnostic's original behaviour.
6399
+ checkBareSignalOrMemoIdentifier(expr, ctx, { descendNested: isRenderedElementAttribute(attr, ctx) })
6249
6400
 
6250
6401
  // Static style object: style={{ key: 'value', ... }} → CSS string at compile time
6251
6402
  if (attr.name.getText(ctx.sourceFile) === 'style' && ts.isObjectLiteralExpression(expr)) {
@@ -7300,53 +7451,287 @@ function processComponentProps(
7300
7451
  // =============================================================================
7301
7452
 
7302
7453
  /**
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.
7454
+ * Is `attr` an attribute on a DOM/SVG element's opening tag, as opposed
7455
+ * to a prop on a component? `JsxAttributes.parent` is always the
7456
+ * `JsxOpeningElement`/`JsxSelfClosingElement` the attribute list belongs
7457
+ * to (`JsxOpeningLikeElement`); its `tagName` follows the same
7458
+ * uppercase-means-component convention `transformSelfClosingElement`
7459
+ * already uses (`isComponent = /^[A-Z]/.test(tagName)`) — lowercase or
7460
+ * hyphenated (a custom element) is a real DOM node, capitalised or
7461
+ * dotted (`Select.Provider`) is a component.
7462
+ *
7463
+ * Feeds `checkBareSignalOrMemoIdentifier`'s `descendNested` gate: a
7464
+ * DOM attribute value is a RENDERED position (this is genuinely what
7465
+ * ends up in the DOM), so a bare getter buried inside it — `style={{
7466
+ * color: val }}` — is exactly as much a forgotten `()` as a top-level
7467
+ * one. A component prop is not rendered — it's an opaque value handed
7468
+ * to the component — and this codebase's Context-Provider idiom
7469
+ * depends on that value being an UNCALLED accessor
7470
+ * (`ui/components/ui/select/index.tsx`'s `value={{ open, ... }}`), so
7471
+ * nested descent must not fire there.
7472
+ */
7473
+ function isRenderedElementAttribute(attr: ts.JsxAttribute, ctx: TransformContext): boolean {
7474
+ const tagName = attr.parent.parent.tagName.getText(ctx.sourceFile)
7475
+ return !/^[A-Z]/.test(tagName)
7476
+ }
7477
+
7478
+ /**
7479
+ * Check for a bare signal/memo getter reference in `expr`.
7480
+ * Emits BF044 when a signal/memo getter is passed without calling it.
7305
7481
  * e.g., value={count} instead of value={count()}
7482
+ *
7483
+ * The top-level check — is `expr` itself a bare reactive identifier —
7484
+ * always runs, unchanged from this diagnostic's original behaviour
7485
+ * (`value={count}`, `<Foo x={count} />` keep firing regardless of
7486
+ * position). `descendNested` additionally opts into walking INSIDE a
7487
+ * composite expression — call arguments, template-literal spans,
7488
+ * ternary branches (condition + both arms), array/object literal
7489
+ * members, binary/logical operands, parenthesized wrappers — to catch
7490
+ * a bare reference buried anywhere in that reachable set (#2755,
7491
+ * #2751: this is the single upstream gate whose shallow top-level-only
7492
+ * check let both downstream bugs through). Callers opt in only for
7493
+ * RENDERED positions (a JSX text child; a DOM element's attribute value
7494
+ * via `isRenderedElementAttribute`) — never for a component prop, where
7495
+ * a bare getter is this codebase's deliberate Context-Provider idiom
7496
+ * (measured: 66 such uses across `ui/components/**`/`site/**`, all
7497
+ * legitimate, none a forgotten `()` — widening the gate to fire there
7498
+ * would be a correctness regression, not a fix).
7499
+ *
7500
+ * Two structural exclusions apply whenever descending, both required
7501
+ * so the walk doesn't false-positive on shapes that read the name
7502
+ * without evaluating its signal value:
7503
+ * - the callee of a zero-arg call (`val()`) is the CORRECT form and
7504
+ * is never visited as a bare reference — only the call's arguments
7505
+ * (there are none) would be descended into.
7506
+ * - a property-access chain's object is visited but its property
7507
+ * NAME never is, so `ctx.val` never fires when a signal `val`
7508
+ * exists — the exact bug class documented in this file's
7509
+ * `csr-substitute.ts` sibling walker, #1100.
7510
+ * JSX embedded inside the checked expression (a `.map()`/`.filter()`
7511
+ * callback returning JSX is the common shape) is NOT this walk's
7512
+ * territory either: `transformNode` independently descends into that
7513
+ * element's own attributes and children, each re-entering this same
7514
+ * check on its own real value expression. Descending into it here
7515
+ * would be actively wrong, not just redundant — a JSX element's `name`
7516
+ * nodes (an attribute's NAME, a tag name) are plain `ts.Identifier`s
7517
+ * with no marker distinguishing them from a value read, so without
7518
+ * this guard an attribute like `checked={x}` gets its own NAME flagged
7519
+ * whenever a same-named signal is in scope (measured directly against
7520
+ * `checkbox-demo.tsx` while building this walk).
7521
+ * A nested function/arrow body that rebinds the name shadows it for
7522
+ * everything under that body — modeled with a local bound-name stack,
7523
+ * same shape as `csrSubstituteOnce`'s `boundStack`
7524
+ * (`ir-to-client-js/csr-substitute.ts`), which solves this exact
7525
+ * member-access/shadowing pair of problems for the CSR-substitution
7526
+ * walk. Deliberately NOT threaded through the shared `BindingScope`
7527
+ * service (`scope/binding-scope.ts`) — that scope answers "what does
7528
+ * the surrounding loop/callback bind"; this walk only needs shadowing
7529
+ * introduced by nested function literals inside the single expression
7530
+ * being checked, the same reasoning `csrSubstituteOnce` already
7531
+ * documents for its own local `boundStack`.
7306
7532
  */
7307
7533
  function checkBareSignalOrMemoIdentifier(
7308
7534
  expr: ts.Expression,
7309
- ctx: TransformContext
7535
+ ctx: TransformContext,
7536
+ options?: { descendNested?: boolean }
7310
7537
  ): void {
7311
- if (!ts.isIdentifier(expr)) return
7312
-
7313
- const name = expr.text
7538
+ const reactiveNames = new Map<string, 'signal' | 'memo'>()
7539
+ for (const signal of ctx.analyzer.signals) reactiveNames.set(signal.getter, 'signal')
7540
+ for (const memo of ctx.analyzer.memos) reactiveNames.set(memo.name, 'memo')
7541
+ if (reactiveNames.size === 0) return
7314
7542
 
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
- )
7543
+ const report = (id: ts.Identifier, name: string, kind: 'signal' | 'memo'): void => {
7544
+ const label = kind === 'signal' ? 'Signal' : 'Memo'
7545
+ ctx.analyzer.errors.push(
7546
+ createError(ErrorCodes.SIGNAL_GETTER_NOT_CALLED,
7547
+ getSourceLocation(id, ctx.sourceFile, ctx.filePath),
7548
+ {
7549
+ message: `${label} getter '${name}' passed without calling it`,
7550
+ suggestion: {
7551
+ message: `${label} getters must be called to read the value. Use \`${name}()\` instead of \`${name}\`.`,
7552
+ replacement: `${name}()`,
7553
+ },
7554
+ }
7328
7555
  )
7556
+ )
7557
+ }
7558
+
7559
+ // Top-level check — unchanged from this diagnostic's original
7560
+ // position-gating (fires unconditionally, everywhere), but NOW also
7561
+ // respects the ambient loop/callback `BindingScope` a shadowed name
7562
+ // needs — a pre-existing gap (the original single-line
7563
+ // `if (!ts.isIdentifier(expr)) return` never consulted scope either)
7564
+ // that stayed invisible only because a bare top-level identifier
7565
+ // shadowed by a `.map(name => ...)` row param never happened to be
7566
+ // exercised by any prior test. `ctx.scope` is the LIVE scope at this
7567
+ // JSX node — already entered for the current loop row/callback frame
7568
+ // by the time an attribute/child expression under it is checked
7569
+ // (`enterLoopRow`/`enterCallback`, `scope/binding-scope.ts`) — so
7570
+ // `isBound` here is a shadow-guard read in exactly the sense
7571
+ // `spec/compiler.md`'s `BindingScope` section describes: is a signal
7572
+ // getter of this name shadowed HERE, not "does this expression need
7573
+ // its own reactive slot".
7574
+ if (ts.isIdentifier(expr)) {
7575
+ const kind = reactiveNames.get(expr.text)
7576
+ if (kind && !ctx.scope.isBound(expr.text)) report(expr, expr.text, kind)
7577
+ return
7578
+ }
7579
+
7580
+ if (!options?.descendNested) return
7581
+
7582
+ const boundStack: Array<Set<string>> = []
7583
+ const isBound = (name: string): boolean => {
7584
+ for (let i = boundStack.length - 1; i >= 0; i--) {
7585
+ if (boundStack[i].has(name)) return true
7586
+ }
7587
+ return ctx.scope.isBound(name)
7588
+ }
7589
+
7590
+ /**
7591
+ * Visit the default VALUES in a binding pattern (`const { x = count } = obj`,
7592
+ * `(f = count) => …`), binding each name into `bound` as it is introduced.
7593
+ *
7594
+ * `collectBindingNames` deliberately walks only NAMES — a binding name is
7595
+ * not a reference — but a default value is an ordinary expression, so a bare
7596
+ * getter can hide there. Not hypothetical: measured, such a shape emits a
7597
+ * module-scope `template` thunk that references a component-scope binding
7598
+ * and throws `ReferenceError` on CSR mount, which is #2751's mechanism —
7599
+ * the class this check exists to close.
7600
+ *
7601
+ * Binds as it goes because JS binds left to right WITHIN a pattern too:
7602
+ * `({ a, b = a })` reads the already-initialized `a`, so `b`'s default must
7603
+ * be checked against a scope that already contains `a`. Visiting every
7604
+ * default first and binding afterwards would flag `a` as a bare signal read
7605
+ * whenever a pattern name happens to collide with a real signal — a false
7606
+ * positive, which is the direction that breaks working code.
7607
+ */
7608
+ const visitBindingDefaults = (name: ts.BindingName, bound: Set<string>): void => {
7609
+ if (ts.isIdentifier(name)) {
7610
+ bound.add(name.text)
7329
7611
  return
7330
7612
  }
7613
+ for (const el of name.elements) {
7614
+ if (ts.isOmittedExpression(el)) continue
7615
+ if (el.initializer) visit(el.initializer)
7616
+ visitBindingDefaults(el.name, bound)
7617
+ }
7331
7618
  }
7332
7619
 
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
- )
7620
+ const collectBindingNames = (name: ts.BindingName, out: Set<string>): void => {
7621
+ if (ts.isIdentifier(name)) out.add(name.text)
7622
+ else if (ts.isObjectBindingPattern(name)) {
7623
+ for (const el of name.elements) collectBindingNames(el.name, out)
7624
+ } else if (ts.isArrayBindingPattern(name)) {
7625
+ for (const el of name.elements) {
7626
+ if (!ts.isOmittedExpression(el)) collectBindingNames(el.name, out)
7627
+ }
7628
+ }
7629
+ }
7630
+
7631
+ const collectBlockDeclarations = (block: ts.Block, out: Set<string>): void => {
7632
+ for (const stmt of block.statements) {
7633
+ if (ts.isVariableStatement(stmt)) {
7634
+ for (const decl of stmt.declarationList.declarations) collectBindingNames(decl.name, out)
7635
+ } else if (ts.isFunctionDeclaration(stmt) && stmt.name) {
7636
+ out.add(stmt.name.text)
7637
+ }
7638
+ }
7639
+ }
7640
+
7641
+ function visit(node: ts.Node): void {
7642
+ // JSX embedded inside the checked expression — see docstring.
7643
+ if (
7644
+ ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)
7645
+ ) {
7347
7646
  return
7348
7647
  }
7648
+
7649
+ // Type positions are not value positions. `({} as { count?: unknown })`
7650
+ // in a rendered position would otherwise read the type literal's property
7651
+ // name `count` as a bare reference to a signal of the same name and refuse
7652
+ // valid code — a false positive, the direction that breaks working code.
7653
+ // Covers every type node reachable from an expression: `as`/`satisfies`
7654
+ // types, type assertions, and generic type arguments.
7655
+ if (ts.isTypeNode(node)) return
7656
+
7657
+ // Zero-arg call with bare-identifier callee (`val()`): the correct
7658
+ // form. The callee is never visited as a bare reference; there are
7659
+ // no arguments to descend into either.
7660
+ if (ts.isCallExpression(node) && node.arguments.length === 0 && ts.isIdentifier(node.expression)) {
7661
+ return
7662
+ }
7663
+
7664
+ // Property access: `obj.prop` — visit `obj` (a real reference),
7665
+ // never `prop` (a member-access tail, not a value read). #1100.
7666
+ if (ts.isPropertyAccessExpression(node)) {
7667
+ visit(node.expression)
7668
+ return
7669
+ }
7670
+
7671
+ // Object literal key: `{ X: value }` — `X` is a key, not a read.
7672
+ if (ts.isPropertyAssignment(node)) {
7673
+ if (ts.isComputedPropertyName(node.name)) visit(node.name.expression)
7674
+ visit(node.initializer)
7675
+ return
7676
+ }
7677
+
7678
+ // Shorthand property: `{ X }` — `X` is both key and value, so the
7679
+ // value side IS a bare reference.
7680
+ if (ts.isShorthandPropertyAssignment(node)) {
7681
+ if (ts.isIdentifier(node.name) && !isBound(node.name.text)) {
7682
+ const kind = reactiveNames.get(node.name.text)
7683
+ if (kind) report(node.name, node.name.text, kind)
7684
+ }
7685
+ return
7686
+ }
7687
+
7688
+ // Nested function/arrow: bind params + block-scoped locals declared
7689
+ // in its body, recurse, unbind. A name shadowed here is a different
7690
+ // binding, not the signal/memo of the same name.
7691
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
7692
+ const bound = new Set<string>()
7693
+ // Parameters bind LEFT TO RIGHT, and each default is evaluated against
7694
+ // the scope as it stands at that point: `(a, b = a) => …` reads the
7695
+ // already-bound parameter `a`, never an outer binding of the same name
7696
+ // (verified against V8). So `bound` is pushed FIRST and grows as the
7697
+ // loop walks — each default sees only the parameters before it.
7698
+ boundStack.push(bound)
7699
+ for (const p of node.parameters) {
7700
+ if (p.initializer) visit(p.initializer)
7701
+ visitBindingDefaults(p.name, bound)
7702
+ }
7703
+ if (node.body && ts.isBlock(node.body)) collectBlockDeclarations(node.body, bound)
7704
+ if (node.body) visit(node.body)
7705
+ boundStack.pop()
7706
+ return
7707
+ }
7708
+
7709
+ // Variable declaration: the binding name itself isn't a reference;
7710
+ // only its initializer can be.
7711
+ if (ts.isVariableDeclaration(node)) {
7712
+ if (node.initializer) visit(node.initializer)
7713
+ // The names this declaration introduces shadow outer ones for any LATER
7714
+ // default in the same pattern (`const { a, b = a } = obj`), so they bind
7715
+ // into a scope of their own rather than leaking into the enclosing one.
7716
+ const declared = new Set<string>()
7717
+ boundStack.push(declared)
7718
+ visitBindingDefaults(node.name, declared)
7719
+ boundStack.pop()
7720
+ return
7721
+ }
7722
+
7723
+ // Bare identifier reference — the case this check exists for.
7724
+ if (ts.isIdentifier(node)) {
7725
+ if (isBound(node.text)) return
7726
+ const kind = reactiveNames.get(node.text)
7727
+ if (kind) report(node, node.text, kind)
7728
+ return
7729
+ }
7730
+
7731
+ ts.forEachChild(node, visit)
7349
7732
  }
7733
+
7734
+ visit(expr)
7350
7735
  }
7351
7736
 
7352
7737
  /**
@@ -7532,6 +7917,49 @@ function isPropsReference(expr: string, ctx: TransformContext, visited?: Set<str
7532
7917
  return false
7533
7918
  }
7534
7919
 
7920
+ /**
7921
+ * Does this element carry a `{...spread}` that forwards the caller's
7922
+ * leftover props (the destructured `...rest` binding or a whole `(props)`
7923
+ * parameter, through any alias hop)?
7924
+ *
7925
+ * Such a spread is the one attribute source whose keys are unknowable at
7926
+ * compile time, so neither template can carry it: the SSR adapters render
7927
+ * it through their own spread helper against real request data, and the
7928
+ * client's registration template drops it entirely, leaving the runtime's
7929
+ * `applyRestAttrs` as the only thing that can apply it after a pure CSR
7930
+ * mount. `applyRestAttrs` addresses its element by slot id, so an element
7931
+ * whose only dynamic attribute source is this spread still needs one —
7932
+ * otherwise `init` has no patch point, and every caller-supplied attribute
7933
+ * is silently absent under `createComponent` while SSR and hydration look
7934
+ * correct (#2754).
7935
+ *
7936
+ * Deliberately narrower than "has any spread": a spread of an ordinary
7937
+ * object (`{...extra}`) IS statically emitted into both templates, needs
7938
+ * no runtime application, and must not start allocating slot ids.
7939
+ */
7940
+ function forwardsCallerRestProps(attrs: IRAttribute[], ctx: TransformContext): boolean {
7941
+ for (const attr of attrs) {
7942
+ if (attr.value.kind !== 'spread') continue
7943
+ const constants = restSpreadConstantValues(ctx)
7944
+ for (const name of new Set([attr.value.expr, attr.value.templateExpr])) {
7945
+ if (!name) continue
7946
+ if (resolveRestSpreadOriginCore(ctx.analyzer, constants, name) !== null) return true
7947
+ }
7948
+ }
7949
+ return false
7950
+ }
7951
+
7952
+ /** `ctx.analyzer.localConstants` indexed by name, memoized per component walk. */
7953
+ function restSpreadConstantValues(ctx: TransformContext): ReadonlyMap<string, string | undefined> {
7954
+ if (ctx._restSpreadConstantValues) return ctx._restSpreadConstantValues
7955
+ const byName = new Map<string, string | undefined>()
7956
+ for (const constant of ctx.analyzer.localConstants) {
7957
+ if (!byName.has(constant.name)) byName.set(constant.name, constant.value)
7958
+ }
7959
+ ctx._restSpreadConstantValues = byName
7960
+ return byName
7961
+ }
7962
+
7535
7963
  /**
7536
7964
  * Check if any attributes in the list are reactive (depend on signals/memos).
7537
7965
  * Reactive attributes need a slotId so the client JS can update them.