@barefootjs/jsx 0.31.0 → 0.31.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/adapters/interface.d.ts +11 -0
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/jsx-adapter.d.ts +92 -1
  4. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  5. package/dist/adapters/test-adapter.d.ts.map +1 -1
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/compiler.d.ts.map +1 -1
  8. package/dist/css-layer-prefixer.d.ts +16 -0
  9. package/dist/css-layer-prefixer.d.ts.map +1 -1
  10. package/dist/errors.d.ts +1 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/html-types.d.ts +19 -0
  13. package/dist/html-types.d.ts.map +1 -1
  14. package/dist/index.d.ts +3 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1402 -1124
  17. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/phases/props-event-handlers.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/phases/props-extraction.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/utils.d.ts +6 -4
  22. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  23. package/dist/jsx-runtime/index.d.ts +2 -8
  24. package/dist/jsx-runtime/index.d.ts.map +1 -1
  25. package/dist/jsx-to-ir.d.ts.map +1 -1
  26. package/dist/module-exports.d.ts +9 -1
  27. package/dist/module-exports.d.ts.map +1 -1
  28. package/dist/prop-rewrite.d.ts +7 -2
  29. package/dist/prop-rewrite.d.ts.map +1 -1
  30. package/dist/props-binding.d.ts +40 -0
  31. package/dist/props-binding.d.ts.map +1 -0
  32. package/dist/relocate.d.ts +9 -0
  33. package/dist/relocate.d.ts.map +1 -1
  34. package/dist/ssr-defaults.d.ts +43 -0
  35. package/dist/ssr-defaults.d.ts.map +1 -1
  36. package/dist/template-parts.d.ts +53 -0
  37. package/dist/template-parts.d.ts.map +1 -0
  38. package/dist/types.d.ts +10 -0
  39. package/dist/types.d.ts.map +1 -1
  40. package/package.json +2 -2
  41. package/src/__tests__/adapter-output.test.ts +8 -4
  42. package/src/__tests__/aliased-destructured-prop-csr.test.ts +112 -0
  43. package/src/__tests__/css-layer-prefixer.test.ts +72 -0
  44. package/src/__tests__/form-control-value-ssr.test.ts +48 -3
  45. package/src/__tests__/memo-deps-comments.test.ts +99 -0
  46. package/src/__tests__/multi-return-sibling-diagnostic.test.ts +241 -0
  47. package/src/__tests__/ssr-defaults.test.ts +124 -1
  48. package/src/__tests__/staged-ir/08-relocate-unit.test.ts +1 -0
  49. package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +1 -0
  50. package/src/adapters/interface.ts +11 -0
  51. package/src/adapters/jsx-adapter.ts +266 -4
  52. package/src/adapters/test-adapter.ts +13 -10
  53. package/src/analyzer.ts +50 -8
  54. package/src/compiler.ts +119 -18
  55. package/src/css-layer-prefixer.ts +80 -24
  56. package/src/errors.ts +18 -0
  57. package/src/html-types.ts +24 -0
  58. package/src/index.ts +7 -1
  59. package/src/ir-to-client-js/collect-elements.ts +4 -1
  60. package/src/ir-to-client-js/emit-reactive.ts +4 -2
  61. package/src/ir-to-client-js/phases/props-event-handlers.ts +4 -3
  62. package/src/ir-to-client-js/phases/props-extraction.ts +7 -4
  63. package/src/ir-to-client-js/plan/build-declaration-emit.ts +6 -3
  64. package/src/ir-to-client-js/utils.ts +5 -24
  65. package/src/jsx-runtime/index.ts +2 -7
  66. package/src/jsx-to-ir.ts +75 -42
  67. package/src/module-exports.ts +11 -2
  68. package/src/prop-rewrite.ts +25 -5
  69. package/src/props-binding.ts +70 -0
  70. package/src/relocate.ts +19 -2
  71. package/src/ssr-defaults.ts +70 -0
  72. package/src/template-parts.ts +81 -0
  73. package/src/types.ts +10 -0
@@ -7,6 +7,7 @@ import ts from 'typescript'
7
7
  import type { AttrValue, IRTemplatePart, LoopParamBinding, FreeReference, IRNode } from '../types.ts'
8
8
  import type { TopLevelLoop, BranchLoop, LoopOffset } from './types.ts'
9
9
  import { buildLoopChainExpr } from '../loop-chain.ts'
