@barefootjs/jsx 0.33.3 → 0.33.6

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 (87) 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/analyzer.d.ts.map +1 -1
  4. package/dist/errors.d.ts +1 -0
  5. package/dist/errors.d.ts.map +1 -1
  6. package/dist/index.d.ts +2 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1392 -1117
  9. package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/child-components.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/component-scope.d.ts +20 -0
  13. package/dist/ir-to-client-js/component-scope.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/compute-inlinability.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +2 -4
  17. package/dist/ir-to-client-js/control-flow/plan/loop.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/component-loop.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/csr-substitute.d.ts +35 -9
  24. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/emit-reactive.d.ts +10 -0
  26. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/html-template.d.ts +49 -2
  28. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/imports.d.ts +2 -2
  30. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  32. package/dist/ir-to-client-js/rewrite-props-object.d.ts +12 -1
  33. package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
  34. package/dist/jsx-to-ir.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 +19 -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__/fragment-body-loop-key.test.ts +95 -0
  46. package/src/__tests__/inner-loop-mathml-namespace.test.ts +1 -1
  47. package/src/__tests__/inner-loop-svg-namespace.test.ts +4 -4
  48. package/src/__tests__/issue-2724-prop-alias-loop-not-static.test.ts +355 -0
  49. package/src/__tests__/issue-2737-props-object-alias-template.test.ts +86 -0
  50. package/src/__tests__/issue-2756-input-skeleton-bails.test.ts +123 -0
  51. package/src/__tests__/issue-2778-getter-alias-csr-template.test.ts +113 -0
  52. package/src/__tests__/issue-2806-rest-bag-template-rewrite.test.ts +136 -0
  53. package/src/__tests__/issue-2835-props-type-member-name-collision.test.ts +259 -0
  54. package/src/__tests__/loop-item-root-scope.test.ts +7 -3
  55. package/src/__tests__/markup-prop-brand.test.ts +165 -26
  56. package/src/__tests__/multi-root-loop-body.test.ts +7 -3
  57. package/src/__tests__/namespace-import-primitive.test.ts +244 -0
  58. package/src/__tests__/preamble-declarations.test.ts +42 -0
  59. package/src/__tests__/rewrite-destructured-props.test.ts +53 -0
  60. package/src/__tests__/static-loop-csr-materialize.test.ts +2 -2
  61. package/src/adapters/child-scope.ts +23 -0
  62. package/src/analyzer.ts +130 -0
  63. package/src/errors.ts +14 -1
  64. package/src/index.ts +7 -0
  65. package/src/ir-to-client-js/build-references.ts +7 -0
  66. package/src/ir-to-client-js/child-components.ts +8 -1
  67. package/src/ir-to-client-js/collect-elements.ts +10 -38
  68. package/src/ir-to-client-js/component-scope.ts +60 -0
  69. package/src/ir-to-client-js/compute-inlinability.ts +46 -11
  70. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +19 -1
  71. package/src/ir-to-client-js/control-flow/plan/loop.ts +2 -4
  72. package/src/ir-to-client-js/control-flow/shared.ts +12 -5
  73. package/src/ir-to-client-js/control-flow/stringify/component-loop.ts +7 -0
  74. package/src/ir-to-client-js/control-flow/stringify/loop.ts +8 -2
  75. package/src/ir-to-client-js/control-flow.ts +12 -0
  76. package/src/ir-to-client-js/csr-substitute.ts +82 -16
  77. package/src/ir-to-client-js/emit-reactive.ts +29 -13
  78. package/src/ir-to-client-js/emit-registration.ts +1 -1
  79. package/src/ir-to-client-js/html-template.ts +194 -82
  80. package/src/ir-to-client-js/imports.ts +4 -0
  81. package/src/ir-to-client-js/index.ts +34 -23
  82. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +7 -2
  83. package/src/ir-to-client-js/rewrite-props-object.ts +14 -0
  84. package/src/jsx-to-ir.ts +318 -54
  85. package/src/prop-rewrite.ts +23 -5
  86. package/src/props-binding.ts +74 -18
  87. package/src/types.ts +19 -5
