@barefootjs/jsx 0.33.4 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/adapters/child-scope.d.ts +13 -0
  2. package/dist/adapters/child-scope.d.ts.map +1 -1
  3. package/dist/adapters/parsed-expr-emitter.d.ts +17 -0
  4. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/errors.d.ts +1 -0
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/expression-parser.d.ts +17 -4
  9. package/dist/expression-parser.d.ts.map +1 -1
  10. package/dist/index.d.ts +4 -3
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1399 -1173
  13. package/dist/ir-to-client-js/child-components.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/component-scope.d.ts +20 -0
  16. package/dist/ir-to-client-js/component-scope.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/compute-inlinability.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/shared.d.ts +4 -3
  19. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/csr-substitute.d.ts +35 -9
  22. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/emit-reactive.d.ts +10 -0
  24. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/html-template.d.ts +49 -2
  26. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/imports.d.ts +2 -2
  28. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/rewrite-props-object.d.ts +12 -1
  31. package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
  32. package/dist/jsx-to-ir.d.ts.map +1 -1
  33. package/dist/lowering-registry.d.ts +12 -0
  34. package/dist/lowering-registry.d.ts.map +1 -1
  35. package/dist/prop-rewrite.d.ts +5 -2
  36. package/dist/prop-rewrite.d.ts.map +1 -1
  37. package/dist/props-binding.d.ts +49 -11
  38. package/dist/props-binding.d.ts.map +1 -1
  39. package/dist/types.d.ts +6 -5
  40. package/dist/types.d.ts.map +1 -1
  41. package/package.json +2 -2
  42. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +15 -15
  43. package/src/__tests__/aliased-import-child-registry-2777.test.ts +112 -0
  44. package/src/__tests__/date-lowering.test.ts +18 -4
  45. package/src/__tests__/inner-loop-mathml-namespace.test.ts +1 -1
  46. package/src/__tests__/inner-loop-svg-namespace.test.ts +4 -4
  47. package/src/__tests__/issue-2724-prop-alias-loop-not-static.test.ts +355 -0
  48. package/src/__tests__/issue-2737-props-object-alias-template.test.ts +86 -0
  49. package/src/__tests__/issue-2756-input-skeleton-bails.test.ts +123 -0
  50. package/src/__tests__/issue-2778-getter-alias-csr-template.test.ts +113 -0
  51. package/src/__tests__/issue-2806-rest-bag-template-rewrite.test.ts +136 -0
  52. package/src/__tests__/issue-2835-props-type-member-name-collision.test.ts +259 -0
  53. package/src/__tests__/loop-item-root-scope.test.ts +7 -3
  54. package/src/__tests__/lowering-value-position.test.ts +232 -0
  55. package/src/__tests__/markup-prop-brand.test.ts +165 -26
  56. package/src/__tests__/namespace-import-primitive.test.ts +244 -0
  57. package/src/__tests__/rewrite-destructured-props.test.ts +53 -0
  58. package/src/__tests__/static-loop-csr-materialize.test.ts +2 -2
  59. package/src/adapters/child-scope.ts +23 -0
  60. package/src/adapters/parsed-expr-emitter.ts +43 -0
  61. package/src/analyzer.ts +130 -0
  62. package/src/errors.ts +14 -1
  63. package/src/expression-parser.ts +63 -29
  64. package/src/index.ts +9 -1
  65. package/src/ir-to-client-js/child-components.ts +8 -1
  66. package/src/ir-to-client-js/collect-elements.ts +10 -38
  67. package/src/ir-to-client-js/component-scope.ts +60 -0
  68. package/src/ir-to-client-js/compute-inlinability.ts +46 -11
  69. package/src/ir-to-client-js/control-flow/shared.ts +12 -5
  70. package/src/ir-to-client-js/control-flow/stringify/loop.ts +8 -2
  71. package/src/ir-to-client-js/csr-substitute.ts +82 -16
  72. package/src/ir-to-client-js/emit-reactive.ts +29 -13
  73. package/src/ir-to-client-js/emit-registration.ts +1 -1
  74. package/src/ir-to-client-js/html-template.ts +141 -70
  75. package/src/ir-to-client-js/imports.ts +4 -0
  76. package/src/ir-to-client-js/index.ts +34 -23
  77. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +7 -2
  78. package/src/ir-to-client-js/rewrite-props-object.ts +14 -0
  79. package/src/jsx-to-ir.ts +200 -42
  80. package/src/lowering-registry.ts +21 -0
  81. package/src/prop-rewrite.ts +23 -5
  82. package/src/props-binding.ts +74 -18
  83. package/src/types.ts +6 -5