10
+ import { templatePartsToJsExpr } from '../template-parts.ts'
10
11
  import {
11
12
  iterateJsTokens,
12
13
  isIdentifierLikeToken,
@@ -73,31 +74,11 @@ export function profileBindingId(componentName: string | undefined, slotId: stri
73
74
  * Convert a `template` variant's parts into a JS template-literal string.
74
75
  * Shared by both `attrValueToString` and any consumer that wants to flatten
75
76
  * a structured template into JS-level concatenation.
77
+ *
78
+ * Re-exported from the single renderer in `../template-parts.ts` — the
79
+ * client bundle is plain JS, so it never passes `typed`.
76
80
  */
77
- export function templatePartsToJsExpr(parts: readonly IRTemplatePart[], opts?: { useTemplate?: boolean }): string {
78
- let result = '`'
79
- for (const part of parts) {
80
- if (part.type === 'string') {
81
- result += (opts?.useTemplate && part.templateValue) ? part.templateValue : part.value
82
- } else if (part.type === 'ternary') {
83
- const cond = (opts?.useTemplate && part.templateCondition) ? part.templateCondition : part.condition
84
- result += `\${${cond} ? '${part.whenTrue}' : '${part.whenFalse}'}`
85
- } else if (part.type === 'lookup') {
86
- // `${MAP[KEY]}` was structurally captured at IR time so SSR
87
- // adapters could emit a switch. For client-side JS we rebuild
88
- // the equivalent runtime indexed lookup against the resolved
89
- // cases — keeps the JSX runtime path semantically identical to
90
- // the original `${variantClasses[variant]}` source.
91
- const key = (opts?.useTemplate && part.templateKey) ? part.templateKey : part.key
92
- const obj = '{' + Object.entries(part.cases).map(
93
- ([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`
94
- ).join(', ') + '}'
95
- result += `\${(${obj})[${key}]}`
96
- }
97
- }
98
- result += '`'
99
- return result
100
- }
81
+ export { templatePartsToJsExpr }
101
82
 
102
83
  /**
103
84
  * Flatten an `AttrValue` to its raw string form, suitable for HTML attribute
@@ -45,6 +45,7 @@ import type {
45
45
  SVGBaseAttributes,
46
46
  SVGPresentationAttributes,
47
47
  SVGMarkerReferenceAttributes,
48
+ SVGSVGAttributes,
48
49
  } from '../html-types.ts'
49
50
 
50
51
  // Stub function types (for type checking only - no runtime implementation)
@@ -208,13 +209,7 @@ export declare namespace JSX {
208
209
  // SVG (basic support).
209
210
  // Each entry uses `SVGBaseAttributes` so `ref` can be narrowed per-tag.
210
211
  // See `SVGBaseAttributes` JSDoc for why plain intersection doesn't work.
211
- svg: SVGBaseAttributes & SVGPresentationAttributes & {
212
- viewBox?: string
213
- xmlns?: string
214
- width?: number | string
215
- height?: number | string
216
- ref?: (element: SVGSVGElement) => void
217
- }
212
+ svg: SVGSVGAttributes
218
213
  path: SVGBaseAttributes & SVGPresentationAttributes & SVGMarkerReferenceAttributes & {
219
214
  d?: string
220
215
  pathLength?: number | string
package/src/jsx-to-ir.ts CHANGED
@@ -49,6 +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
53
  import { resolveFreeRefs, isNameBound as isNameBoundInEnv, type BindingEnvironment } from './free-refs.ts'
53
54
  import { computeFileScope } from './ir-to-client-js/component-scope.ts'
54
55
  import { createTemplateAwareStringProtector } from './ir-to-client-js/html-template.ts'
@@ -59,6 +60,7 @@ import type { LoweringMatcher } from './lowering-registry.ts'
59
60
  import { extractFreeIdentifiersFromNode, initializerShapeContainsJsx, extractMultiReturnJsxBranches, type MultiReturnJsxBranches } from './analyzer.ts'
60
61
  import { iterateJsTokens, replaceInExprContexts } from './scanner/js-scanner.ts'
61
62
  import { reconstructAsSegments } from './strip-types.ts'
63
+ import { templatePartsToJsExpr } from './template-parts.ts'
62
64
  import { toHTMLAttrName, decodeEntities } from '@barefootjs/shared'
63
65
 
64
66
  // =============================================================================
@@ -91,6 +93,15 @@ interface TransformContext {
91
93
  _moduleClientSignalNames?: Set<string>
92
94
  /** Cached set of destructured prop names for AST-based rewriting */
93
95
  _destructuredPropNames?: Set<string> | null
96
+ /**
97
+ * Cached local-name → caller-facing-key (`sourceName ?? name`) map for
98
+ * `_destructuredPropNames`, entries only for names that actually rename
99
+ * (`{ n: count }` → `count` → `n`). Built alongside `_destructuredPropNames`
100
+ * so both stay in lockstep with the same shadow-filtered eligible set —
101
+ * consumed by `rewriteBarePropRefsCore` to emit `_p.<caller>` instead of
102
+ * `_p.<local>` (#2524 CSR half: `_p` is always caller-keyed).
103
+ */
104
+ _destructuredPropAliases?: Map<string, string> | null
94
105
  /** Active loop parameter names for slotId assignment to loop-param-dependent expressions */
95
106
  loopParams: Set<string>
96
107
  /**
@@ -536,7 +547,8 @@ function rewriteBarePropRefs(text: string, expr: ts.Node, ctx: TransformContext)
536
547
  // `_branchScopePropDeps` at branch entry; here we just walk `expr`
537
548
  // for references to those locals and union the matching dep sets.
538
549
  const extraPropRefs = collectBranchLocalPropRefsViaSubstitution(expr, ctx)
539
- return rewriteBarePropRefsCore(dateLowered, expr, propNames, extraPropRefs)
550
+ const propAliases = getDestructuredPropAliases(ctx)
551
+ return rewriteBarePropRefsCore(dateLowered, expr, propNames, extraPropRefs, propAliases ?? undefined)
540
552
  }
541
553
 
542
554
  /**
@@ -618,14 +630,26 @@ function getDestructuredPropNames(ctx: TransformContext): Set<string> | null {
618
630
  if (!isDestructureFromProps) shadowed.add(c.name)
619
631
  }
620
632
  }
621
- const names = ctx.analyzer.propsParams
622
- .map(p => p.name)
623
- .filter(n => !shadowed.has(n))
633
+ const eligible = ctx.analyzer.propsParams.filter(p => !shadowed.has(p.name))
634
+ const names = eligible.map(p => p.name)
624
635
  ctx._destructuredPropNames = names.length > 0 ? new Set(names) : null
636
+ ctx._destructuredPropAliases = buildPropAliasMap(eligible) ?? null
625
637
  }
626
638
  return ctx._destructuredPropNames ?? null
627
639
  }
628
640
 
641
+ /**
642
+ * Companion to `getDestructuredPropNames`: the local-name → caller-key
643
+ * alias map for the SAME shadow-filtered eligible set, populated as a
644
+ * side effect of that call. Always call `getDestructuredPropNames` first
645
+ * (or accept it may return an empty cache) — the two caches are written
646
+ * together in one pass.
647
+ */
648
+ function getDestructuredPropAliases(ctx: TransformContext): Map<string, string> | null {
649
+ if (ctx._destructuredPropNames === undefined) getDestructuredPropNames(ctx)
650
+ return ctx._destructuredPropAliases ?? null
651
+ }
652
+
629
653
  function createTransformContext(analyzer: AnalyzerContext): TransformContext {
630
654
  return {
631
655
  analyzer,
@@ -1278,12 +1302,21 @@ function transformJsxElement(
1278
1302
  * - `<textarea>` with no children gains a NON-reactive expression child
1279
1303
  * (the initial value as element content — updates keep flowing through
1280
1304
  * the `.value` effect, deliberately not a live text slot);
1281
- * - `<select>` distributes `selected={(value) === 'opt'}` onto each
1282
- * statically-valued `<option>` (incl. under `<optgroup>`/fragments)
1283
- * the exact per-option comparison shape the `select-option-selected`
1284
- * fixture already proves across every adapter. Options rendered by a
1285
- * dynamic loop can't be statically distributed and are left to the
1286
- * hydrate-time effect (tracked with #2466 on the #2464 thread).
1305
+ * - `<select>` distributes `selected={(value) === optValue}` onto each
1306
+ * `<option>` (incl. under `<optgroup>`/fragments, and under a `.map()`
1307
+ * loop body) — the exact per-option comparison shape the
1308
+ * `select-option-selected` fixture already proves across every adapter.
1309
+ * A literal `optValue` (e.g. `value="banana"`) compares by
1310
+ * `JSON.stringify`; an expression `optValue` (a static dynamic value, or
1311
+ * a loop row reading its item — e.g. `value={o.id}`) compares against
1312
+ * the expression text directly. For a loop row this makes `selected` an
1313
+ * ordinary per-item reactive attribute like any other (`o.id`, the row's
1314
+ * text) — it rides the SAME loop-plan machinery
1315
+ * (`collectLoopChildReactiveAttrs` → `emitAttrUpdate`, which special-cases
1316
+ * `selected` as a boolean DOM PROPERTY write, not just an HTML attribute)
1317
+ * that already reruns per row on item change and per outer-signal change,
1318
+ * so selectedness is recomputed instead of staying attached to whichever
1319
+ * physical `<option>` a reorder happened to rewrite in place (#2466).
1287
1320
  */
1288
1321
  function lowerFormControlValueSsr(
1289
1322
  tagName: string,
@@ -1318,21 +1351,47 @@ function lowerFormControlValueSsr(
1318
1351
  return
1319
1352
  }
1320
1353
 
1321
- const selectedFor = (optValue: string): AttrValue =>
1354
+ const selectedForLiteral = (optValue: string): AttrValue =>
1322
1355
  AttrValueOf.expression(
1323
1356
  `(${expr}) === ${JSON.stringify(optValue)}`,
1324
1357
  templateExpr !== undefined
1325
1358
  ? { templateExpr: `(${templateExpr}) === ${JSON.stringify(optValue)}` }
1326
1359
  : undefined,
1327
1360
  )
1361
+ // An expression-valued `option value` — the shape every `.map()` loop row
1362
+ // uses (`value={o.id}`) since the whole point of the loop is a per-item
1363
+ // value. Compares the controlled value directly against the option's own
1364
+ // value EXPRESSION TEXT (never JSON-stringified — it is JS, not a string
1365
+ // literal). Inside a loop row this expression reads both the row item
1366
+ // (`o.id`) and the outer controlled signal (`expr`, e.g. `val()`), which
1367
+ // is exactly the "reads both" shape `collectLoopChildReactiveAttrs` /
1368
+ // `classifyLazyBinding` already know how to place into both `applyItem`
1369
+ // (row changed) and `applyOuter` (controlled value changed) — no new loop
1370
+ // machinery, just one more per-row reactive attribute (#2466).
1371
+ const selectedForExpr = (optExpr: string, optTemplateExpr: string | undefined): AttrValue =>
1372
+ AttrValueOf.expression(
1373
+ `(${expr}) === (${optExpr})`,
1374
+ templateExpr !== undefined || optTemplateExpr !== undefined
1375
+ ? { templateExpr: `(${templateExpr ?? expr}) === (${optTemplateExpr ?? optExpr})` }
1376
+ : undefined,
1377
+ )
1328
1378
  const distribute = (nodes: IRNode[]): void => {
1329
1379
  for (const n of nodes) {
1330
1380
  if (n.type === 'element' && n.tag === 'option') {
1331
1381
  if (n.attrs.some(a => a.name === 'selected')) continue
1332
1382
  const optValue = n.attrs.find(a => a.name === 'value')
1333
- if (!optValue || optValue.value.kind !== 'literal') continue
1334
- n.attrs.push({ name: 'selected', value: selectedFor(optValue.value.value), loc: n.loc })
1335
- } else if (n.type === 'fragment' || (n.type === 'element' && n.tag === 'optgroup')) {
1383
+ if (!optValue) continue
1384
+ if (optValue.value.kind === 'literal') {
1385
+ n.attrs.push({ name: 'selected', value: selectedForLiteral(optValue.value.value), loc: n.loc })
1386
+ } else if (optValue.value.kind === 'expression') {
1387
+ const selected = selectedForExpr(optValue.value.expr, optValue.value.templateExpr)
1388
+ n.attrs.push({ name: 'selected', value: selected, loc: n.loc })
1389
+ }
1390
+ } else if (
1391
+ n.type === 'fragment' ||
1392
+ n.type === 'loop' ||
1393
+ (n.type === 'element' && n.tag === 'optgroup')
1394
+ ) {
1336
1395
  distribute(n.children)
1337
1396
  }
1338
1397
  }
@@ -6777,8 +6836,8 @@ function processComponentProps(
6777
6836
  // the bare `classes` identifier with no prop refs, so the rewrite
6778
6837
  // no-ops and the module-scope registration template leaks bare
6779
6838
  // destructured props into `renderChild(...)` (#2468).
6780
- const collapsed = templatePartsToJsString(value.parts)
6781
- const collapsedTemplate = templatePartsToJsString(value.parts, { useTemplate: true })
6839
+ const collapsed = templatePartsToJsExpr(value.parts)
6840
+ const collapsedTemplate = templatePartsToJsExpr(value.parts, { useTemplate: true })
6782
6841
  value = AttrValueOf.expression(collapsed, {
6783
6842
  parts: value.parts,
6784
6843
  ...(collapsedTemplate !== collapsed && { templateExpr: collapsedTemplate }),
@@ -6820,32 +6879,6 @@ function processComponentProps(
6820
6879
  return props
6821
6880
  }
6822
6881
 
6823
- /**
6824
- * Flatten a structured template-literal's parts back into a JS expression
6825
- * string. Used at IR construction time when a structured `template` variant
6826
- * needs to be collapsed into an `expression` for component-prop forwarding —
6827
- * component props are runtime JS values, not HTML attribute bodies.
6828
- */
6829
- function templatePartsToJsString(parts: readonly IRTemplatePart[], opts?: { useTemplate?: boolean }): string {
6830
- let result = '`'
6831
- for (const part of parts) {
6832
- if (part.type === 'string') {
6833
- result += (opts?.useTemplate && part.templateValue) ? part.templateValue : part.value
6834
- } else if (part.type === 'ternary') {
6835
- const cond = (opts?.useTemplate && part.templateCondition) ? part.templateCondition : part.condition
6836
- result += `\${${cond} ? '${part.whenTrue}' : '${part.whenFalse}'}`
6837
- } else if (part.type === 'lookup') {
6838
- const key = (opts?.useTemplate && part.templateKey) ? part.templateKey : part.key
6839
- const obj = '{' + Object.entries(part.cases).map(
6840
- ([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`
6841
- ).join(', ') + '}'
6842
- result += `\${(${obj})[${key}]}`
6843
- }
6844
- }
6845
- result += '`'
6846
- return result
6847
- }
6848
-
6849
6882
  // =============================================================================
6850
6883
  // Helpers
6851
6884
  // =============================================================================
@@ -21,10 +21,19 @@ export function generateModuleExports(
21
21
  ir: ComponentIR,
22
22
  extraInlineExported: ReadonlySet<string> = new Set(),
23
23
  rewriteRelativeImport?: (importPath: string) => string,
24
+ options?: {
25
+ /**
26
+ * Skip `export const` / `export function` value declarations — the
27
+ * adapter already emitted them inside its module-scope section, in
28
+ * source order (see `TemplateSections.moduleConstantsIncludeExports`).
29
+ * `export { … } [from '…']` specifier blocks are still emitted.
30
+ */
31
+ skipValueDeclarations?: boolean
32
+ },
24
33
  ): string | null {
25
34
  const lines: string[] = []
26
35
 
27
- for (const constant of ir.metadata.localConstants) {
36
+ for (const constant of options?.skipValueDeclarations ? [] : ir.metadata.localConstants) {
28
37
  if (!constant.isExported) continue
29
38
  const keyword = constant.declarationKind ?? 'const'
30
39
  if (!constant.value) {
@@ -38,7 +47,7 @@ export function generateModuleExports(
38
47
  lines.push(`export ${keyword} ${constant.name} = ${constant.value}`)
39
48
  }
40
49
 
41
- for (const func of ir.metadata.localFunctions) {
50
+ for (const func of options?.skipValueDeclarations ? [] : ir.metadata.localFunctions) {
42
51
  if (!func.isExported) continue
43
52
  // Prefer the source-verbatim signature so type predicates and explicit
44
53
  // `:unknown` parameter annotations survive — see FunctionInfo.typedParams
@@ -135,7 +135,11 @@ export function collectAstPropRefs(
135
135
  * Returns null when `text` does not parse cleanly as an expression —
136
136
  * the caller falls back to the legacy regex rewrite.
137
137
  */
138
- function applyScopedPropRefRewrite(text: string, propRefs: Set<string>): string | null {
138
+ function applyScopedPropRefRewrite(
139
+ text: string,
140
+ propRefs: Set<string>,
141
+ propAliases?: ReadonlyMap<string, string>,
142
+ ): string | null {
139
143
  // Wrap in parens so object literals and arrows parse as expressions.
140
144
  const prefix = '('
141
145
  const sf = ts.createSourceFile('__bf_prop_rewrite.ts', `${prefix}${text}\n)`, ts.ScriptTarget.Latest, true)
@@ -149,11 +153,15 @@ function applyScopedPropRefRewrite(text: string, propRefs: Set<string>): string
149
153
  const start = n.getStart(sf) - prefix.length
150
154
  const end = n.getEnd() - prefix.length
151
155
  if (start < 0 || end > text.length) return
156
+ // `_p` is always keyed by the caller-facing name (`sourceName ?? name`
157
+ // — #2524 CSR half); the local binding (`n.text`) only survives on the
158
+ // left of a shorthand expansion.
159
+ const callerKey = propAliases?.get(n.text) ?? n.text
152
160
  if (parent && ts.isShorthandPropertyAssignment(parent) && parent.name === n) {
153
- edits.push({ start, end, replacement: `${n.text}: ${PROPS_PARAM}.${n.text}` })
161
+ edits.push({ start, end, replacement: `${n.text}: ${PROPS_PARAM}.${callerKey}` })
154
162
  return
155
163
  }
156
- edits.push({ start, end, replacement: `${PROPS_PARAM}.${n.text}` })
164
+ edits.push({ start, end, replacement: `${PROPS_PARAM}.${callerKey}` })
157
165
  })
158
166
 
159
167
  if (edits.length === 0) return text
@@ -177,11 +185,14 @@ function applyScopedPropRefRewrite(text: string, propRefs: Set<string>): string
177
185
  export function applyRegexPropRefRewrite(
178
186
  text: string,
179
187
  propRefs: Iterable<string>,
188
+ propAliases?: ReadonlyMap<string, string>,
180
189
  ): string {
181
190
  const { protect, restore } = createTemplateAwareStringProtector()
182
191
  let result = protect(text)
183
192
 
184
193
  for (const propName of propRefs) {
194
+ // `_p` is always keyed by the caller-facing name (#2524 CSR half).
195
+ const callerKey = propAliases?.get(propName) ?? propName
185
196
  const pattern = new RegExp(`(?<!${PROPS_PARAM}\\.)(?<!['"\\w.-])\\b${propName}\\b(?![a-zA-Z0-9_$])`, 'g')
186
197
  result = result.replace(pattern, (match, offset, str) => {
187
198
  // Skip object literal keys: preceded by { or , and followed by :
@@ -190,7 +201,7 @@ export function applyRegexPropRefRewrite(
190
201
  const before = str.slice(0, offset)
191
202
  if (/[{,]\s*$/.test(before)) return match
192
203
  }
193
- return `${PROPS_PARAM}.${propName}`
204
+ return `${PROPS_PARAM}.${callerKey}`
194
205
  })
195
206
  }
196
207
 
@@ -209,12 +220,18 @@ export function applyRegexPropRefRewrite(
209
220
  * `text` was produced by inlining a branch-local whose initializer
210
221
  * references the prop). The rewrite only touches genuine value
211
222
  * references, so passing an over-broad set is safe.
223
+ * @param propAliases - Local name → caller-facing key (`sourceName ?? name`)
224
+ * for aliased destructured props (`{ n: count }` → `count` → `n`).
225
+ * `_p` is always keyed by the caller-facing name (#2524 CSR half); a name
226
+ * absent from this map emits `_p.<name>` unchanged (the un-aliased case,
227
+ * where `sourceName ?? name` is an identity).
212
228
  */
213
229
  export function rewriteBarePropRefs(
214
230
  text: string,
215
231
  node: ts.Node,
216
232
  propNames: Set<string>,
217
233
  extraPropRefs?: ReadonlySet<string>,
234
+ propAliases?: ReadonlyMap<string, string>,
218
235
  ): string | undefined {
219
236
  // Walk AST to find which prop names are actually used as value references
220
237
  const foundPropRefs = new Set<string>()
@@ -225,5 +242,8 @@ export function rewriteBarePropRefs(
225
242
  }
226
243
  }
227
244
  if (foundPropRefs.size === 0) return undefined
228
- return applyScopedPropRefRewrite(text, foundPropRefs) ?? applyRegexPropRefRewrite(text, foundPropRefs)
245
+ return (
246
+ applyScopedPropRefRewrite(text, foundPropRefs, propAliases) ??
247
+ applyRegexPropRefRewrite(text, foundPropRefs, propAliases)
248
+ )
229
249
  }
@@ -0,0 +1,70 @@
1
+ import ts from 'typescript'
2
+ import type { ParamInfo } from './types.ts'
3
+
4
+ /**
5
+ * Authoritative IdentifierName classification for a destructure-pattern
6
+ * property key, built on TS's own `isIdentifierStart` / `isIdentifierPart`
7
+ * primitives (Unicode-aware, stays aligned with what TS itself accepts as
8
+ * a bare property key). Mirrors the `isIdent` precedent in
9
+ * `jsx-to-ir.ts` (#1244) — a source key like `data-key` or `aria-label`
10
+ * can't be emitted as a bare `key: local` destructure and must be quoted
11
+ * (`"data-key": local`).
12
+ */
13
+ export function isIdentifierName(key: string): boolean {
14
+ if (key.length === 0) return false
15
+ for (let i = 0; i < key.length; ) {
16
+ const cp = key.codePointAt(i)!
17
+ const ok = i === 0
18
+ ? ts.isIdentifierStart(cp, ts.ScriptTarget.Latest)
19
+ : ts.isIdentifierPart(cp, ts.ScriptTarget.Latest)
20
+ if (!ok) return false
21
+ i += cp > 0xFFFF ? 2 : 1
22
+ }
23
+ return true
24
+ }
25
+
26
+ /**
27
+ * The single destructure-binding renderer for a props param, shared by
28
+ * every JSX-runtime SSR adapter (Hono, TestAdapter). The caller-facing
29
+ * key is `sourceName ?? name` (ParamInfo's own rule) — `name` is only
30
+ * ever the LOCAL binding. Emits the plain shorthand when they match
31
+ * (byte-identical to the pre-rename-aware form); emits a `key: local`
32
+ * rename otherwise (b4f5075). This also covers the `class` → `className`
33
+ * rename: a source prop literally named `class` can only reach
34
+ * `propsParams` via an aliased destructure (`{ class: className }` —
35
+ * `class` is a reserved word, so it can never be an un-aliased binding),
36
+ * which sets `sourceName: 'class'` and takes the rename branch
37
+ * (`class: className`), not a bare `className`.
38
+ *
39
+ * One exported implementation, two consumers, zero drift — the
40
+ * hono/test-adapter pair carrying private copies is exactly the
41
+ * lockstep-rule duplication #2460/#2524 were about.
42
+ */
43
+ export function propsDestructureBinding(p: ParamInfo): string {
44
+ const callerKey = p.sourceName ?? p.name
45
+ const localName = p.name
46
+ const binding = callerKey === localName
47
+ ? localName
48
+ : `${isIdentifierName(callerKey) ? callerKey : JSON.stringify(callerKey)}: ${localName}`
49
+ return p.defaultValue ? `${binding} = ${p.defaultValue}` : binding
50
+ }
51
+
52
+ /**
53
+ * Local-name → caller-facing-key map for prop-reference rewrites —
54
+ * entries only for `ParamInfo`s that actually rename (`sourceName` set,
55
+ * see its docstring in `types.ts`). `_p` is always keyed by the
56
+ * caller-facing name (#2524 CSR half); an un-aliased prop leaves no
57
+ * entry, so `map?.get(name) ?? name` degrades to an identity there.
58
+ * Returns `undefined` when nothing renames, so callers can
59
+ * short-circuit.
60
+ */
61
+ export function buildPropAliasMap(params: readonly ParamInfo[]): Map<string, string> | undefined {
62
+ let map: Map<string, string> | undefined
63
+ for (const p of params) {
64
+ if (p.sourceName) {
65
+ if (!map) map = new Map()
66
+ map.set(p.name, p.sourceName)
67
+ }
68
+ }
69
+ return map
70
+ }
package/src/relocate.ts CHANGED
@@ -15,6 +15,7 @@ import type { Scope, BindingKind, IRMetadata } from './types.ts'
15
15
  import { isVisibleIn } from './types.ts'
16
16
  import type { AnalyzerContext } from './analyzer-context.ts'
17
17
  import { PROPS_PARAM } from './ir-to-client-js/utils.ts'
18
+ import { buildPropAliasMap } from './props-binding.ts'
18
19
  import type {
19
20
  TemplatePrimitiveRegistry,
20
21
  TemplateCallAcceptor,
@@ -42,6 +43,15 @@ export interface RelocateEnv {
42
43
  * `TransformContext._destructuredPropNames`.
43
44
  */
44
45
  propsForLift: Set<string>
46
+ /**
47
+ * Local prop name → caller-facing key (`sourceName ?? name`), entries
48
+ * only for `ParamInfo`s that rename (`{ n: count }` → `count` → `n`).
49
+ * `_p` is always keyed by the caller-facing name (#2524 CSR half) — the
50
+ * `lift-to-prop` action reads this so `count` lifts to `_p.n`, not
51
+ * `_p.count`. A name absent from this map is un-aliased, so
52
+ * `propSourceNames.get(name) ?? name` degrades to an identity there.
53
+ */
54
+ propSourceNames: ReadonlyMap<string, string>
45
55
  /**
46
56
  * Name of the props parameter (e.g. `props`). Used to detect
47
57
  * `props.X` member access at lift sites — those are not free refs
@@ -179,8 +189,10 @@ function decideAction(
179
189
  if (env.propsObjectName !== null && name === env.propsObjectName) {
180
190
  return { action: 'lift-to-prop', rewrittenAs: PROPS_PARAM }
181
191
  }
182
- // Lift `name` → `_p.name`.
183
- return { action: 'lift-to-prop', rewrittenAs: `${PROPS_PARAM}.${name}` }
192
+ // Lift `name` → `_p.<caller-facing key>` — `_p` is always keyed by
193
+ // the caller-facing name (#2524 CSR half), not the local binding.
194
+ const callerKey = env.propSourceNames.get(name) ?? name
195
+ return { action: 'lift-to-prop', rewrittenAs: `${PROPS_PARAM}.${callerKey}` }
184
196
  }
185
197
 
186
198
  if ((kind === 'init-local' || kind === 'sub-init-local') && toScope === 'template') {
@@ -860,6 +872,10 @@ function buildRelocateEnvFromFields(src: EnvFields): RelocateEnv {
860
872
  if (kind === 'prop') propsForLift.add(name)
861
873
  }
862
874
 
875
+ // propSourceNames: local prop name → caller-facing key, entries only
876
+ // for `ParamInfo`s that actually rename. See `RelocateEnv.propSourceNames`.
877
+ const propSourceNames = buildPropAliasMap(src.propsParams) ?? new Map<string, string>()
878
+
863
879
  // aliasTargets (#2069 R2): one-hop alias resolution table for
864
880
  // `isCallAcceptedByAdapter`. A const whose FINAL resolved binding kind
865
881
  // is `init-local` or `module-local` (i.e. not a signal/memo/prop-alias
@@ -884,6 +900,7 @@ function buildRelocateEnvFromFields(src: EnvFields): RelocateEnv {
884
900
  bindings,
885
901
  inlinable: new Map(), // populated by compute-inlinability after analyzer runs
886
902
  propsForLift,
903
+ propSourceNames,
887
904
  propsObjectName,
888
905
  allowFallback: true,
889
906
  aliasTargets,
@@ -62,6 +62,76 @@ export interface SsrDefault {
62
62
  isRestProps?: boolean
63
63
  }
64
64
 
65
+ /**
66
+ * TS twin of the runtime `derive*FromDefaults` family that ships in every
67
+ * OTHER SSR runtime port (Ruby's `BarefootJS::Context.derive_vars_from_defaults`
68
+ * — `packages/adapter-erb/lib/barefoot_js.rb:337-360` — plus Python's
69
+ * `barefootjs.runtime._derive_stash_from_defaults`, PHP's
70
+ * `Barefoot\BarefootJS::deriveStashFromDefaults`, Perl's
71
+ * `BarefootJS::_derive_stash_from_defaults`, and Rust's
72
+ * `barefootjs::manifest::derive_stash_from_defaults`). TypeScript had no such
73
+ * function — every adapter-tests conformance harness (and 3 production
74
+ * integration sites) either discarded `SsrDefault.propName` outright or keyed
75
+ * its seeding loop off the LOCAL template-var name instead of the
76
+ * caller-facing prop key, which is exactly the #2157 defect class (and its
77
+ * #2524 SSR-seeding recurrence) restated at
78
+ * `packages/adapter-erb/src/test-render.ts:276-287`. Any harness or
79
+ * integration deriving template-stash vars from an `extractSsrDefaults(...)`
80
+ * map MUST route through this function (or its runtime-language twin, when
81
+ * one is reachable) instead of hand-flattening `SsrDefault.value` and
82
+ * merging raw caller props over it — that flattening is precisely what
83
+ * silently drops the rename.
84
+ *
85
+ * Semantics (mirrors `derive_vars_from_defaults`'s observable behavior,
86
+ * including its edge cases — though not always the identical mechanism;
87
+ * e.g. Python's `_derive_stash_from_defaults` checks `props.get(prop_name)
88
+ * is not None` with no separate `in` membership test, relying on `dict.get`
89
+ * defaulting a missing key to `None` — behaviorally identical to this
90
+ * function's explicit `propName in props && props[propName] != null` for a
91
+ * plain dict/object, just expressed differently):
92
+ * - A non-object entry (a bare JSON value some callers may still pass,
93
+ * e.g. a manifest round-tripped through a generic JSON domain) is used
94
+ * AS-IS.
95
+ * - `isRestProps` entries: prefer `props[<this entry's own key>]` when the
96
+ * caller supplied one (checked via `in`, so an explicit `undefined` /
97
+ * `null` value still counts as "supplied" — the rest bag is a
98
+ * caller-assembled aggregate, not a single scalar with a meaningful
99
+ * "absent" state), else the static `value` fallback (normally `{}`).
100
+ * - Otherwise: prefer `props[propName]` when `propName` is set AND the
101
+ * caller supplied a NON-NULLISH (`!= null`, so `undefined` and `null`
102
+ * both fall through — mirrors every other port's "present and defined"
103
+ * check) value for it, else the static `value` fallback. `propName`-less
104
+ * entries (signal / memo locals) always use the static value — the
105
+ * caller cannot override them by construction.
106
+ */
107
+ export function deriveStashFromDefaults(
108
+ defaults: Record<string, SsrDefault>,
109
+ props: Record<string, unknown>,
110
+ ): Record<string, unknown> {
111
+ const extra: Record<string, unknown> = {}
112
+ for (const [name, d] of Object.entries(defaults)) {
113
+ if (d === null || typeof d !== 'object') {
114
+ // Defensive: every ENTRY `extractSsrDefaults` itself emits is always
115
+ // the `{ value, propName?, isRestProps? }` shape, but a caller may
116
+ // feed this a manifest round-tripped through a generic JSON domain
117
+ // (mirrors every runtime port's own `ref($d) eq 'HASH'` /
118
+ // `d.is_a?(Hash)` / `isinstance(d, dict)` guard).
119
+ extra[name] = d
120
+ continue
121
+ }
122
+ if (d.isRestProps) {
123
+ extra[name] = name in props ? props[name] : d.value
124
+ continue
125
+ }
126
+ if (d.propName !== undefined && d.propName in props && props[d.propName] != null) {
127
+ extra[name] = props[d.propName]
128
+ } else {
129
+ extra[name] = d.value
130
+ }
131
+ }
132
+ return extra
133
+ }
134
+
65
135
  const UNRESOLVED = Symbol('unresolved')
66
136
  type EvalResult = unknown | typeof UNRESOLVED
67
137
 
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The single renderer for a structured `template` variant's parts back into
3
+ * JS template-literal source.
4
+ *
5
+ * Three sites used to carry byte-identical copies of this loop — the IR-time
6
+ * component-prop collapse (`jsx-to-ir.ts`), the client-JS emitter
7
+ * (`ir-to-client-js/utils.ts`), and the JSX adapters' attribute renderer. They
8
+ * have to agree: the collapse's output is what a JSX adapter emits verbatim
9
+ * for a component prop, so a divergence between any two of them is a silent
10
+ * SSR/CSR mismatch. One door, three callers.
11
+ */
12
+
13
+ import type { IRTemplatePart } from './types.ts'
14
+
15
+ export interface TemplatePartsToJsOptions {
16
+ /**
17
+ * Prefer each part's prop-rewritten projection (`templateValue` /
18
+ * `templateCondition` / `templateKey`, i.e. destructured props rewritten
19
+ * to `_p.X`) when present. Used by the client-JS / module-registration
20
+ * template emitters, which run outside the component's destructured scope.
21
+ */
22
+ useTemplate?: boolean
23
+ /**
24
+ * Emit TypeScript type annotations. Only adapters whose output is
25
+ * type-checked as .tsx set this — see `JsxAdapterConfig.preserveTypes`.
26
+ * The neutral (untyped) form is what DSL adapters' expression pipelines
27
+ * and the client-JS bundle consume, so it must stay plain JS.
28
+ */
29
+ typed?: boolean
30
+ }
31
+
32
+ /**
33
+ * Render one `lookup` part — `${MAP[KEY]}` structurally captured at IR time
34
+ * so DSL adapters can emit a switch — as the equivalent runtime indexed
35
+ * access against the resolved cases.
36
+ *
37
+ * Under `typed`, the inlined object literal is annotated
38
+ * `as Record<string, string>` (#2565). The IR's `key` is the TYPE-STRIPPED
39
+ * source text of the index expression, so a narrowing assertion written at
40
+ * the use site — `strokePaths[name as keyof typeof strokePaths]` — is already
41
+ * gone by the time the record's cases are folded in here. That leaves the
42
+ * literal's exact key set indexed by the binding's unnarrowed union, which
43
+ * fails TS7053 ("expression of type 'IconName' can't be used to index type
44
+ * '{ check: string; … }'") in any consumer that type-checks its compiled
45
+ * templates. Widening the literal to a string index signature restores the
46
+ * assertion's effect without reconstructing its text, which may name types
47
+ * the emitted template never declares (`keyof typeof strokePaths` where
48
+ * `strokePaths` was localised into a component body). Purely a type-level
49
+ * annotation — the runtime expression is identical either way.
50
+ */
51
+ export function lookupPartToJsExpr(
52
+ part: Extract<IRTemplatePart, { type: 'lookup' }>,
53
+ opts?: TemplatePartsToJsOptions,
54
+ ): string {
55
+ const key = (opts?.useTemplate && part.templateKey) ? part.templateKey : part.key
56
+ const obj = '{' + Object.entries(part.cases).map(
57
+ ([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`
58
+ ).join(', ') + '}'
59
+ const typed = opts?.typed ? ' as Record<string, string>' : ''
60
+ return `(${obj}${typed})[${key}]`
61
+ }
62
+
63
+ /** Convert a `template` variant's parts into a JS template-literal string. */
64
+ export function templatePartsToJsExpr(
65
+ parts: readonly IRTemplatePart[],
66
+ opts?: TemplatePartsToJsOptions,
67
+ ): string {
68
+ let result = '`'
69
+ for (const part of parts) {
70
+ if (part.type === 'string') {
71
+ result += (opts?.useTemplate && part.templateValue) ? part.templateValue : part.value
72
+ } else if (part.type === 'ternary') {
73
+ const cond = (opts?.useTemplate && part.templateCondition) ? part.templateCondition : part.condition
74
+ result += `\${${cond} ? '${part.whenTrue}' : '${part.whenFalse}'}`
75
+ } else if (part.type === 'lookup') {
76
+ result += `\${${lookupPartToJsExpr(part, opts)}}`
77
+ }
78
+ }
79
+ result += '`'
80
+ return result
81
+ }