@@ -20,7 +20,7 @@
20
20
  import type { IRLoopChildComponent, MapCallbackPreamble } from '../../types.ts'
21
21
  import type { ClientJsContext, NestedLoop, TopLevelLoop } from '../types.ts'
22
22
  import { quotePropName, varSlotId, attrValueToString, buildLoopChildIndexExpr } from '../utils.ts'
23
- import { irChildrenToJsExpr, renderPreamble, irToHtmlTemplate } from '../html-template.ts'
23
+ import { jsxChildrenPropGetterExpr, renderPreamble, irToHtmlTemplate } from '../html-template.ts'
24
24
 
25
25
  /**
26
26
  * Render a loop preamble for the static-array init context. Static `forEach`
@@ -232,7 +232,12 @@ function buildStaticPropsExpr(props: readonly LoopChildCompProp[]): PropsExpr {
232
232
  case 'boolean-attr':
233
233
  return `${quotePropName(p.name)}: true`
234
234
  case 'jsx-children':
235
- return `get ${quotePropName(p.name)}() { return ${irChildrenToJsExpr(p.value.children)} }`
235
+ // Brand via the shared door (#2651/#2702) — see
236
+ // `jsxChildrenPropGetterExpr`'s docstring. A named jsx-children
237
+ // prop on a component instantiated inside a static-array `.map()`
238
+ // loop used to reach its getter unbranded (pullfrog review, PR
239
+ // #2820).
240
+ return `get ${quotePropName(p.name)}() { return ${jsxChildrenPropGetterExpr(p.value.children, p.name)} }`
236
241
  case 'expression':
237
242
  case 'template':
238
243
  case 'spread':
@@ -26,6 +26,18 @@
26
26
  import ts from 'typescript'
27
27
  import { PROPS_PARAM } from './utils.ts'
28
28
  import { identifierPattern } from '../identifier-pattern.ts'
29
+ import type { BindingScope } from '../scope/binding-scope.ts'
30
+
31
+ export interface PropsObjectRewriteOptions {
32
+ /**
33
+ * SHADOW GUARD (CLAUDE.md's `BindingScope` distinction — `isBound()`,
34
+ * not `valueBoundNames()`): a `.map(props => ...)` row whose callback
35
+ * PARAMETER happens to be named the same as the outer `propsObjectName`
36
+ * must not have that parameter rewritten to `_p` — it shadows the outer
37
+ * props object inside its own row, same as any other bound name would.
38
+ */
39
+ enclosingScope?: BindingScope
40
+ }
29
41
 