@@ -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) {
@@ -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 {
@@ -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`)
@@ -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
@@ -15,6 +15,7 @@ import { datePlugin, DATE_METHODS } from '../date-lowering.ts'
15
15
  import { toLocaleDatePlugin, foldedArgToClientJs } from '../to-locale-date-lowering.ts'
16
16
  import { tsNodeToParsedExpr } from '../expression-parser.ts'
17
17
  import type { LoweringMatcher } from '../lowering-registry.ts'
18
+ import { rewriteBarePropRefs } from '../prop-rewrite.ts'
18
19
 
19
20
  /**
20
21
  * Profile mode (#1690, SR3/SR4): the id appended to a DOM-binding effect so the
@@ -127,28 +128,43 @@ export function emitAttrUpdate(target: string, attrName: string, expression: str
127
128
  /**
128
129
  * Rewrite destructured prop names in an expression to `(props.xxx ?? default)`.
129
130
  * Only applies when the component uses destructured props (not props.xxx style).
131
+ *
132
+ * Delegates the actual walk to `rewriteBarePropRefs` (`prop-rewrite.ts`) — the
133
+ * one AST walk in the compiler that already distinguishes a VALUE reference
134
+ * from an object-literal key, a property-access name, a shorthand property,
135
+ * or a name shadowed by an inner binding (`items.map((tag) => tag.x)`).
136
+ * This function used to run its own `\btag\b`-style regex over the raw
137
+ * expression text with no such distinction, so `queryHref(base, { tag: tag })`
138
+ * rewrote the KEY too (`{ _p.tag: _p.tag }`, a syntax error) — #2741. Only
139
+ * the default-value `(_p.x ?? <default>)` wrapping is specific to this call
140
+ * site, supplied via `replacementFor` rather than a second copy of the walk.
130
141
  */
131
142
  export function rewriteDestructuredPropsInExpr(expr: string, ctx: ClientJsContext): string {
132
143
  if (ctx.propsObjectName) return expr
133
144
 
134
- const { protect, restore } = createTemplateAwareStringProtector()
135
- let result = protect(expr)
136
-
145
+ const propNames = new Set<string>()
146
+ const propAliases = new Map<string, string>()
147
+ const propsByName = new Map<string, ClientJsContext['propsParams'][number]>()
137
148
  for (const prop of ctx.propsParams) {
138
149
  if (prop.name === 'children') continue
139
- const pattern = new RegExp(`(?<![-.])\\b${prop.name}\\b`, 'g')
140
- if (!pattern.test(result)) continue
141
-
150
+ propNames.add(prop.name)
151
+ propsByName.set(prop.name, prop)
142
152
  // `_p` is always keyed by the caller-facing name (#2524 CSR half).
143
- const callerKey = prop.sourceName ?? prop.name
144
- const defaultVal = prop.defaultValue
145
- const replacement = defaultVal
146
- ? `(${PROPS_PARAM}.${callerKey} ?? ${prop.defaultContainsArrow ? `(${defaultVal})` : defaultVal})`
147
- : `${PROPS_PARAM}.${callerKey}`
148
- result = result.replace(new RegExp(`(?<![-.])\\b${prop.name}\\b`, 'g'), replacement)
153
+ if (prop.sourceName && prop.sourceName !== prop.name) propAliases.set(prop.name, prop.sourceName)
149
154
  }
155
+ if (propNames.size === 0) return expr
150
156
 
151
- return restore(result)
157
+ const replacementFor = (localName: string, callerKey: string): string => {
158
+ const defaultVal = propsByName.get(localName)?.defaultValue
159
+ if (!defaultVal) return `${PROPS_PARAM}.${callerKey}`
160
+ const defaultContainsArrow = propsByName.get(localName)?.defaultContainsArrow
161
+ return `(${PROPS_PARAM}.${callerKey} ?? ${defaultContainsArrow ? `(${defaultVal})` : defaultVal})`
162
+ }
163
+
164
+ // Parenthesized so an object-literal or arrow-function expression parses
165
+ // standalone — same convention `applyScopedPropRefRewrite` itself uses.
166
+ const sourceFile = ts.createSourceFile('__bf_reactive_expr.ts', `(${expr}\n)`, ts.ScriptTarget.Latest, true)
167
+ return rewriteBarePropRefs(expr, sourceFile, propNames, undefined, propAliases, replacementFor) ?? expr
152
168
  }
153
169
 
154
170
  /**
@@ -223,7 +223,7 @@ export function emitRegistrationAndHydration(
223
223
  // (#2651) — see `generateCsrTemplate`'s identical derivation below
224
224
  // for why this is reused as-is rather than re-derived.
225
225
  const markupSlotIds = new Set(ctx.dynamicElements.map(e => e.slotId))
226
- const templateHtml = irToComponentTemplate(_ir.root, inlinableConstants, restSpreadNames, ctx.propsObjectName, markupSlotIds)
226
+ const templateHtml = irToComponentTemplate(_ir.root, inlinableConstants, restSpreadNames, ctx.propsObjectName, markupSlotIds, ctx.restPropsName)
227
227
  if (templateHtml) {
228
228
  defParts.push(buildTemplateDefPart(ctx, templateHtml))
229
229
  }