@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
@@ -6,7 +6,7 @@ import { type IRNode, type IRElement, type IRComponent, type IRLoop, type IRProp
6
6
  import type { ClientJsContext, ConditionalBranchChildComponent, ConditionalBranchReactiveAttr, BranchLoop, ConditionalBranchTextEffect, ConditionalElement, LoopChildBindings, LoopChildBranchSummary, LoopChildConditional, LoopOffset, NestedLoop } from './types.ts'
7
7
  import { attrValueToString, freeIdsFromRefs, quotePropName, PROPS_PARAM } from './utils.ts'
8
8
  import { classifyReactivity, decideWrapForAttr, decideWrapForChildProp, decideWrapFromAstFlags, collectEventHandlersFromIR, collectConditionalBranchEvents, collectConditionalBranchRefs, collectConditionalBranchChildComponents, collectLoopChildEventsWithNesting, collectLoopChildReactiveAttrs, collectLoopChildReactiveTexts, collectLoopChildRefs, emptyLoopChildBindings, buildLoopRowScope, anyNameIn } from './reactivity.ts'
9
- import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr, buildLoopSkeletonTemplate, computeSkeletonSlotPaths, renderFlatMapClientBody, renderFlatMapProjectionClientBody, flatMapCallbackHasKeyedLeaf, type SkeletonSlotPaths } from './html-template.ts'
9
+ import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr, jsxChildrenPropGetterExpr, buildLoopSkeletonTemplate, computeSkeletonSlotPaths, renderFlatMapClientBody, renderFlatMapProjectionClientBody, flatMapCallbackHasKeyedLeaf, type SkeletonSlotPaths } from './html-template.ts'
10
10
  import { detectRootNamespaceWrapTag } from './control-flow/stringify/template-parse.ts'
11
11
  import { expandDynamicPropValue, expandConstantForReactivity, resolveRestSpreadOrigin, resolveRestSpreadNames } from './prop-handling.ts'
12
12
  import { extractFreeIdentifiersFromText } from './csr-substitute.ts'
@@ -475,29 +475,6 @@ function jsxChildrenContainComponent(nodes: IRNode[]): boolean {
475
475
  return false
476
476
  }
477
477
 
478
- /**
479
- * Narrow gate for branding a `jsx-children` getter's value with `bfMarkup()`
480
- * (#2651). The constructor (`jsx-to-ir.ts`'s `processComponentProps`) always
481
- * stores exactly one node here (`AttrValueOf.jsxChildren([...])`), so
482
- * `nodes[0]` is the whole payload; `irChildrenToJsExpr` (`html-template.ts`)
483
- * turns a single `'element'` node into ONE HTML-string template literal —
484
- * the same shape the `renderChild` / `irToComponentTemplateWithOpts` /
485
- * `generateCsrTemplateWithOpts` "jsx-children" doors always produce (they
486
- * join every child into one string regardless of shape), and the shape
487
- * this fixture's `header={<strong>Title</strong>}` exercises.
488
- *
489
- * Deliberately narrow — NOT a general "does this reduce to one string"
490
- * check: a `'fragment'` node (`header={<>text<strong/></>}`, multiple
491
- * children) or a `'conditional'` node reduces through `irChildrenToJsExpr`
492
- * to an array literal or a nested ternary of un-escaped-vs-escaped parts,
493
- * for which `escapeTextOrNode`/`escapeTextOrMarkup` have no (array) or an
494
- * unproven (ternary) contract. Those shapes are left unbranded — see
495
- * #2651's door inventory — rather than guessed at here.
496
- */
497
- function isSingleElementJsxChildren(nodes: IRNode[]): boolean {
498
- return nodes.length === 1 && nodes[0].type === 'element'
499
- }
500
-
501
478
  /** Build propsExpr for a child component from its IR props. */