30
42
  /**
31
43
  * Rename every value-position reference to `propsObjectName` — and,
@@ -64,11 +76,13 @@ export function rewritePropsObjectRef(
64
76
  code: string,
65
77
  propsObjectName: string | null,
66
78
  restPropsName: string | null = null,
79
+ opts?: PropsObjectRewriteOptions,
67
80
  ): string {
68
81
  let result = code
69
82
  const seen = new Set<string>()
70
83
  for (const srcPropsName of [propsObjectName ?? 'props', restPropsName]) {
71
84
  if (srcPropsName === null || srcPropsName === PROPS_PARAM || seen.has(srcPropsName)) continue
85
+ if (opts?.enclosingScope?.isBound(srcPropsName)) continue
72
86
  seen.add(srcPropsName)
73
87
  result = rewriteOneName(result, srcPropsName)
74
88
  }
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,
@@ -3793,6 +3817,19 @@ function extractLoopKey(node: IRNode): string | null {
3793
3817
  if (b === null) return null
3794
3818
  return normalizeKeyExpr(a) === normalizeKeyExpr(b) ? a : null
3795
3819
  }
3820
+ if (node.type === 'fragment') {
3821
+ // #2763: a `.map()` body returning a fragment (`<><li key={..}/><li/></>`)
3822
+ // reads the key off its FIRST element, not first node — same "first
3823
+ // element, not first node" rule as `IRElement.keyAttr`'s docstring and
3824
+ // `markCarrierIn`'s relay case, skipping whitespace-only text the way
3825
+ // `branchHasNoElement` does. Recursing through `extractLoopKey` (rather
3826
+ // than requiring `type === 'element'` directly) lets a nested
3827
+ // conditional/fragment first child resolve too.
3828
+ const first = node.children.find(
3829
+ (c) => !(c.type === 'text' && typeof c.value === 'string' && !c.value.trim())
3830
+ )
3831
+ return first ? extractLoopKey(first) : null
3832
+ }
3796
3833
  return null
3797
3834
  }
3798
3835
 
@@ -4108,7 +4145,7 @@ function leafIsWirelessElement(el: ts.JsxElement | ts.JsxSelfClosingElement): bo
4108
4145
  if (ts.isJsxSpreadAttribute(attr)) { ok = false; return }
4109
4146
  if (ts.isJsxAttribute(attr)) {
4110
4147
  const name = attr.name.getText()
4111
- if (/^on[A-Z]/.test(name)) { ok = false; return }
4148
+ if (isEventHandlerAttrName(name)) { ok = false; return }
4112
4149
  }
4113
4150
  }
4114
4151
  }
@@ -4206,12 +4243,12 @@ function tagLoopItemRootComponents(nodes: readonly IRNode[]): void {
4206
4243
  *
4207
4244
  * Deliberately does NOT remove the literal `key` entry from `.attrs`: the
4208
4245
  * 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).
4246
+ * `resolvedKeyAttrName` helper, #2763) reads it back off `.attrs` too, but
4247
+ * only emits `data-key` when THIS field `node.keyAttr`is actually set;
4248
+ * a raw `key` attribute the SSR side didn't recognize as a row key (e.g. a
4249
+ * fragment-bodied row before #2763's fix) no longer gets baked client-side
4250
+ * either. Adapters skip the raw `key` attr in their generic `renderAttributes`
4251
+ * loop instead (Hono's own JSX runtime already drops it silently either way).
4215
4252
  *
4216
4253
  * `component` bodies are left untouched: a `<Comp key={x}/>` row root's key
4217
4254
  * is a PROP (`renderComponentProps`'s own `data-key` prop-forwarding), not
@@ -4226,6 +4263,16 @@ function applyLoopKeyAttr(node: IRNode, name: string, value: string): void {
4226
4263
  if (node.type === 'conditional') {
4227
4264
  applyLoopKeyAttr(node.whenTrue, name, value)
4228
4265
  applyLoopKeyAttr(node.whenFalse, name, value)
4266
+ return
4267
+ }
4268
+ if (node.type === 'fragment') {
4269
+ // Write-side twin of `extractLoopKey`'s fragment case: stamp the SAME
4270
+ // first-real-child this key was read from, not every child, matching
4271
+ // the "first element, not first node" rule.
4272
+ const first = node.children.find(
4273
+ (c) => !(c.type === 'text' && typeof c.value === 'string' && !c.value.trim())
4274
+ )
4275
+ if (first) applyLoopKeyAttr(first, name, value)
4229
4276
  }
4230
4277
  }
4231
4278
 
@@ -4667,7 +4714,7 @@ function transformMapCall(
4667
4714
  // the conditional — the same carrier the single-return path uses. A
4668
4715
  // JS-runtime adapter (and the browser under /* @client */) runs it
4669
4716
  // verbatim.
4670
- preamble = preambleFromValueStatements(pre, ctx)
4717
+ preamble = preambleFromValueStatements(pre, ctx, body.statements)
4671
4718
 
4672
4719
  // Stage 2 of spec/callback-fidelity.md, narrowed by #2447. This used
4673
4720
  // to refuse EVERY branch preamble on a DSL target, on the premise
@@ -4874,7 +4921,7 @@ function transformMapCall(
4874
4921
  valueStmts.push(stmt)
4875
4922
  }
