@barefootjs/jsx 0.33.4 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/adapters/child-scope.d.ts +13 -0
  2. package/dist/adapters/child-scope.d.ts.map +1 -1
  3. package/dist/adapters/parsed-expr-emitter.d.ts +17 -0
  4. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/errors.d.ts +1 -0
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/expression-parser.d.ts +17 -4
  9. package/dist/expression-parser.d.ts.map +1 -1
  10. package/dist/index.d.ts +4 -3
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1399 -1173
  13. package/dist/ir-to-client-js/child-components.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/component-scope.d.ts +20 -0
  16. package/dist/ir-to-client-js/component-scope.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/compute-inlinability.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/shared.d.ts +4 -3
  19. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/csr-substitute.d.ts +35 -9
  22. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/emit-reactive.d.ts +10 -0
  24. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/html-template.d.ts +49 -2
  26. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/imports.d.ts +2 -2
  28. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/rewrite-props-object.d.ts +12 -1
  31. package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
  32. package/dist/jsx-to-ir.d.ts.map +1 -1
  33. package/dist/lowering-registry.d.ts +12 -0
  34. package/dist/lowering-registry.d.ts.map +1 -1
  35. package/dist/prop-rewrite.d.ts +5 -2
  36. package/dist/prop-rewrite.d.ts.map +1 -1
  37. package/dist/props-binding.d.ts +49 -11
  38. package/dist/props-binding.d.ts.map +1 -1
  39. package/dist/types.d.ts +6 -5
  40. package/dist/types.d.ts.map +1 -1
  41. package/package.json +2 -2
  42. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +15 -15
  43. package/src/__tests__/aliased-import-child-registry-2777.test.ts +112 -0
  44. package/src/__tests__/date-lowering.test.ts +18 -4
  45. package/src/__tests__/inner-loop-mathml-namespace.test.ts +1 -1
  46. package/src/__tests__/inner-loop-svg-namespace.test.ts +4 -4
  47. package/src/__tests__/issue-2724-prop-alias-loop-not-static.test.ts +355 -0
  48. package/src/__tests__/issue-2737-props-object-alias-template.test.ts +86 -0
  49. package/src/__tests__/issue-2756-input-skeleton-bails.test.ts +123 -0
  50. package/src/__tests__/issue-2778-getter-alias-csr-template.test.ts +113 -0
  51. package/src/__tests__/issue-2806-rest-bag-template-rewrite.test.ts +136 -0
  52. package/src/__tests__/issue-2835-props-type-member-name-collision.test.ts +259 -0
  53. package/src/__tests__/loop-item-root-scope.test.ts +7 -3
  54. package/src/__tests__/lowering-value-position.test.ts +232 -0
  55. package/src/__tests__/markup-prop-brand.test.ts +165 -26
  56. package/src/__tests__/namespace-import-primitive.test.ts +244 -0
  57. package/src/__tests__/rewrite-destructured-props.test.ts +53 -0
  58. package/src/__tests__/static-loop-csr-materialize.test.ts +2 -2
  59. package/src/adapters/child-scope.ts +23 -0
  60. package/src/adapters/parsed-expr-emitter.ts +43 -0
  61. package/src/analyzer.ts +130 -0
  62. package/src/errors.ts +14 -1
  63. package/src/expression-parser.ts +63 -29
  64. package/src/index.ts +9 -1
  65. package/src/ir-to-client-js/child-components.ts +8 -1
  66. package/src/ir-to-client-js/collect-elements.ts +10 -38
  67. package/src/ir-to-client-js/component-scope.ts +60 -0
  68. package/src/ir-to-client-js/compute-inlinability.ts +46 -11
  69. package/src/ir-to-client-js/control-flow/shared.ts +12 -5
  70. package/src/ir-to-client-js/control-flow/stringify/loop.ts +8 -2
  71. package/src/ir-to-client-js/csr-substitute.ts +82 -16
  72. package/src/ir-to-client-js/emit-reactive.ts +29 -13
  73. package/src/ir-to-client-js/emit-registration.ts +1 -1
  74. package/src/ir-to-client-js/html-template.ts +141 -70
  75. package/src/ir-to-client-js/imports.ts +4 -0
  76. package/src/ir-to-client-js/index.ts +34 -23
  77. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +7 -2
  78. package/src/ir-to-client-js/rewrite-props-object.ts +14 -0
  79. package/src/jsx-to-ir.ts +200 -42
  80. package/src/lowering-registry.ts +21 -0
  81. package/src/prop-rewrite.ts +23 -5
  82. package/src/props-binding.ts +74 -18
  83. package/src/types.ts +6 -5
package/src/jsx-to-ir.ts CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  } from './types.ts'
41
41
  import { type AnalyzerContext, type MultiReturnJsxInfo, getSourceLocation, collectReactiveGetterNames } from './analyzer-context.ts'
42
42
  import { parseExpression, isSupported, parseBlockBody, foldBlockToExpr, predicateTernaryToLogical, tsNodeToParsedExpr, sortComparatorFromArrow, stringifyParsedExpr, cssKebabCase, CALLBACK_METHODS, type ParsedExpr } from './expression-parser.ts'
43
- import type { IRLoopSort, FunctionInfo } from './types.ts'
43
+ import type { IRLoopSort, FunctionInfo, ConstantInfo } from './types.ts'
44
44
  import { formatParamWithType } from './module-exports.ts'
45
45
  import { createError, ErrorCodes, internalInvariant } from './errors.ts'
46
46
  import { CLIENT_BUILTIN_SOURCE, isClientBuiltinName, type ClientBuiltinTag } from './builtins.ts'