502
479
  function buildComponentPropsExpr(props: IRProp[], ctx: ClientJsContext): string {
503
480
  const knownSpreadProp = props.find(p => {
@@ -526,19 +503,13 @@ function buildComponentPropsExpr(props: IRProp[], ctx: ClientJsContext): string
526
503
  }
527
504
  switch (prop.value.kind) {
528
505
  case 'jsx-children': {
529
- const jsxExpr = irChildrenToJsExpr(prop.value.children)
530
506
  if (jsxChildrenContainComponent(prop.value.children)) {
507
+ const jsxExpr = irChildrenToJsExpr(prop.value.children)
531
508
  propsForInit.push(`get ${quotePropName(prop.name)}() { return __slot(() => ${jsxExpr}) }`)
532
- } else if (prop.name !== 'children' && isSingleElementJsxChildren(prop.value.children)) {
533
- // Brand (#2651): `jsxExpr` is the single HTML-string template
534
- // literal `irChildrenToJsExpr` builds for a lone 'element' node —
535
- // see `isSingleElementJsxChildren`'s docstring for why only this
536
- // shape is branded here. Excludes an explicit `children={<jsx/>}`
537
- // prop (out of scope, unchanged) — the child's `{children}`
538
- // interpolation is a bare passthrough with no unwrap call, so a
539
- // branded object there would stringify to `[object Object]`.
540
- propsForInit.push(`get ${quotePropName(prop.name)}() { return bfMarkup(${jsxExpr}) }`)
541
509
  } else {
510
+ // Brand via the shared door (#2651/#2702) — see
511
+ // `jsxChildrenPropGetterExpr`'s docstring.
512
+ const jsxExpr = jsxChildrenPropGetterExpr(prop.value.children, prop.name)
542
513
  propsForInit.push(`get ${quotePropName(prop.name)}() { return ${jsxExpr} }`)
543
514
  }
544
515
  break
@@ -772,10 +743,11 @@ export function collectElements(
772
743
  // passed), matching the plain-element path's `staticItemTemplate`.
773
744
  if (l.isStaticArray && l.children[0]) {
774
745
  // `l.children[0]` is the loop's row-root component — already
775
- // tagged `loopItemRoot` in the IR, so `derivesScopeFromSlot`
776
- // drops the parent's slot suffix automatically. Each iteration
777
- // owns a distinct scope identified by `data-key`, mirroring the
778
- // SSR template's renderChild emit.
746
+ // tagged `loopItemRoot` in the IR, so `renderChildScopeArgs`
747
+ // passes its slotId with the `loopItemRoot` flag (#2833) instead
748
+ // of deriving its own scope from the parent slot. Each iteration
749
+ // still owns a distinct scope identified by `data-key`, mirroring
750
+ // the SSR template's renderChild emit.
779
751
  staticItemTemplate = irToHtmlTemplate(l.children[0], resolveRestSpreadNames(ctx), 0, undefined, undefined)
780
752
  }
781
753
  } else if (l.children[0] && !projectionInner) {
@@ -1,3 +1,5 @@
1
+ import type { ImportInfo } from '../types.ts'
2
+
1
3
  /**
2
4
  * File-scoped component name disambiguation for the runtime registry.
3
5
  *
@@ -63,8 +65,66 @@ export function setActiveComponentScope(scope: { fileScope: string; nonExportedS
63
65
  _activeScope = scope
64
66
  }
65
67
 
68
+ /**
69
+ * #2777 — a client component referenced under an import alias
70
+ * (`import { Foo as Bar } from './Foo'`, `<Bar/>`) must resolve its
71
+ * registry key to the DECLARED/exported name (`Foo`, what the child's own
72
+ * module registers under via `hydrate('Foo', ...)`), not the caller-local
73
+ * binding (`Bar`) — the registry is keyed by string name, so a mismatch
74
+ * here left `initChild('Bar', ...)` unable to find `Foo`'s registration
75
+ * and hydration silently never ran.
76
+ *
77
+ * A local alias → exported name map, active per client-JS generation the
78
+ * same way `_activeScope` is (module-level state, installed/cleared by
79
+ * `generateClientJsWithSourceMap`) rather than threaded through every
80
+ * recursive emitter — same rationale as `_activeScope` above.
81
+ */
82
+ let _activeImportAliases: ReadonlyMap<string, string> | null = null
83
+
84
+ /**
85
+ * Build a local-alias → exported-name map from a component's parsed
86
+ * imports. Only VALUE, non-default, non-namespace, ALIASED specifiers
87
+ * produce an entry — a bare `import { Foo }` needs no rewrite (`name`
88
+ * already equals what's registered), and default/namespace imports can't
89
+ * be resolved this way (a default's exported name isn't recorded here,
90
+ * and a namespace member reference isn't an aliased specifier at all).
91
+ */
92
+ export function buildImportAliasMap(imports: readonly ImportInfo[]): Map<string, string> {
93
+ const aliases = new Map<string, string>()
94
+ for (const imp of imports) {
95
+ if (imp.isTypeOnly) continue
96
+ for (const spec of imp.specifiers) {
97
+ if (spec.isTypeOnly || spec.isDefault || spec.isNamespace || spec.alias === null) continue
98
+ aliases.set(spec.alias, spec.name)
99
+ }
100
+ }
101
+ return aliases
102
+ }
103
+
104
+ export function setActiveImportAliases(aliases: ReadonlyMap<string, string> | null): void {
105
+ _activeImportAliases = aliases
106
+ }
107
+
108
+ /**
109
+ * Resolve a local JSX tag name to the name the referenced component's own
110
+ * module registers under (`hydrate(<name>, ...)`) — the caller-facing
111
+ * alias if imported under one, otherwise the name unchanged. Used
112
+ * anywhere a name needs to reach the runtime registry WITHOUT also going
113
+ * through the non-exported-sibling rewrite below (the `@bf-child:` marker
114
+ * comment, which must never carry a same-file sibling's hash).
115
+ */
116
+ export function resolveImportedComponentName(name: string): string {
117
+ return _activeImportAliases?.get(name) ?? name
118
+ }
119
+
66
120
  /** Resolve a component name to its registry key under the active file scope. */
67
121
  export function nameForRegistryRef(name: string): string {
122
+ // An aliased IMPORT always wins over the non-exported-sibling rewrite:
123
+ // `import { Foo as Bar } from './Foo'` plus a private same-file
124
+ // `function Foo() {}` must still resolve `<Bar/>` to the imported
125
+ // `Foo`, not hash the sibling's `Foo` key onto it.
126
+ const imported = _activeImportAliases?.get(name)
127
+ if (imported !== undefined) return imported
68
128
  if (!_activeScope) return name
69
129
  if (!_activeScope.fileScope) return name
70
130
  if (!_activeScope.nonExportedSiblings.has(name)) return name
@@ -32,7 +32,7 @@ import type { RelocateEnv, RelocateDecision } from '../relocate.ts'
32
32
  import { createError, ErrorCodes } from '../errors.ts'
33
33
  import { attrValueToString } from './utils.ts'
34
34
  import { walkIR, type IRVisitor } from './walker.ts'
35
- import { buildSignalMemoEnv, csrSubstitute, type CsrEnv, type CsrSubstitution } from './csr-substitute.ts'
35
+ import { buildSignalMemoEnv, csrSubstitute, resolveGetterAliases, type CsrEnv, type CsrSubstitution } from './csr-substitute.ts'
36
36
 
37
37
  /**
38
38
  * Build a `RelocateEnv` from a live `ClientJsContext`. The IR-keyed env
@@ -237,6 +237,13 @@ export function computeInlinability(
237
237
  const signalGetters = new Set(ctx.signals.map(s => s.getter))
238
238
  const signalSetters = new Set(ctx.signals.filter(s => s.setter).map(s => s.setter!))
239
239
  const memoNames = new Set(ctx.memos.map(m => m.name))
240
+ // A bare alias-hop chain that ultimately names a signal/memo getter
241
+ // (`const items__alias = items`, #2778) is `reactive-read` the same as
242
+ // a direct reference — computed once here and threaded to both the
243
+ // per-const classification below AND `populateCsrInlinable`, so the
244
+ // two passes can't independently reach different verdicts for the
245
+ // same alias.
246
+ const getterAliases = resolveGetterAliases(ctx.localConstants, (n) => signalGetters.has(n) || memoNames.has(n))
240
247
 
241
248
  // RelocateEnv is built once per component from the live ClientJsContext.
242
249
  // The adapter capabilities (`templatePrimitives` / `acceptsTemplateCall`)
@@ -253,6 +260,7 @@ export function computeInlinability(
253
260
  signalSetters,
254
261
  memoNames,
255
262
  env,
263
+ getterAliases,
256
264
  )
257
265
  constants.set(c.name, status)
258
266
  decisionsByName.set(c.name, decisions)
@@ -267,7 +275,7 @@ export function computeInlinability(
267
275
  // transformation. The map lives on `ClientJsContext` — not on the
268
276
  // cross-adapter `ConstantInfo` IR — so SSR adapters don't see CSR
269
277
  // substitution semantics they have no use for.
270
- populateCsrInlinable(ctx, env)
278
+ populateCsrInlinable(ctx, env, getterAliases)
271
279
 
272
280
  return { constants, functions, decisionsByName, templateRiskyNames }
273
281
  }
@@ -289,20 +297,26 @@ export function computeInlinability(
289
297
  * - the value contains an arrow / function expression (identity per render)
290
298
  * - it's a system construct (`createContext`, `new WeakMap`)
291
299
  * - it's a JSX literal (handled by jsx-inline routing)
300
+ * - it's a bare alias-hop chain naming a signal/memo getter (`getterAliases`)
292
301
  * - the substituted form fails `isInlinableInTemplate` (would re-execute
293
302
  * a non-pure call at template-eval time — #1138)
294
303
  */
295
- function populateCsrInlinable(ctx: ClientJsContext, relocateEnv: RelocateEnv): void {
304
+ function populateCsrInlinable(ctx: ClientJsContext, relocateEnv: RelocateEnv, getterAliases: ReadonlyMap<string, string>): void {
296
305
  if (ctx.localConstants.length === 0) return
297
306
 
298
- // Base env: signal getters + memo calls in raw (props.X) form. We
299
- // keep the substitution map raw so the post-substitution
300
- // `isInlinableInTemplate` check sees bridged prop references (the
301
- // form `useYjs(props.X)`) and rejects them via
302
- // `hasCallWithBridgedArg` (#1138). The final `_p.X` rewrite happens
307
+ // Base env: signal getters + memo calls in raw (props.X) form, PLUS
308
+ // `getterAliases`'s call-kind entries (`buildSignalMemoEnv`'s own
309
+ // `localConstants` param, #2778) the same call-kind entry an
310
+ // alias's origin already has, so `items__alias()` substitutes exactly
311
+ // like `items()` rather than through this function's generic
312
+ // identifier-inlining path below (see the pre-mark loop's comment for
313
+ // why that path must never see these names). We keep the substitution
314
+ // map raw so the post-substitution `isInlinableInTemplate` check sees
315
+ // bridged prop references (the form `useYjs(props.X)`) and rejects them
316
+ // via `hasCallWithBridgedArg` (#1138). The final `_p.X` rewrite happens
303
317
  // when the template emitter calls `csrSubstitute` on the IR text —
304
318
  // by then it's safe because we already know the form is inline-safe.
305
- const baseEnv = buildSignalMemoEnv(ctx.signals, ctx.memos, ctx.propsObjectName)
319
+ const baseEnv = buildSignalMemoEnv(ctx.signals, ctx.memos, ctx.propsObjectName, ctx.localConstants)
306
320
  const constSubs = new Map<string, CsrSubstitution>()
307
321
  const finalised = new Set<string>()
308
322
 
@@ -314,8 +328,18 @@ function populateCsrInlinable(ctx: ClientJsContext, relocateEnv: RelocateEnv): v
314
328
  // Pre-mark consts that are structurally ineligible — they record
315
329
  // `null` in `ctx.csrInlinable`. Mirrors `classifyConstantInitial`
316
330
  // for the kinds that have no value to substitute.
331
+ //
332
+ // A `getterAliases` name is pre-marked `null` here too — NOT because
333
+ // it can't be substituted (it already IS, as a call-kind entry in
334
+ // `baseEnv` above) but because this function's generic path below
335
+ // would otherwise resolve it through `isInlinableInTemplate`, whose
336
+ // signal-getter FALLBACK verdict (`rewrittenAs: 'undefined'`, meant
337
+ // for a getter referenced bare and uncalled) would freeze in as an
338
+ // identifier-kind override that wins over `baseEnv`'s correct
339
+ // call-kind entry (`buildEnvWithConsts`'s Map spread puts `constSubs`
340
+ // last) — the literal `(undefined)()` this issue is about (#2778).
317
341
  for (const c of ctx.localConstants) {
318
- if (c.isJsx || !c.value || c.containsArrow || c.systemConstructKind) {
342
+ if (c.isJsx || !c.value || c.containsArrow || c.systemConstructKind || getterAliases.has(c.name)) {
319
343
  ctx.csrInlinable.set(c.name, null)
320
344
  finalised.add(c.name)
321
345
  }
@@ -371,7 +395,7 @@ function populateCsrInlinable(ctx: ClientJsContext, relocateEnv: RelocateEnv): v
371
395
  // stay out of the template lambda. The relocate output
372
396
  // (`rewrittenValue`) is the bridged form (bare destructured prop
373
397
  // refs lifted to `_p.X`); we store that so the emit-time
374
- // `applyPropsRewrite` doesn't need to redo the AST work. The
398
+ // `rewritePropsObjectRef` doesn't need to redo the AST work. The
375
399
  // free identifiers are re-extracted from the bridged form so
376
400
  // the post-substitution unsafe-name check stays exact.
377
401
  const inlineResult = isInlinableInTemplate(rewritten, relocateEnv)
@@ -598,11 +622,22 @@ function classifyConstantInitial(
598
622
  signalSetters: Set<string>,
599
623
  memoNames: Set<string>,
600
624
  env: RelocateEnv,
625
+ getterAliases: ReadonlyMap<string, string>,
601
626
  ): { status: ConstantInlinability; decisions: RelocateDecision[] } {
602
627
  if (c.isJsx) return { status: { kind: 'jsx-inline' }, decisions: [] }
603
628
  if (!c.value) return { status: { kind: 'placeholder-let' }, decisions: [] }
604
629
  if (c.containsArrow) return { status: { kind: 'arrow-literal' }, decisions: [] }
605
630
  if (c.systemConstructKind) return { status: { kind: 'system-construct' }, decisions: [] }
631
+ // A multi-hop alias chain to a getter (`const a = items; const b = a`)
632
+ // has `b`'s OWN free id as `a` — an `init-local`, not a getter — so the
633
+ // direct free-id check just below only catches the one-hop case. Without
634
+ // this, `b` falls through to the stage-2 `isInlinableInTemplate` check,
635
+ // which resolves `a` to `init-local fallback` and (since `b` is
636
+ // template-risky) turns a now-correctly-substituted reference into a
637
+ // spurious BF061 error (#2778). `getterAliases` already walked the full
638
+ // chain, so checking it here makes every hop agree with the one-hop case
639
+ // without re-deriving the walk.
640
+ if (getterAliases.has(c.name)) return { status: { kind: 'reactive-read' }, decisions: [] }
606
641
 
607
642
  const freeIds = c.freeIdentifiers
608
643
  if (freeIds) {
@@ -26,9 +26,10 @@ import {
26
26
  isTextOnlyConditional,
27
27
  buildChildRefBindings,
28
28
  } from '../shared.ts'
29
- import { irChildrenToJsExpr } from '../../html-template.ts'
29
+ import { irChildrenToJsExpr, renderPreamble } from '../../html-template.ts'
30
30
  import { buildReactiveEffectsPlan } from './build-reactive-effects.ts'
31
31
  import type { ComponentLoopPlan, NestedComponentInit } from './types.ts'
32
+ import { internalInvariant } from '../../../errors.ts'
32
33
 
33
34
  /** @internal — prefer `buildLoopPlan`. */
34
35
  export function buildComponentLoopPlan(elem: TopLevelLoop, profileComponentName?: string): ComponentLoopPlan {
@@ -37,6 +38,22 @@ export function buildComponentLoopPlan(elem: TopLevelLoop, profileComponentName?
37
38
  const keyExpr = wrapLoopParamAsAccessor(elem.key || '__idx', elem.param, elem.paramBindings)
38
39
  const { head: paramHead, unwrap: paramUnwrap } = destructureLoopParam(elem.param, elem.paramBindings)
39
40
 
41
+ // A component-root loop's preamble is JS-only by construction: Phase 1
42
+ // (jsx-to-ir.ts) already refuses and strips any preamble with a JSX leaf
43
+ // (`builderNames.length > 0`) on this shape, because a lowered HTML-string
44
+ // leaf passed as a prop would diverge from SSR's real JSX elements — see
45
+ // `rowConstruction: 'dom-ops'` below. `renderLeaf` is therefore never
46
+ // reachable here; if it ever fires, a Phase-1 refusal for the new shape
47
+ // that let a JSX-bearing preamble through is missing, not this line.
48
+ const mapPreambleWrapped = elem.preamble
49
+ ? renderPreamble(elem.preamble, {
50
+ transformJs: text => wrapLoopParamAsAccessor(text, elem.param, elem.paramBindings),
51
+ renderLeaf: () => {
52
+ internalInvariant(false, 'component-root loop received a JSX-bearing preamble — Phase 1 should have refused it')
53
+ },
54
+ })
55
+ : ''
56
+
40
57
  // Only init components at loopDepth 0 — inner-loop components are handled by their own loop
41
58
  const outerNestedComps = (elem.nestedComponents ?? []).filter(c => !c.loopDepth)
42
59
  const nestedComps: NestedComponentInit[] = outerNestedComps.map(comp => {
@@ -64,6 +81,7 @@ export function buildComponentLoopPlan(elem: TopLevelLoop, profileComponentName?
64
81
  // prop would diverge from SSR (which passes real JSX elements). The plan
65
82
  // dispatcher refuses a JSX-bearing preamble on this variant.
66
83
  rowConstruction: 'dom-ops',
84
+ mapPreambleWrapped,
67
85
  containerVar: `_${varSlotId(elem.slotId)}`,
68
86
  markerId: elem.markerId,
69
87
  arrayExpr: buildChainedArrayExpr(elem),
@@ -77,6 +77,8 @@ interface DynamicLoopCommon extends LoopPlanCommon {
77
77
  paramHead: string
78
78
  /** Statement to unwrap a destructured param at body entry. Empty when not needed. */
79
79
  paramUnwrap: string
80
+ /** Pre-render preamble line (already wrapped with loop param accessor). Empty when none. */
81
+ mapPreambleWrapped: string
80
82
  }
81
83
 
82
84
  /**
@@ -117,8 +119,6 @@ export interface LoopChildRefBinding {
117
119
  */
118
120
  interface PlainLoopVariant extends DynamicLoopCommon {
119
121
  kind: 'plain'
120
- /** Pre-render preamble line (already wrapped with loop param accessor). Empty when none. */
121
- mapPreambleWrapped: string
122
122
  /** HTML template string for one item. */
123
123
  template: string
124
124
  /**
@@ -225,8 +225,6 @@ interface ComponentLoopVariant extends DynamicLoopCommon {
225
225
  */
226
226
  interface CompositeLoopVariant extends DynamicLoopCommon {
227
227
  kind: 'composite'
228
- /** Wrapped mapPreamble line, hoisted before the SSR/CSR split. Empty when none. */
229
- mapPreambleWrapped: string
230
228
  /** Inner template HTML for the loop body (single item). */
231
229
  template: string
232
230
  /** Outer-level child components (depth 0), with `insideConditional` ones already filtered out. */
@@ -16,7 +16,7 @@ import type { LoopChildEvent, LoopChildRef, TopLevelLoop, NestedLoop, CollectedL
16
16
  import type { IRLoopChildComponent, LoopParamBinding } from '../../types.ts'
17
17
  import { preambleAnalysisText } from '../../types.ts'
18
18
  import { quotePropName, wrapLoopParamAsAccessor, irChildrenFreeIds, attrValueToString } from '../utils.ts'
19
- import { irChildrenToJsExpr } from '../html-template.ts'
19
+ import { irChildrenToJsExpr, jsxChildrenPropGetterExpr } from '../html-template.ts'
20
20
  import { emitListenerBlock } from './stringify/event-listener.ts'
21
21
  import { nameForRegistryRef } from '../component-scope.ts'
22
22
  import { BF_SCOPE, BF_HOST, BF_AT } from '@barefootjs/shared'
@@ -285,7 +285,13 @@ export function buildComponentPropsExpr(
285
285
  case 'boolean-attr':
286
286
  return `get ${quotePropName(p.name)}() { return true }`
287
287
  case 'jsx-children': {
288
- const childExpr = irChildrenToJsExpr(p.value.children)
288
+ // Brand via the shared door (#2651/#2702) — see
289
+ // `jsxChildrenPropGetterExpr`'s docstring. This builder is shared
290
+ // by loop and conditional-branch component reconciliation, so a
291
+ // named jsx-children prop on a component instantiated in either
292
+ // context used to reach its getter unbranded (pullfrog review, PR
293
+ // #2820).
294
+ const childExpr = jsxChildrenPropGetterExpr(p.value.children, p.name)
289
295
  return `get ${quotePropName(p.name)}() { return ${wrap(childExpr)} }`
290
296
  }
291
297
  case 'expression':
@@ -360,9 +366,10 @@ function emitEventSetup(
360
366
  * e.g. `qsa(__el, ${buildCompSelector(c)})`.
361
367
  *
362
368
  * - With `slotId`: primary `(bf-h, bf-m)` against the enclosing scope's
363
- * `__scopeId`, with a `[bf-s$="_<slot>"]` fallback for `renderChild`
364
- * paths that stamp only the parent-anchored bf-s. See `spec/compiler.md`
365
- * "Slot identity".
369
+ * `__scopeId`, with a `[bf-s$="_<slot>"]` fallback for a DSL adapter's SSR
370
+ * output that doesn't stamp `(bf-h, bf-m)` on this shape (CSR's own
371
+ * `renderChild` stamps both, loop item root included, since #2833). See
372
+ * `spec/compiler.md` "Slot identity".
366
373
  * - Without `slotId`: `bf-s^="<Name>_"` name-prefix for top-level lookup.
367
374
  */
368
375
  export function buildCompSelector(comp: { slotId?: string | null; name: string }): string {
@@ -49,11 +49,18 @@ export function stringifyComponentLoop(lines: string[], plan: ComponentLoopPlan)
49
49
  nestedComps,
50
50
  childConditionalEffects,
51
51
  profileLoopId,
52
+ mapPreambleWrapped,
52
53
  } = plan
53
54
 
54
55
  const loopBfId = profileLoopId ? `, ${JSON.stringify(profileLoopId)}` : ''
55
56
  lines.push(` mapArray(() => ${arrayExpr}, ${containerVar}, ${keyFn}, (${paramHead}, ${indexParam}, __existing) => {`)
56
57
  if (paramUnwrap) lines.push(` ${paramUnwrap}`)
58
+ // The preamble's consts are referenced by componentPropsExpr's getters
59
+ // below, in BOTH the `initChild` (hydration-reuse) and `createComponent`
60
+ // (fresh row) branches — a row rebuild and a hydration-reused row must
61
+ // read the same preamble-computed value, so it can't be hoisted into only
62
+ // one branch.
63
+ if (mapPreambleWrapped) lines.push(` ${mapPreambleWrapped}`)
57
64
 
58
65
  const scopedComp = nameForRegistryRef(componentName)
59
66
 
@@ -421,7 +421,12 @@ export function stringifyStaticLoop(lines: string[], plan: StaticLoopPlan): void
421
421
  // insert them in order. `__iterEl` is the first root (the one reactive
422
422
  // bindings attach to); the rest land alongside it via insertBefore.
423
423
  lines.push(` const __mtpl = document.createElement('template')`)
424
- lines.push(` __mtpl.innerHTML = \`${itemHtml}\``)
424
+ // Materialize runs during init, after the parent's own template
425
+ // evaluation (and its ambient parent-scope id) has already unwound —
426
+ // `withParentScope` re-establishes it just for this row's HTML so
427
+ // `renderChild` inside it stamps `bf-h` with the same `__scopeId` the
428
+ // static init's own selector interpolates (#2833).
429
+ lines.push(` __mtpl.innerHTML = withParentScope(__scopeId, () => \`${itemHtml}\`)`)
425
430
  lines.push(` const __anchor = ${containerVar}.children[${childIndexExpr}] ?? null`)
426
431
  lines.push(` let __first = null`)
427
432
  lines.push(` let __sib = __mtpl.content${childPath}.firstElementChild`)
@@ -435,7 +440,8 @@ export function stringifyStaticLoop(lines: string[], plan: StaticLoopPlan): void
435
440
  lines.push(` __iterEl = __first`)
436
441
  } else {
437
442
  lines.push(` const __tpl = document.createElement('template')`)
438
- lines.push(` __tpl.innerHTML = \`${itemHtml}\``)
443
+ // See the multi-root branch's comment above (#2833).
444
+ lines.push(` __tpl.innerHTML = withParentScope(__scopeId, () => \`${itemHtml}\`)`)
439
445
  lines.push(` const __cloned = __tpl.content${childPath}`)
440
446
  lines.push(` if (__cloned) {`)
441
447
  lines.push(` const __anchor = ${containerVar}.children[${childIndexExpr}] ?? null`)
@@ -86,6 +86,18 @@ export function emitLoopUpdates(lines: string[], ctx: ClientJsContext, unsafeLoc
86
86
  !(elem.preamble && elem.preamble.builderNames.length > 0 && plan.rowConstruction === 'dom-ops'),
87
87
  `loop variant '${plan.kind}' declares dom-ops row construction but received a JSX-bearing preamble — add a Phase-1 refusal (or wire renderPreamble support) for this shape`,
88
88
  )
89
+ // #2797's hole, generalized: a JS-only preamble (no JSX leaf, so the
90
+ // check above doesn't fire) can still be silently dropped if a variant
91
+ // just never reads `elem.preamble` at all — which is exactly what
92
+ // happened to the 'component' variant before it grew
93
+ // `mapPreambleWrapped`. A variant that doesn't declare that field can't
94
+ // be checked here (nothing to read), so this only catches a variant
95
+ // that declares it but leaves it unpopulated for a preamble that has
96
+ // names to declare.
97
+ internalInvariant(
98
+ !(elem.preamble && elem.preamble.declaredNames.length > 0 && 'mapPreambleWrapped' in plan && plan.mapPreambleWrapped === ''),
99
+ `loop variant '${plan.kind}' has a preamble with declared names (${elem.preamble?.declaredNames.join(', ')}) but its plan's mapPreambleWrapped is empty — wire it up or the emitted call site references a name nothing declares`,
100
+ )
89
101
  stringifyLoop(lines, plan)
90
102
  emitLoopEventDelegation(lines, elem, plan.kind, ctx.profile ? ctx.componentName : undefined)
91
103
  }
@@ -25,10 +25,11 @@
25
25
  */
26
26
 
27
27
  import ts from 'typescript'
28
- import { PROPS_PARAM, inferDefaultValue } from './utils.ts'
28
+ import { inferDefaultValue } from './utils.ts'
29
29
  import { extractFreeIdentifiersFromNode } from '../analyzer.ts'
30
- import type { MemoInfo, SignalInfo } from '../types.ts'
30
+ import type { ConstantInfo, MemoInfo, SignalInfo } from '../types.ts'
31
31
  import type { BindingScope } from '../scope/binding-scope.ts'
32
+ import { resolveAliasOrigin } from '../props-binding.ts'
32
33
 
33
34
  /**
34
35
  * CSR-substituted const value: the const's initializer with every
@@ -321,23 +322,34 @@ function csrSubstituteOnce(
321
322
  // lets `isInlinableInTemplate` see bridged prop references and
322
323
  // reject calls like `useYjs(props.X)` from the inline path (#1138).
323
324
  // Callers that want the emit-form (`_p.X`) apply the rewrite via
324
- // `applyPropsRewrite` after the inline-safety check.
325
+ // `rewritePropsObjectRef` (rewrite-props-object.ts) after the
326
+ // inline-safety check — an AST walk, not a regex, so it survives a
327
+ // parenthesised receiver (`(props).label`, produced when `props`
328
+ // itself was just constant-inlined under a local alias) and skips
329
+ // non-value positions a regex can't distinguish (#2737).
325
330
 
326
331
  // Compute free identifiers of the rewritten form by re-parsing. Avoids
327
332
  // having to track analytically through nested substitutions, which is
328
333
  // exactly the bookkeeping that made the legacy code drift from emission.
329
- return { rewritten, freeIdentifiers: extractFreeIdentifiersFromText(rewritten) }
330
- }
331
-
332
- /**
333
- * Apply the source-level `propsObjectName.X _p.X` rewrite. Runs as
334
- * a separate step (not inside `csrSubstitute`) so the substitution
335
- * output stays in raw form long enough for `isInlinableInTemplate`
336
- * to detect bridged-arg calls (#1138).
337
- */
338
- export function applyPropsRewrite(text: string, propsObjectName: string | null): string {
339
- if (!propsObjectName || propsObjectName === PROPS_PARAM) return text
340
- return text.replace(new RegExp(`\\b${propsObjectName}\\.`, 'g'), `${PROPS_PARAM}.`)
334
+ //
335
+ // `extractFreeIdentifiersFromText` only sees `rewritten` in isolation —
336
+ // it has no way to know about `enclosingScope`, the loop-row bindings
337
+ // introduced OUTSIDE this expression's own AST (`isBound`'s second half,
338
+ // above). Without filtering by it too, a name legitimately shadowed by
339
+ // the enclosing loop row (never substituted, since `isBound` already
340
+ // skipped it during `visit`) still comes back as "free" here and trips
341
+ // the caller's `unsafeLocalNames` check the exact same identifier the
342
+ // substitution step correctly left alone gets the UNSAFE sentinel
343
+ // anyway. Filtering by the same `enclosingScope.isBound` predicate keeps
344
+ // the two checks in agreement, matching this function's own docstring
345
+ // contract above (#2814 review).
346
+ const freeIdentifiers = extractFreeIdentifiersFromText(rewritten)
347
+ if (enclosingScope) {
348
+ for (const name of freeIdentifiers) {
349
+ if (enclosingScope.isBound(name)) freeIdentifiers.delete(name)
350
+ }
351
+ }
352
+ return { rewritten, freeIdentifiers }
341
353
  }
342
354
 
343
355
  export function extractFreeIdentifiersFromText(text: string): Set<string> {
@@ -430,6 +442,50 @@ export function extractMemoBodyExpr(computation: string): string {
430
442
  return `(() => ${body})()`
431
443
  }
432
444
 
445
+ /**
446
+ * Which local consts are bare alias-hop chains that ultimately name a
447
+ * substitutable signal/memo getter (`isGetter`) — e.g. `const
448
+ * items__alias = items` where `items` is a signal getter. Returns
449
+ * alias name → origin getter name (the alias itself is never a key of
450
+ * its own map, so `origin !== alias` always holds for entries returned).
451
+ *
452
+ * A bare identifier alias of a getter is NOT an "inlinable const" in the
453
+ * ordinary sense (#2778): `populateCsrInlinable`'s generic path resolves
454
+ * a const's initializer through `csrSubstitute`/relocate, and `items`
455
+ * used bare (not called) is a `signal-getter` there, which relocate can
456
+ * only answer with its own `undefined` FALLBACK (correct when `items`
457
+ * itself appears bare, wrong once frozen as `items__alias`'s permanent
458
+ * value — a wrong substitution, not a missing one, so the #2468 scope
459
+ * gate can't see it either since `undefined` is a real in-scope global).
460
+ * Resolving the alias to its origin BEFORE that generic path runs, and
461
+ * registering it as the origin's own call-kind entry, means
462
+ * `items__alias()` is substituted by the exact mechanism that already
463
+ * substitutes `items()` correctly — one call-kind entry, not a second
464
+ * "identifier that means undefined-unless-called" special case.
465
+ *
466
+ * Reuses `resolveAliasOrigin` (`props-binding.ts`) — the same hop-walker
467
+ * `resolveRestSpreadOriginCore` uses for the props/rest alias question —
468
+ * rather than a second alias-chain walker for this different terminal
469
+ * set (a signal/memo NAME here, `restPropsName`/`propsObjectName` there).
470
+ */
471
+ export function resolveGetterAliases(
472
+ localConstants: readonly ConstantInfo[],
473
+ isGetter: (name: string) => boolean,
474
+ ): Map<string, string> {
475
+ const constantValues = new Map<string, string | undefined>()
476
+ for (const c of localConstants) {
477
+ if (c.isModule) continue
478
+ constantValues.set(c.name, c.value)
479
+ }
480
+ const aliases = new Map<string, string>()
481
+ for (const c of localConstants) {
482
+ if (c.isModule || isGetter(c.name)) continue
483
+ const origin = resolveAliasOrigin(constantValues, c.name, (current) => (isGetter(current) ? current : null))
484
+ if (origin !== null && origin !== c.name) aliases.set(c.name, origin)
485
+ }
486
+ return aliases
487
+ }
488
+
433
489
  /**
434
490
  * Build the CSR substitution env from the live `ClientJsContext`.
435
491
  *
@@ -442,11 +498,18 @@ export function extractMemoBodyExpr(computation: string): string {
442
498
  * in `compute-inlinability.ts`). Inlinable-const substitutions are
443
499
  * layered into a copy of this env at `generateCsrTemplate`'s entry,
444
500
  * reading from `ClientJsContext.csrInlinable`.
501
+ *
502
+ * `localConstants` is consulted ONLY to resolve bare getter aliases
503
+ * (`resolveGetterAliases`, #2778) — a name that is itself a signal/memo
504
+ * getter always wins the `substitutions.has` check there, so `signals`/
505
+ * `memos` stay the single source of truth for what a getter name means;
506
+ * this just extends which SOURCE NAMES reach that same meaning.
445
507
  */
446
508
  export function buildSignalMemoEnv(
447
509
  signals: readonly SignalInfo[],
448
510
  memos: readonly MemoInfo[],
449
511
  propsObjectName: string | null,
512
+ localConstants: readonly ConstantInfo[] = [],
450
513
  ): CsrEnv {
451
514
  const substitutions = new Map<string, CsrSubstitution>()
452
515
  for (const s of signals) {
@@ -474,6 +537,9 @@ export function buildSignalMemoEnv(
474
537
  freeIdentifiers: m.computationFreeIdentifiers ?? new Set(),
475
538
  })
476
539
  }
540
+ for (const [alias, origin] of resolveGetterAliases(localConstants, (n) => substitutions.has(n))) {
541
+ substitutions.set(alias, substitutions.get(origin)!)
542
+ }
477
543
  return { substitutions, propsObjectName }
478
544
  }
479
545
 
@@ -481,7 +547,7 @@ export function buildSignalMemoEnv(
481
547
  * Add the `?? <default>` SSR fallback to a signal's initial value when
482
548
  * the value is a bare `propsName.X` reference. Returns the value in raw
483
549
  * (props.X) form — the `propsObjectName → _p` rewrite is deferred to
484
- * `applyPropsRewrite` at emit time so the post-substitution
550
+ * `rewritePropsObjectRef` at emit time so the post-substitution
485
551
  * `isInlinableInTemplate` check still sees bridged-arg shapes (#1138).
486
552
  *
487
553
  * The `??` fallback prevents literal `undefined` from leaking into the