4876
4923
  if (valueStmts.length > 0) {
4877
- preamble = preambleFromValueStatements(valueStmts, ctx)
4924
+ preamble = preambleFromValueStatements(valueStmts, ctx, body.statements)
4878
4925
 
4879
4926
  // #2447. A DSL adapter cannot execute `preamble.segments` (JS
4880
4927
  // text), so it lowers `declarations` — one per-row local each —
@@ -5456,8 +5503,8 @@ function buildFlatMapCallback(
5456
5503
  // Template variant per segment, like buildPreambleSegments: the hydrate
5457
5504
  // registration template is module scope (`template: (_p) => ...`), so a
5458
5505
  // bare destructured-prop reference must rewrite to `_p.xxx` there —
5459
- // `applyPropsRewrite` can't do it (destructured components have no props
5460
- // object name).
5506
+ // `rewritePropsObjectRef` can't do it (destructured components have no
5507
+ // props object name to rewrite from).
5461
5508
  const tpl = rewriteBarePropRefs(piece.js, body, ctx)
5462
5509
  return tpl !== undefined && tpl !== piece.js
5463
5510
  ? { kind: 'js', text: piece.js, templateText: tpl }
@@ -5844,6 +5891,7 @@ function computePreambleReactiveNames(
5844
5891
  function preambleFromValueStatements(
5845
5892
  statements: readonly ts.Statement[],
5846
5893
  ctx: TransformContext,
5894
+ bodyStatements: readonly ts.Statement[],
5847
5895
  ): MapCallbackPreamble {
5848
5896
  const segments: PreambleSegment[] = []
5849
5897
  const typedParts: string[] = []
@@ -5865,7 +5913,7 @@ function preambleFromValueStatements(
5865
5913
  declaredNames: [...declared],
5866
5914
  // Value-only preambles accumulate no JSX, so no child needs the array join.
5867
5915
  builderNames: [],
5868
- declarations: neutralPreambleDeclarations(statements, ctx) ?? undefined,
5916
+ declarations: neutralPreambleDeclarations(statements, ctx, bodyStatements) ?? undefined,
5869
5917
  reactiveNames: reactiveNames.size > 0 ? [...reactiveNames] : undefined,
5870
5918
  }
5871
5919
  }
@@ -5882,6 +5930,7 @@ function preambleFromValueStatements(
5882
5930
  function neutralPreambleDeclarations(
5883
5931
  statements: readonly ts.Statement[],
5884
5932
  ctx: TransformContext,
5933
+ bodyStatements: readonly ts.Statement[],
5885
5934
  ): PreambleValueDeclaration[] | null {
5886
5935
  const out: PreambleValueDeclaration[] = []
5887
5936
  for (const stmt of statements) {
@@ -5893,6 +5942,20 @@ function neutralPreambleDeclarations(
5893
5942
  // no adapter has a single per-row local form for that.
5894
5943
  if (!ts.isIdentifier(decl.name)) return null
5895
5944
  if (!decl.initializer) return null
5945
+ // #2797: a function-valued local (an event handler hoisted out of the
5946
+ // JSX attribute position, e.g. `const handleClick = () => {...}`) has
5947
+ // no template-expression form on any backend — no DSL here has a
5948
+ // callable value. But every adapter's own SSR component-prop builder
5949
+ // already skips an event-handler prop (`isEventHandlerAttrName`)
5950
+ // outright, so a name read ONLY that way needs no SSR representation
5951
+ // at all — elide the declaration instead of refusing the whole
5952
+ // preamble. The client-JS preamble (`segments`, not `declarations`)
5953
+ // still carries the real closure; this only concerns what the DSL
5954
+ // SSR template sees.
5955
+ if (isFunctionLiteral(decl.initializer)) {
5956
+ if (everyReadIsEventHandlerPropValue(decl.name.text, bodyStatements)) continue
5957
+ return null
5958
+ }
5896
5959
  const valueParsed = tsNodeToParsedExpr(decl.initializer)
5897
5960
  // The same subset gate every other DSL-lowered expression passes
5898
5961
  // through. An adapter that additionally cannot emit a supported shape
@@ -5907,7 +5970,74 @@ function neutralPreambleDeclarations(
5907
5970
  })
5908
5971
  }
5909
5972
  }
5910
- return out.length > 0 ? out : null
5973
+ // Not `out.length > 0 ? out : null` — an all-elided preamble legitimately
5974
+ // has zero declarations to emit (see MapCallbackPreamble.declarations'
5975
+ // docstring); `[]` is what tells the Phase-1 gate "fully declared."
5976
+ return out
5977
+ }
5978
+
5979
+ /** `const x = () => {...}` / `const x = function () {...}` — a callable value, never a template-expression shape. */
5980
+ function isFunctionLiteral(node: ts.Expression): boolean {
5981
+ return ts.isArrowFunction(node) || ts.isFunctionExpression(node)
5982
+ }
5983
+
5984
+ /**
5985
+ * Whether every read of `name` in `bodyStatements`, other than its own
5986
+ * binding site, sits inside an event-handler `JsxAttribute`'s
5987
+ * (`onClick`/`onInput`/...) value — i.e. `name={<ident>}` where the
5988
+ * attribute's own name passes {@link isEventHandlerAttrName}, the exact
5989
+ * predicate every DSL adapter's own SSR prop-builder already uses to skip an
5990
+ * event-handler prop (e.g. `packages/adapter-jinja/src/adapter/
5991
+ * jinja-adapter.ts`'s `renderComponent`, `packages/adapter-go-template/...`'s
5992
+ * `prop.isEventHandler` check).
5993
+ *
5994
+ * Walks the raw preamble+return syntax rather than the already-built row IR
5995
+ * (`children`) deliberately: `neutralPreambleDeclarations` runs on the
5996
+ * PREAMBLE statements specifically, and the IR built from the return
5997
+ * expression doesn't retain a per-occurrence link back to which SOURCE
5998
+ * identifier fed a given `IREvent`/`IRProp` — re-deriving from syntax here
5999
+ * keeps the safety check self-contained rather than threading that link
6000
+ * through the IR builder for one caller.
6001
+ *
6002
+ * Conservative: a property-access name (`x.name`), a nested declaration's own
6003
+ * binding, or any other read shape (a text/attr expression, a NON-event
6004
+ * child prop, a call) fails this and falls through to the ordinary refusal —
6005
+ * this only widens the shapes that are SAFE to elide, never narrows what was
6006
+ * already accepted. Uses `ts.forEachChild`'s own short-circuit (a truthy
6007
+ * return stops the walk) rather than a separate mutable flag.
6008
+ */
6009
+ function everyReadIsEventHandlerPropValue(name: string, bodyStatements: readonly ts.Statement[]): boolean {
6010
+ const findDisallowedRead = (node: ts.Node): true | undefined => {
6011
+ if (ts.isIdentifier(node) && node.text === name) {
6012
+ const isBindingName = ts.isVariableDeclaration(node.parent) && node.parent.name === node
6013
+ const isPropertyName = ts.isPropertyAccessExpression(node.parent) && node.parent.name === node
6014
+ if (!isBindingName && !isPropertyName) {
6015
+ const jsxExpr = node.parent
6016
+ const attr = jsxExpr.parent
6017
+ const isEventHandlerAttrValue =
6018
+ ts.isJsxExpression(jsxExpr) &&
6019
+ ts.isJsxAttribute(attr) &&
6020
+ attr.initializer === jsxExpr &&
6021
+ ts.isIdentifier(attr.name) &&
6022
+ isEventHandlerAttrName(attr.name.text)
6023
+ if (!isEventHandlerAttrValue) return true
6024
+ }
6025
+ }
6026
+ return ts.forEachChild(node, findDisallowedRead)
6027
+ }
6028
+ return !bodyStatements.some(findDisallowedRead)
6029
+ }
6030
+
6031
+ /**
6032
+ * Whether a JSX attribute name is an event handler (`onClick`, `onInput`,
6033
+ * ...) rather than a plain attribute/prop. Shared by every site in this file
6034
+ * that needs the same call: native-element attribute extraction (routes into
6035
+ * `IREvent`, never rendered HTML), the preamble-verbatim-leaf wiring guard,
6036
+ * and {@link everyReadIsEventHandlerPropValue}'s elision safety check — one
6037
+ * decision, one implementation, per CLAUDE.md.
6038
+ */
6039
+ function isEventHandlerAttrName(name: string): boolean {
6040
+ return /^on[A-Z]/.test(name)
5911
6041
  }
5912
6042
 
5913
6043
  /** Drop the trailing separator space from the final js segment. */
@@ -6261,7 +6391,7 @@ function processAttributes(
6261
6391
  // they're wired up at hydration time (delegated event registration) and
6262
6392
  // must not leak into rendered HTML. The DOM event name is lowercase
6263
6393
  // (`click`, not `Click`), so strip the `on` prefix and downcase.
6264
- if (/^on[A-Z]/.test(rawName)) {
6394
+ if (isEventHandlerAttrName(rawName)) {
6265
6395
  if (attr.initializer && ts.isJsxExpression(attr.initializer) && attr.initializer.expression) {
6266
6396
  const eventName = rawName.slice(2).toLowerCase()
6267
6397
  reportJsxBranchLocalInCallback(attr.initializer.expression, ctx)
@@ -7287,10 +7417,7 @@ function expressionWrapsJsx(node: ts.Expression): boolean {
7287
7417
  * Refuse a component prop whose initializer is a ternary/array literally
7288
7418
  * wrapping JSX (#2667) — see `expressionWrapsJsx`'s docstring and the call
7289
7419
  * site above for why this can't fall through to the plain `expression`
7290
- * AttrValue path (raw JSX syntax would splice into the emitted client
7291
- * JS) and why the fragment-wrap escape is NOT offered here (BF021 message
7292
- * below explains the unsoundness inline; see #2667's tracking issue for
7293
- * the full door-inventory finding).
7420
+ * AttrValue path (raw JSX syntax would splice into the emitted client JS).
7294
7421
  */
7295
7422
  function reportNakedJsxWrapperProp(
7296
7423
  ctx: TransformContext,
@@ -7305,15 +7432,21 @@ function reportNakedJsxWrapperProp(
7305
7432
  `Prop '${propName}' is ${shape} wrapping JSX (${jsxExpr.getText(ctx.sourceFile)}). ` +
7306
7433
  `This shape is not compiled — only a JSX element/fragment given DIRECTLY as the prop value is.`,
7307
7434
  suggestion: {
7308
- message:
7309
- `Move the conditional/array out of the prop position: compute it in a local ` +
7310
- `const and pass it as the component's children instead of a named prop ` +
7311
- `(e.g. const ${propName} = ${jsxExpr.getText(ctx.sourceFile)}; <Comp>{${propName}}</Comp>). ` +
7312
- `Wrapping the ternary/array in a fragment at the prop position ` +
7313
- `(${propName}={<>{${jsxExpr.getText(ctx.sourceFile)}}</>}) is NOT a safe escape here: it compiles, ` +
7314
- `but the child's own reactive prop getter receives the branch's HTML unbranded and re-escapes it as ` +
7315
- `text on the child's very next reactive run, corrupting the DOM (a narrower gap #2651's door ` +
7316
- `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).`,
7317
7450
  escape: [{ kind: 'rewrite' }],
7318
7451
  },
7319
7452
  }),
@@ -7368,14 +7501,13 @@ function processComponentProps(
7368
7501
  // plain `expression` path below, which stringifies the initializer's
7369
7502
  // SOURCE TEXT — literal JSX syntax spliced into the emitted client
7370
7503
  // JS (invalid at runtime; the #2651 door inventory's discovery).
7371
- // Refuse loudly instead of guessing a lowering: the fragment-wrap
7372
- // escape this diagnostic once considered recommending turns out to
7373
- // be unsound for the same shape (`isSingleElementJsxChildren`'s
7374
- // docstring in `ir-to-client-js/collect-elements.ts`a
7375
- // conditional-in-fragment reaches `initChild`'s getter UNbranded,
7376
- // corrupting the child's DOM the moment its own reactive effect
7377
- // first reads the prop), so only the children-passthrough escape is
7378
- // 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.
7379
7511
  if (
7380
7512
  (ts.isConditionalExpression(jsxExpr) || ts.isArrayLiteralExpression(jsxExpr)) &&
7381
7513
  expressionWrapsJsx(jsxExpr)
@@ -7738,31 +7870,152 @@ function checkBareSignalOrMemoIdentifier(
7738
7870
  * Structural AST check: is the array expression a direct prop reference?
7739
7871
  *
7740
7872
  * Returns true only when arrayExpr is:
7741
- * (a) An Identifier that is a destructured prop binding (e.g. `toggleItems`)
7742
- * (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)
7743
7881
  *
7744
7882
  * Unlike the regex-based `isPropsReference`, this avoids false positives from
7745
7883
  * unrelated identifiers that happen to share a prop name (e.g. `state.items`
7746
- * 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.
7747
7902
  */
7748
7903
  function isArrayExprDirectPropRef(arrayExpr: ts.Expression, ctx: TransformContext): boolean {
7749
- const propNames = new Set(ctx.patterns.props.map(p => p.name))
7750
7904
  const propsObjName = ctx.analyzer.propsObjectName
7751
7905
 
7752
7906
  if (ts.isIdentifier(arrayExpr)) {
7753
- return propNames.has(arrayExpr.text)
7907
+ return resolveAliasOrigin(propAliasHopCandidates(ctx), arrayExpr.text, name => (isDirectPropBindingName(name, ctx) ? true : null)) === true
7754
7908
  }
7755
7909
 
7756
7910
  if (ts.isPropertyAccessExpression(arrayExpr) && propsObjName) {
7757
7911
  const obj = arrayExpr.expression
7758
- if (ts.isIdentifier(obj) && obj.text === propsObjName) {
7759
- return true
7912
+ if (ts.isIdentifier(obj)) {
7913
+ return resolveAliasOrigin(propAliasHopCandidates(ctx), obj.text, name => (name === propsObjName ? true : null)) === true
7760
7914
  }
7761
7915
  }
7762
7916
 
7763
7917
  return false
7764
7918
  }
7765
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
+
7766
8019
  /**
7767
8020
  * Check if array expression is a signal or memo getter call.
7768
8021
  * Used to determine if a loop needs mapArray for dynamic DOM updates.
@@ -7940,7 +8193,7 @@ function isPropsReference(expr: string, ctx: TransformContext, visited?: Set<str
7940
8193
  function forwardsCallerRestProps(attrs: IRAttribute[], ctx: TransformContext): boolean {
7941
8194
  for (const attr of attrs) {
7942
8195
  if (attr.value.kind !== 'spread') continue
7943
- const constants = restSpreadConstantValues(ctx)
8196
+ const constants = constantValuesByName(ctx)
7944
8197
  for (const name of new Set([attr.value.expr, attr.value.templateExpr])) {
7945
8198
  if (!name) continue
7946
8199
  if (resolveRestSpreadOriginCore(ctx.analyzer, constants, name) !== null) return true
@@ -7949,14 +8202,25 @@ function forwardsCallerRestProps(attrs: IRAttribute[], ctx: TransformContext): b
7949
8202
  return false
7950
8203
  }
7951
8204
 
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
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
7955
8208
  const byName = new Map<string, string | undefined>()
7956
8209
  for (const constant of ctx.analyzer.localConstants) {
7957
8210
  if (!byName.has(constant.name)) byName.set(constant.name, constant.value)
7958
8211
  }
7959
- 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
7960
8224
  return byName
7961
8225
  }
7962
8226