@@ -49,7 +49,7 @@ import {
49
49
  rewriteBarePropRefs as rewriteBarePropRefsCore,
50
50
  collectAstPropRefs,
51
51
  } from './prop-rewrite.ts'
52
- import { buildPropAliasMap, resolveRestSpreadOriginCore } from './props-binding.ts'
52
+ import { boundPropLocalNames, buildPropAliasMap, resolveAliasOrigin, 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'
@@ -73,6 +73,7 @@ import { identifierPattern, identifierCallPattern } from './identifier-pattern.t
73
73
  interface ReactivityPatterns {
74
74
  signals: { getter: string; pattern: RegExp }[]
75
75
  memos: { name: string; pattern: RegExp }[]
76
+ /** For regex `props.<key>` detection only — for exact-name binding checks read `TransformContext.boundPropNames` instead (#2835). */
76
77
  props: { name: string; pattern: RegExp }[]
77
78
  constants: { name: string; value: string | undefined; pattern: RegExp }[]
78
79
  }
@@ -85,6 +86,14 @@ interface TransformContext {
85
86
  isRoot: boolean
86
87
  insideComponentChildren: boolean
87
88
  patterns: ReactivityPatterns
89
+ /**
90
+ * Local names actually bound by the props parameter (#2835) — unlike
91
+ * `patterns.props`, which for a whole `(props: Props)` parameter holds
92
+ * every TYPE-MEMBER name (needed so its regex `pattern` still matches
93
+ * `props.<key>` accesses), this is empty in that shape since none of
94
+ * those names are local bindings a shadow check could ever see.
95
+ */
96
+ boundPropNames: ReadonlySet<string>
88
97
  /** Shortcut for analyzer.getJS(node) */
89
98
  getJS(node: ts.Node): string
90
99
  /** getJS + rewrite destructured prop refs for client JS templates (#807) */
@@ -93,8 +102,20 @@ interface TransformContext {
93
102
  _reactiveGetterNames?: Set<string>
94
103
  /** Cached set of module-scope @client signal/memo names. */
95
104
  _moduleClientSignalNames?: Set<string>
96
- /** Cached `localConstants` index for `forwardsCallerRestProps`'s alias walk */
97
- _restSpreadConstantValues?: ReadonlyMap<string, string | undefined>
105
+ /**
106
+ * Cached `localConstants` index (name → value text), shared by every
107
+ * `resolveAliasOrigin` walk in this file — `forwardsCallerRestProps`'s
108
+ * rest/props-spread alias walk and `isArrayExprDirectPropRef`'s
109
+ * direct-prop-array alias walk (#2724).
110
+ */
111
+ _constantValuesByName?: ReadonlyMap<string, string | undefined>
112
+ /**
113
+ * Cached `localConstants` index (name → full `ConstantInfo`), for
114
+ * lookups that need more than the value text — e.g.
115
+ * `isArrayExprDirectPropRef` reading a constant's structured `.parsed`
116
+ * to recognize `const arr = props.items` (#2724).
117
+ */
118
+ _constantsByName?: ReadonlyMap<string, ConstantInfo>
98
119
  /** Cached set of destructured prop names for AST-based rewriting */
99
120
  _destructuredPropNames?: Set<string> | null
100
121
  /**
@@ -668,6 +689,10 @@ function getDestructuredPropAliases(ctx: TransformContext): Map<string, string>
668
689
  }
669
690
 
670
691
  function createTransformContext(analyzer: AnalyzerContext): TransformContext {
692
+ // Shared by `patterns.props` (regex matching) and `boundPropNames`
693
+ // (exact-name binding checks, #2835) so both agree on which prop
694
+ // params are in play — `children` excluded from both the same way.
695
+ const propsParamsForMatching = analyzer.propsParams.filter(p => p.name !== 'children')
671
696
  return {
672
697
  analyzer,
673
698
  sourceFile: analyzer.sourceFile,
@@ -681,6 +706,7 @@ function createTransformContext(analyzer: AnalyzerContext): TransformContext {
681
706
  insideComponentChildren: false,
682
707
  scope: BindingScope.EMPTY,
683
708
  loopDepth: 0,
709
+ boundPropNames: boundPropLocalNames({ propsParams: propsParamsForMatching, propsObjectName: analyzer.propsObjectName }),
684
710
  patterns: {
685
711
  signals: analyzer.signals.map(s => ({
686
712
  getter: s.getter,
@@ -690,9 +716,7 @@ function createTransformContext(analyzer: AnalyzerContext): TransformContext {
690
716
  name: m.name,
691
717
  pattern: identifierCallPattern(m.name),
692
718
  })),
693
- props: analyzer.propsParams
694
- .filter(p => p.name !== 'children')
695
- .map(p => ({ name: p.name, pattern: identifierPattern(p.name) })),
719
+ props: propsParamsForMatching.map(p => ({ name: p.name, pattern: identifierPattern(p.name) })),
696
720
  constants: analyzer.localConstants.map(c => ({
697
721
  name: c.name,
698
722
  value: c.value,
@@ -5479,8 +5503,8 @@ function buildFlatMapCallback(
5479
5503
  // Template variant per segment, like buildPreambleSegments: the hydrate
5480
5504
  // registration template is module scope (`template: (_p) => ...`), so a
5481
5505
  // bare destructured-prop reference must rewrite to `_p.xxx` there —
5482
- // `applyPropsRewrite` can't do it (destructured components have no props
5483
- // object name).
5506
+ // `rewritePropsObjectRef` can't do it (destructured components have no
5507
+ // props object name to rewrite from).
5484
5508
  const tpl = rewriteBarePropRefs(piece.js, body, ctx)
5485
5509
  return tpl !== undefined && tpl !== piece.js
5486
5510
  ? { kind: 'js', text: piece.js, templateText: tpl }
@@ -7393,10 +7417,7 @@ function expressionWrapsJsx(node: ts.Expression): boolean {
7393
7417
  * Refuse a component prop whose initializer is a ternary/array literally
7394
7418
  * wrapping JSX (#2667) — see `expressionWrapsJsx`'s docstring and the call
7395
7419
  * site above for why this can't fall through to the plain `expression`
7396
- * AttrValue path (raw JSX syntax would splice into the emitted client
7397
- * JS) and why the fragment-wrap escape is NOT offered here (BF021 message
7398
- * below explains the unsoundness inline; see #2667's tracking issue for
7399
- * the full door-inventory finding).
7420
+ * AttrValue path (raw JSX syntax would splice into the emitted client JS).
7400
7421
  */
7401
7422
  function reportNakedJsxWrapperProp(
7402
7423
  ctx: TransformContext,
@@ -7411,15 +7432,21 @@ function reportNakedJsxWrapperProp(
7411
7432
  `Prop '${propName}' is ${shape} wrapping JSX (${jsxExpr.getText(ctx.sourceFile)}). ` +
7412
7433
  `This shape is not compiled — only a JSX element/fragment given DIRECTLY as the prop value is.`,
7413
7434
  suggestion: {
7414
- message:
7415
- `Move the conditional/array out of the prop position: compute it in a local ` +
7416
- `const and pass it as the component's children instead of a named prop ` +
7417
- `(e.g. const ${propName} = ${jsxExpr.getText(ctx.sourceFile)}; <Comp>{${propName}}</Comp>). ` +
7418
- `Wrapping the ternary/array in a fragment at the prop position ` +
7419
- `(${propName}={<>{${jsxExpr.getText(ctx.sourceFile)}}</>}) is NOT a safe escape here: it compiles, ` +
7420
- `but the child's own reactive prop getter receives the branch's HTML unbranded and re-escapes it as ` +
7421
- `text on the child's very next reactive run, corrupting the DOM (a narrower gap #2651's door ` +
7422
- `inventory left open tracked separately).`,
7435
+ message: ts.isConditionalExpression(jsxExpr)
7436
+ ? `Move the conditional out of the prop position: compute it in a local const and pass it as ` +
7437
+ `the component's children instead of a named prop ` +
7438
+ `(e.g. const ${propName} = ${jsxExpr.getText(ctx.sourceFile)}; <Comp>{${propName}}</Comp>). ` +
7439
+ `Wrapping the ternary in a fragment at the prop position ` +
7440
+ `(${propName}={<>{${jsxExpr.getText(ctx.sourceFile)}}</>}) is also a safe escape (#2702): the ` +
7441
+ `compiler brands each JSX branch individually, so the child's reactive prop getter unwraps it ` +
7442
+ `correctly instead of re-escaping it as text.`
7443
+ : `Move the array out of the prop position: compute it in a local const and pass it as the ` +
7444
+ `component's children instead of a named prop ` +
7445
+ `(e.g. const ${propName} = ${jsxExpr.getText(ctx.sourceFile)}; <Comp>{${propName}}</Comp>). ` +
7446
+ `Wrapping the array in a fragment at the prop position is NOT a safe escape here: it compiles, ` +
7447
+ `but flattens to more than one part, which the child's reactive prop getter has no branding ` +
7448
+ `contract for (a multi-part jsx-children shape, tracked separately from #2702's single-` +
7449
+ `conditional fix).`,
7423
7450
  escape: [{ kind: 'rewrite' }],
7424
7451
  },
7425
7452
  }),
@@ -7474,14 +7501,13 @@ function processComponentProps(
7474
7501
  // plain `expression` path below, which stringifies the initializer's
7475
7502
  // SOURCE TEXT — literal JSX syntax spliced into the emitted client
7476
7503
  // JS (invalid at runtime; the #2651 door inventory's discovery).
7477
- // Refuse loudly instead of guessing a lowering: the fragment-wrap
7478
- // escape this diagnostic once considered recommending turns out to
7479
- // be unsound for the same shape (`isSingleElementJsxChildren`'s
7480
- // docstring in `ir-to-client-js/collect-elements.ts`a
7481
- // conditional-in-fragment reaches `initChild`'s getter UNbranded,
7482
- // corrupting the child's DOM the moment its own reactive effect
7483
- // first reads the prop), so only the children-passthrough escape is
7484
- // offered.
7504
+ // Refuse loudly instead of guessing a lowering. The fragment-wrap
7505
+ // escape IS safe for the ternary shape (#2702: `jsxChildrenPropGetterExpr`,
7506
+ // `ir-to-client-js/html-template.ts`, brands each JSX branch after the
7507
+ // fragment hoists the conditional) `reportNakedJsxWrapperProp`
7508
+ // offers it for that case. It stays unsafe for the array-literal
7509
+ // shape (a multi-part jsx-children value has no branding contract),
7510
+ // so only the children-passthrough escape is offered there.
7485
7511
  if (
7486
7512
  (ts.isConditionalExpression(jsxExpr) || ts.isArrayLiteralExpression(jsxExpr)) &&
7487
7513
  expressionWrapsJsx(jsxExpr)
@@ -7844,31 +7870,152 @@ function checkBareSignalOrMemoIdentifier(
7844
7870
  * Structural AST check: is the array expression a direct prop reference?
7845
7871
  *
7846
7872
  * Returns true only when arrayExpr is:
7847
- * (a) An Identifier that is a destructured prop binding (e.g. `toggleItems`)
7848
- * (b) A PropertyAccessExpression rooted at the props object (e.g. `props.items`)
7873
+ * (a) An Identifier that is a destructured prop binding (e.g. `toggleItems`),
7874
+ * OR reaches one through a bare `x = y` local-alias-hop chain (e.g.
7875
+ * `const toggleItems__alias = toggleItems`, any number of hops, `const`
7876
+ * or `let`, #2724 — see `propAliasHopCandidates`'s docstring for why
7877
+ * `let` stays eligible)
7878
+ * (b) A PropertyAccessExpression rooted at the props object (e.g. `props.items`),
7879
+ * OR one whose object reaches the props object through the same kind of
7880
+ * alias-hop chain as (a) (e.g. `const p = props; p.items`, #2724 review)
7849
7881
  *
7850
7882
  * Unlike the regex-based `isPropsReference`, this avoids false positives from
7851
7883
  * unrelated identifiers that happen to share a prop name (e.g. `state.items`
7852
- * when `items` is also a prop).
7884
+ * when `items` is also a prop) — the alias-hop walk only ever follows a
7885
+ * constant's own bare-identifier value text or its structured `.parsed`
7886
+ * shape, never a substring/regex match against arbitrary expression text.
7887
+ * `propAliasHopCandidates` additionally restricts which constants are
7888
+ * eligible hop sources (see its docstring). A name that merely COLLIDES
7889
+ * with a prop's name without being bound to it (a module constant sharing a
7890
+ * whole-`props`-object type's member name) is excluded by the terminal
7891
+ * (`isDirectPropBindingName` reads `ctx.boundPropNames`, not the wider
7892
+ * `ctx.patterns.props` — #2835).
7893
+ *
7894
+ * This is more than a "prop reference somewhere in the expression" check
7895
+ * (`isPropsReference`'s regex semantics): the return value also feeds
7896
+ * `IRLoop.isPropDerivedArray`, which the Go adapter uses to decide whether a
7897
+ * nested component's field is literally the loop's prop-sourced data — a
7898
+ * false positive there would misclassify DSL-adapter codegen, not just add
7899
+ * one extra `mapArray`. So the alias walk stays structural (AST-derived
7900
+ * `ConstantInfo.parsed`), not a second regex-based implementation of
7901
+ * `isPropsReference`/`isSignalOrMemoReference`'s chain walk.
7853
7902
  */
7854
7903
  function isArrayExprDirectPropRef(arrayExpr: ts.Expression, ctx: TransformContext): boolean {
7855
- const propNames = new Set(ctx.patterns.props.map(p => p.name))
7856
7904
  const propsObjName = ctx.analyzer.propsObjectName
7857
7905
 
7858
7906
  if (ts.isIdentifier(arrayExpr)) {
7859
- return propNames.has(arrayExpr.text)
7907
+ return resolveAliasOrigin(propAliasHopCandidates(ctx), arrayExpr.text, name => (isDirectPropBindingName(name, ctx) ? true : null)) === true
7860
7908
  }
7861
7909
 
7862
7910
  if (ts.isPropertyAccessExpression(arrayExpr) && propsObjName) {
7863
7911
  const obj = arrayExpr.expression
7864
- if (ts.isIdentifier(obj) && obj.text === propsObjName) {
7865
- return true
7912
+ if (ts.isIdentifier(obj)) {
7913
+ return resolveAliasOrigin(propAliasHopCandidates(ctx), obj.text, name => (name === propsObjName ? true : null)) === true
7866
7914
  }
7867
7915
  }
7868
7916
 
7869
7917
  return false
7870
7918
  }
7871
7919
 
7920
+ /**
7921
+ * Does the local name `name` itself directly denote a prop — either a
7922
+ * destructured prop binding, or a local constant whose value is a bare
7923
+ * `<propsObjName>.<key>` member access (`const arr = props.items`)? The
7924
+ * alias-hop walk in `isArrayExprDirectPropRef` calls this at every hop; it
7925
+ * never inspects arbitrary expression text itself, only a single constant's
7926
+ * name (against the destructured-prop set) or its own structured `.parsed`
7927
+ * (against the props-object shape) — same "structural, not regex" guarantee
7928
+ * `isArrayExprDirectPropRef`'s docstring makes.
7929
+ *
7930
+ * Shadow-guarded FIRST (pullfrog review finding): `propAliasHopCandidates`
7931
+ * only keeps a shadowed name out of the map `resolveAliasOrigin` hops
7932
+ * INTO — it does nothing for `name` itself on the very first call, which
7933
+ * reads the separate, shadow-unaware `constantsByName(ctx)` index directly.
7934
+ * Without this guard, `rows.map((list, ri) => list.map(...))`'s inner
7935
+ * `list` — an arbitrary row value with no relation to props — would
7936
+ * resolve via an unrelated, shadowed outer `const list = props.entries`
7937
+ * the moment that outer constant's OWN value happens to be a
7938
+ * member-access-on-props shape, defeating the shadow guard entirely
7939
+ * (`propAliasHopCandidates`'s own `rows.map((list) => list.map(...))`
7940
+ * docstring example, just with a member-access-valued outer const instead
7941
+ * of an identifier-valued one — confirmed to reach real Go `isPropDerived`
7942
+ * codegen the same way, since Go's BF101 gate explicitly skips a
7943
+ * scope-bound array name).
7944
+ *
7945
+ * Reads `ctx.boundPropNames`, not `ctx.patterns.props` (#2835): for a whole
7946
+ * `(props: Props)` parameter, `patterns.props` holds every TYPE-MEMBER name
7947
+ * (needed so its regex still matches `props.<key>`) rather than local
7948
+ * bindings, so a same-named module const was matching here as if it were
7949
+ * the prop itself.
7950
+ */
7951
+ function isDirectPropBindingName(name: string, ctx: TransformContext): boolean {
7952
+ if (ctx.scope.isBound(name)) return false
7953
+ if (ctx.boundPropNames.has(name)) return true
7954
+ const propsObjName = ctx.analyzer.propsObjectName
7955
+ if (!propsObjName) return false
7956
+ const parsed = constantsByName(ctx).get(name)?.parsed
7957
+ return !!parsed && parsed.kind === 'member' && !parsed.computed && parsed.object.kind === 'identifier' && parsed.object.name === propsObjName
7958
+ }
7959
+
7960
+ /**
7961
+ * `constantValuesByName(ctx)`, narrowed to the constants an alias-hop walk
7962
+ * may legitimately continue into when resolving whether a loop's array (or
7963
+ * a property access's object) ultimately denotes a prop (#2724 review
7964
+ * finding — a second pass after the initial fix landed found two false
7965
+ * positives the unrestricted map let through):
7966
+ *
7967
+ * - Never a MODULE-scope constant (`isModule`) — a module constant cannot,
7968
+ * by definition, be an alias of any one component instance's props, so
7969
+ * hopping into one can only ever be a coincidental name match. Same
7970
+ * exclusion `resolveGetterAliases` (csr-substitute.ts, #2778) already
7971
+ * makes for its own alias-hop walk.
7972
+ * - Never a name currently SHADOWED by an active loop/callback binding
7973
+ * (`ctx.scope.isBound`) — `rows.map((list) => list.map(...))`'s inner
7974
+ * `list` must resolve to the OUTER LOOP'S ROW VALUE, never an unrelated
7975
+ * outer-scope `const list = someProp` the row happens to shadow. Same
7976
+ * shadow-guard shape as `tryResolveTemplateSpanFromConst`'s guard
7977
+ * (#2222-family) — `isBound()` (all binding sources), not
7978
+ * `valueBoundNames()`: this is that guard's consumer class, not the
7979
+ * reactivity/slot-ID classifier one (`BindingScope.valueBoundNames`'s
7980
+ * docstring, #2482 Stage 1a Commit 2). This map only half-delivers that
7981
+ * guarantee — it stops `resolveAliasOrigin` from hopping INTO a shadowed
7982
+ * name's value, but the FIRST call (`name` itself, before any hop) goes
7983
+ * straight to the terminal (`isDirectPropBindingName`), which has its own
7984
+ * matching `ctx.scope.isBound` check for exactly this reason (pullfrog
7985
+ * review finding on this PR — a shadowed outer const whose OWN value is a
7986
+ * member-access-on-props shape resolved as prop-derived on the very first
7987
+ * hop, before this map's filter ever applied).
7988
+ *
7989
+ * `let` is deliberately NOT excluded, despite being reassignable — this was
7990
+ * tried during review and reverted (second review pass): `isStaticArray`
7991
+ * (jsx-to-ir.ts's derivation) treats "not recognized as prop-derived" as
7992
+ * STATIC, not dynamic, so excluding `let` doesn't move a `let`-aliased prop
7993
+ * array to the safe/over-reconciling side — it reproduces #2724's exact
7994
+ * silent-divergence shape (the array silently stays on the static
7995
+ * `qsaChildScopes` path, so a CSR-only mount never wires up the row's
7996
+ * child). A `let` reassigned before use could in principle make this hop
7997
+ * resolve against a stale declaration-time value — but that only risks the
7998
+ * SAFE direction (an extra `mapArray` on a loop that turns out not to be
7999
+ * prop-derived after all), matching this file's existing `hasCalls`
8000
+ * over-reconciliation-is-cheap philosophy, and Go's `renderLoop` BF101 gate
8001
+ * independently refuses any bare-identifier loop array that resolves to a
8002
+ * non-module local variable (`let` or `const` alike) before
8003
+ * `isPropDerivedArray` would ever drive real Go codegen off it.
8004
+ *
8005
+ * A NARROWED VIEW, not a change to `constantValuesByName` itself — that
8006
+ * function is also `forwardsCallerRestProps`'s rest/props-spread alias
8007
+ * walk, whose existing (wider) hop set this deliberately leaves alone.
8008
+ */
8009
+ function propAliasHopCandidates(ctx: TransformContext): ReadonlyMap<string, string | undefined> {
8010
+ const bound = ctx.scope.boundNames()
8011
+ const candidates = new Map<string, string | undefined>()
8012
+ for (const constant of ctx.analyzer.localConstants) {
8013
+ if (constant.isModule || bound.has(constant.name)) continue
8014
+ if (!candidates.has(constant.name)) candidates.set(constant.name, constant.value)
8015
+ }
8016
+ return candidates
8017
+ }
8018
+
7872
8019
  /**
7873
8020
  * Check if array expression is a signal or memo getter call.
7874
8021
  * Used to determine if a loop needs mapArray for dynamic DOM updates.
@@ -8046,7 +8193,7 @@ function isPropsReference(expr: string, ctx: TransformContext, visited?: Set<str
8046
8193
  function forwardsCallerRestProps(attrs: IRAttribute[], ctx: TransformContext): boolean {
8047
8194
  for (const attr of attrs) {
8048
8195
  if (attr.value.kind !== 'spread') continue
8049
- const constants = restSpreadConstantValues(ctx)
8196
+ const constants = constantValuesByName(ctx)
8050
8197
  for (const name of new Set([attr.value.expr, attr.value.templateExpr])) {
8051
8198
  if (!name) continue
8052
8199
  if (resolveRestSpreadOriginCore(ctx.analyzer, constants, name) !== null) return true
@@ -8055,14 +8202,25 @@ function forwardsCallerRestProps(attrs: IRAttribute[], ctx: TransformContext): b
8055
8202
  return false
8056
8203
  }
8057
8204
 
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
8205
+ /** `ctx.analyzer.localConstants` indexed by name → value text, memoized per component walk. */
8206
+ function constantValuesByName(ctx: TransformContext): ReadonlyMap<string, string | undefined> {
8207
+ if (ctx._constantValuesByName) return ctx._constantValuesByName
8061
8208
  const byName = new Map<string, string | undefined>()
8062
8209
  for (const constant of ctx.analyzer.localConstants) {
8063
8210
  if (!byName.has(constant.name)) byName.set(constant.name, constant.value)
8064
8211
  }
8065
- ctx._restSpreadConstantValues = byName
8212
+ ctx._constantValuesByName = byName
8213
+ return byName
8214
+ }
8215
+
8216
+ /** `ctx.analyzer.localConstants` indexed by name → full `ConstantInfo`, memoized per component walk. */
8217
+ function constantsByName(ctx: TransformContext): ReadonlyMap<string, ConstantInfo> {
8218
+ if (ctx._constantsByName) return ctx._constantsByName
8219
+ const byName = new Map<string, ConstantInfo>()
8220
+ for (const constant of ctx.analyzer.localConstants) {
8221
+ if (!byName.has(constant.name)) byName.set(constant.name, constant)
8222
+ }
8223
+ ctx._constantsByName = byName
8066
8224
  return byName
8067
8225
  }
8068
8226
 
@@ -141,6 +141,27 @@ export function matchLoweringCall(
141
141
  return null
142
142
  }
143
143
 
144
+ /**
145
+ * The child expressions a plugin took responsibility for inside a matched
146
+ * call (#2843) — the base + every triple's value/guard for a `guard-list`,
147
+ * or the args for a `helper-call`. Shared by the support gate
148
+ * (`checkSupport`'s `call` arm in `expression-parser.ts`, which recurses
149
+ * into these instead of refusing the call's raw argument shapes — a
150
+ * `guard-list`'s params object literal, for instance, would otherwise be
151
+ * refused before any adapter ever sees the recognised node) and any future
152
+ * walker that needs the same "what did this plugin actually consume"
153
+ * answer, so the two can't drift on what counts as consumed.
154
+ */
155
+ export function loweringNodeChildren(node: LoweringNode): ParsedExpr[] {
156
+ if (node.kind === 'helper-call') return [...node.args]
157
+ const children: ParsedExpr[] = [node.base]
158
+ for (const t of node.triples) {
159
+ if (t.guard) children.push(t.guard)
160
+ children.push(t.value)
161
+ }
162
+ return children
163
+ }
164
+
144
165
  /**
145
166
  * Whether a `LoweringNode.helper` id is safe to splice directly into an
146
167
  * adapter's runtime-helper naming convention (`bf_${helper}` in Go,
@@ -132,6 +132,16 @@ export function collectAstPropRefs(
132
132
  * (`{ org }` → `{ org: _p.org }`) so the result stays syntactically
133
133
  * valid.
134
134
  *
135
+ * `replacementFor` overrides the substitution text for a value
136
+ * reference (default `${PROPS_PARAM}.<callerKey>`) — e.g. the reactive
137
+ * client-JS emit path (`emit-reactive.ts`'s `rewriteDestructuredPropsInExpr`)
138
+ * wraps it in a `(_p.x ?? <default>)` fallback for a prop with a
139
+ * destructure default. Kept as a caller override rather than a
140
+ * duplicate AST walk in that module, per the "one decision, one
141
+ * implementation" rule (CLAUDE.md) — this walk is already the one place
142
+ * that correctly distinguishes a value reference from an object-literal
143
+ * key / property-access name / shorthand property / binding name.
144
+ *
135
145
  * Returns null when `text` does not parse cleanly as an expression —
136
146
  * the caller falls back to the legacy regex rewrite.
137
147
  */
@@ -139,6 +149,7 @@ function applyScopedPropRefRewrite(
139
149
  text: string,
140
150
  propRefs: Set<string>,
141
151
  propAliases?: ReadonlyMap<string, string>,
152
+ replacementFor?: (localName: string, callerKey: string) => string,
142
153
  ): string | null {
143
154
  // Wrap in parens so object literals and arrows parse as expressions.
144
155
  const prefix = '('
@@ -157,11 +168,12 @@ function applyScopedPropRefRewrite(
157
168
  // — #2524 CSR half); the local binding (`n.text`) only survives on the
158
169
  // left of a shorthand expansion.
159
170
  const callerKey = propAliases?.get(n.text) ?? n.text
171
+ const value = replacementFor ? replacementFor(n.text, callerKey) : `${PROPS_PARAM}.${callerKey}`
160
172
  if (parent && ts.isShorthandPropertyAssignment(parent) && parent.name === n) {
161
- edits.push({ start, end, replacement: `${n.text}: ${PROPS_PARAM}.${callerKey}` })
173
+ edits.push({ start, end, replacement: `${n.text}: ${value}` })
162
174
  return
163
175
  }
164
- edits.push({ start, end, replacement: `${PROPS_PARAM}.${callerKey}` })
176
+ edits.push({ start, end, replacement: value })
165
177
  })
166
178
 
167
179
  if (edits.length === 0) return text
@@ -186,6 +198,7 @@ export function applyRegexPropRefRewrite(
186
198
  text: string,
187
199
  propRefs: Iterable<string>,
188
200
  propAliases?: ReadonlyMap<string, string>,
201
+ replacementFor?: (localName: string, callerKey: string) => string,
189
202
  ): string {
190
203
  const { protect, restore } = createTemplateAwareStringProtector()
191
204
  let result = protect(text)
@@ -193,6 +206,7 @@ export function applyRegexPropRefRewrite(
193
206
  for (const propName of propRefs) {
194
207
  // `_p` is always keyed by the caller-facing name (#2524 CSR half).
195
208
  const callerKey = propAliases?.get(propName) ?? propName
209
+ const value = replacementFor ? replacementFor(propName, callerKey) : `${PROPS_PARAM}.${callerKey}`
196
210
  const pattern = new RegExp(`(?<!${PROPS_PARAM}\\.)(?<!['"\\w.-])\\b${propName}\\b(?![a-zA-Z0-9_$])`, 'g')
197
211
  result = result.replace(pattern, (match, offset, str) => {
198
212
  // Skip object literal keys: preceded by { or , and followed by :
@@ -201,7 +215,7 @@ export function applyRegexPropRefRewrite(
201
215
  const before = str.slice(0, offset)
202
216
  if (/[{,]\s*$/.test(before)) return match
203
217
  }
204
- return `${PROPS_PARAM}.${callerKey}`
218
+ return value
205
219
  })
206
220
  }
207
221
 
@@ -225,6 +239,9 @@ export function applyRegexPropRefRewrite(
225
239
  * `_p` is always keyed by the caller-facing name (#2524 CSR half); a name
226
240
  * absent from this map emits `_p.<name>` unchanged (the un-aliased case,
227
241
  * where `sourceName ?? name` is an identity).
242
+ * @param replacementFor - Override the substitution text for a value
243
+ * reference (default `${PROPS_PARAM}.<callerKey>`) — see
244
+ * `applyScopedPropRefRewrite`'s docstring.
228
245
  */
229
246
  export function rewriteBarePropRefs(
230
247
  text: string,
@@ -232,6 +249,7 @@ export function rewriteBarePropRefs(
232
249
  propNames: Set<string>,
233
250
  extraPropRefs?: ReadonlySet<string>,
234
251
  propAliases?: ReadonlyMap<string, string>,
252
+ replacementFor?: (localName: string, callerKey: string) => string,
235
253
  ): string | undefined {
236
254
  // Walk AST to find which prop names are actually used as value references
237
255
  const foundPropRefs = new Set<string>()
@@ -243,7 +261,7 @@ export function rewriteBarePropRefs(
243
261
  }
244
262
  if (foundPropRefs.size === 0) return undefined
245
263
  return (
246
- applyScopedPropRefRewrite(text, foundPropRefs, propAliases) ??
247
- applyRegexPropRefRewrite(text, foundPropRefs, propAliases)
264
+ applyScopedPropRefRewrite(text, foundPropRefs, propAliases, replacementFor) ??
265
+ applyRegexPropRefRewrite(text, foundPropRefs, propAliases, replacementFor)
248
266
  )
249
267
  }
@@ -69,6 +69,31 @@ export function buildPropAliasMap(params: readonly ParamInfo[]): Map<string, str
69
69
  return map
70
70
  }
71
71
 
72
+ /**
73
+ * The props-parameter shape needed to answer "is `name` an actual local
74
+ * binding introduced by the props parameter" — as opposed to
75
+ * `ctx.patterns.props`, which for a whole `(props: Props)` parameter holds
76
+ * every TYPE-MEMBER name (`extractPropsFromTypeMembers`) for regex prop-
77
+ * access matching, not real bindings.
78
+ */
79
+ export interface PropsParamBindings {
80
+ propsParams: readonly ParamInfo[]
81
+ propsObjectName: string | null
82
+ }
83
+
84
+ /**
85
+ * Local names actually bound by the props parameter. Empty for a whole
86
+ * `(props: Props)` parameter: `propsParams` there is type-member names
87
+ * (nothing local to shadow with), and `props` itself is `propsObjectName`,
88
+ * checked separately by callers.
89
+ */
90
+ export function boundPropLocalNames(b: PropsParamBindings): ReadonlySet<string> {
91
+ if (b.propsObjectName !== null) return EMPTY_SET
92
+ return new Set(b.propsParams.filter(p => !p.isRest).map(p => p.name))
93
+ }
94
+
95
+ const EMPTY_SET: ReadonlySet<string> = new Set()
96
+
72
97
  /**
73
98
  * The two component bindings that forward the caller's leftover props:
74
99
  * the destructured `...rest` binding and a whole undestructured `(props)`
@@ -82,10 +107,52 @@ export interface RestSpreadBindings {
82
107
  }
83
108
 
84
109
  /**
85
- * Which of `bindings` the name `name` ultimately reaches, walking through
86
- * any bare `const x__alias = <name>` hop recorded in `constantValues`
87
- * (name initializer text). `'rest'` for the destructured rest binding,
88
- * `'props'` for the whole props object, `null` for anything else.
110
+ * Walks a bare `const x__alias = <name>` hop chain recorded in
111
+ * `constantValues` (name initializer text) until `terminal` recognizes
112
+ * the current name, returning what it recognized. `null` when the chain
113
+ * runs out (a constant whose value isn't a bare identifier — a real
114
+ * computed object — stops the walk) or degenerates into a cycle.
115
+ *
116
+ * The one alias-hop walker in the compiler, shared by every caller that
117
+ * needs "does this name ultimately reach some fixed binding" rather than
118
+ * a bespoke copy per caller — `resolveRestSpreadOriginCore` below,
119
+ * `resolveGetterAliases` (`csr-substitute.ts`, #2778), and
120
+ * `isArrayExprDirectPropRef` (`jsx-to-ir.ts`, #2724, called directly rather
121
+ * than through a `*Core`-style wrapper) are its callers, differing only in
122
+ * what `terminal` recognizes as a hit — and, for `isArrayExprDirectPropRef`,
123
+ * in also narrowing `constantValues` itself (`propAliasHopCandidates`)
124
+ * before passing it in. `terminal` is checked BEFORE the constant-value
125
+ * lookup so a name that is itself a target take priority over any
126
+ * same-named local shadowing it (preserves `resolveRestSpreadOriginCore`'s
127
+ * original hop order) — a caller whose `terminal` can itself be fooled by a
128
+ * shadowed name (e.g. a scope-unaware lookup keyed only by name) must guard
129
+ * that inside `terminal`, since this walker has no scope/binding awareness
130
+ * of its own to do it centrally.
131
+ *
132
+ * `visited` guards a constant cycle (`const a = b; const b = a`); walking
133
+ * hop by hop (not a precomputed set) is what lets a multi-hop alias
134
+ * (`const p2 = props; const p3 = p2`) resolve through every link.
135
+ */
136
+ export function resolveAliasOrigin<T>(
137
+ constantValues: ReadonlyMap<string, string | undefined>,
138
+ name: string,
139
+ terminal: (name: string) => T | null,
140
+ ): T | null {
141
+ const visited = new Set<string>()
142
+ let current: string | undefined = name.trim()
143
+ while (current !== undefined && !visited.has(current)) {
144
+ const hit = terminal(current)
145
+ if (hit !== null) return hit
146
+ visited.add(current)
147
+ current = constantValues.get(current)?.trim()
148
+ }
149
+ return null
150
+ }
151
+
152
+ /**
153
+ * Which of `bindings` the name `name` ultimately reaches. `'rest'` for the
154
+ * destructured rest binding, `'props'` for the whole props object, `null`
155
+ * for anything else.
89
156
  *
90
157
  * The single definition of "this `{...spread}` forwards the caller's
91
158
  * leftover props", shared by the two phases that must agree on it: Phase 1
@@ -96,26 +163,15 @@ export interface RestSpreadBindings {
96
163
  * `spreadAttrs({...})` merge. Two copies of the rule would let an element
97
164
  * qualify for one and not the other, which is exactly the silent-drop
98
165
  * shape #2754 reports.
99
- *
100
- * Walks hop by hop (not a precomputed set) so a multi-hop alias
101
- * (`const p2 = props; const p3 = p2`) resolves through every link;
102
- * `visited` guards a constant cycle (`const a = b; const b = a`). A
103
- * constant whose value isn't a bare identifier on the chain (a real
104
- * computed object) stops the walk, so a genuinely different spread
105
- * expression is never mistaken for the rest object.
106
166
  */
107
167
  export function resolveRestSpreadOriginCore(
108
168
  bindings: RestSpreadBindings,
109
169
  constantValues: ReadonlyMap<string, string | undefined>,
110
170
  name: string,
111
171
  ): 'rest' | 'props' | null {
112
- const visited = new Set<string>()
113
- let current: string | undefined = name.trim()
114
- while (current !== undefined && !visited.has(current)) {
172
+ return resolveAliasOrigin(constantValues, name, (current) => {
115
173
  if (bindings.restPropsName && current === bindings.restPropsName) return 'rest'
116
174
  if (bindings.propsObjectName && current === bindings.propsObjectName) return 'props'
117
- visited.add(current)
118
- current = constantValues.get(current)?.trim()
119
- }
120
- return null
175
+ return null
176
+ })
121
177
  }
package/src/types.ts CHANGED
@@ -594,7 +594,9 @@ export interface IRLoop {
594
594
  isStaticArray: boolean
595
595
 
596
596
  /**
597
- * True when the array is a direct prop reference promoted to mapArray (#1586).
597
+ * True when the array is a direct prop reference promoted to mapArray
598
+ * (#1586) — including one reached only through a bare `const x = y`
599
+ * alias-hop chain (`isArrayExprDirectPropRef`, jsx-to-ir.ts, #2724).
598
600
  * Adapters use this to include the array in JSON serialization (bf-p)
599
601
  * even though isStaticArray is false — the client's mapArray needs the
600
602
  * initial data for hydration, unlike signal-backed arrays.
@@ -1548,10 +1550,9 @@ export interface SignalInfo {
1548
1550
  * `templateCondition` fields on other IR positions. Destructured mode
1549
1551
  * only (`propsObjectName === null`); undefined when no rewrite was
1550
1552
  * needed (no destructured prop referenced) or the component is in
1551
- * object-props mode (there, `rewritePropsObjectRef`/`applyPropsRewrite`
1552
- * handle `props.x` → `_p.x` on the final joined/substituted string
1553
- * instead, since a bare `props.x` textual match works without an
1554
- * AST-level pre-rewrite).
1553
+ * object-props mode (there, `rewritePropsObjectRef` handles `props.x` →
1554
+ * `_p.x` on the final joined/substituted string instead, since a bare
1555
+ * `props.x` textual match works without an AST-level pre-rewrite).
1555
1556
  */
1556
1557
  templateInitialValue?: string
1557
1558
  /**