@barefootjs/jsx 0.34.0 → 0.35.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 (101) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/index.d.ts +2 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +344 -215
  5. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  7. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +11 -0
  10. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts +52 -0
  13. package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts.map +1 -0
  14. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +2 -0
  15. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +6 -7
  17. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +5 -7
  19. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +10 -6
  22. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/shared.d.ts +11 -9
  24. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/stringify/composite-loop.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/csr-substitute.d.ts +16 -0
  28. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/html-template.d.ts +2 -2
  32. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/imports.d.ts +2 -2
  34. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  36. package/dist/ir-to-client-js/markup-slots.d.ts +21 -0
  37. package/dist/ir-to-client-js/markup-slots.d.ts.map +1 -0
  38. package/dist/ir-to-client-js/safe-html.d.ts +158 -0
  39. package/dist/ir-to-client-js/safe-html.d.ts.map +1 -0
  40. package/dist/ir-to-client-js/utils.d.ts +34 -1
  41. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  42. package/dist/jsx-to-ir.d.ts.map +1 -1
  43. package/dist/prop-rewrite.d.ts +8 -3
  44. package/dist/prop-rewrite.d.ts.map +1 -1
  45. package/dist/props-binding.d.ts +22 -1
  46. package/dist/props-binding.d.ts.map +1 -1
  47. package/dist/ssr-defaults.d.ts.map +1 -1
  48. package/dist/types.d.ts +5 -10
  49. package/dist/types.d.ts.map +1 -1
  50. package/package.json +2 -2
  51. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +10 -12
  52. package/src/__tests__/build-plain-row-consolidation.test.ts +120 -0
  53. package/src/__tests__/client-template-escape-soundness.test.ts +538 -0
  54. package/src/__tests__/csr-template-loop-shadowing.test.ts +4 -2
  55. package/src/__tests__/flatmap-segments.test.ts +1 -1
  56. package/src/__tests__/lazy-conditional.test.ts +52 -12
  57. package/src/__tests__/lazy-preamble.test.ts +11 -11
  58. package/src/__tests__/lazy-row-eligibility.test.ts +8 -3
  59. package/src/__tests__/loop-child-reactive-attr-const-shadow.test.ts +25 -3
  60. package/src/__tests__/loop-item-conditional-codegen.test.ts +4 -2
  61. package/src/__tests__/map-arbitrary-body.test.ts +1 -1
  62. package/src/__tests__/nested-loop-index-param.test.ts +14 -3
  63. package/src/__tests__/nested-loop-reactive-attrs.test.ts +4 -2
  64. package/src/__tests__/reactive-attrs-in-map.test.ts +13 -10
  65. package/src/__tests__/rewrite-destructured-props.test.ts +37 -0
  66. package/src/__tests__/safe-html-splice-door.test.ts +61 -0
  67. package/src/__tests__/ssr-defaults.test.ts +59 -0
  68. package/src/analyzer.ts +4 -0
  69. package/src/index.ts +8 -2
  70. package/src/ir-to-client-js/collect-elements.ts +18 -2
  71. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +20 -28
  72. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +9 -7
  73. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +10 -6
  74. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +8 -8
  75. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +18 -2
  76. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +3 -3
  77. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +35 -35
  78. package/src/ir-to-client-js/control-flow/plan/build-plain-row.ts +110 -0
  79. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +5 -2
  80. package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +11 -10
  81. package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +11 -13
  82. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +13 -3
  83. package/src/ir-to-client-js/control-flow/plan/loop.ts +10 -6
  84. package/src/ir-to-client-js/control-flow/shared.ts +40 -17
  85. package/src/ir-to-client-js/control-flow/stringify/composite-loop.ts +2 -1
  86. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +10 -0
  87. package/src/ir-to-client-js/csr-substitute.ts +29 -0
  88. package/src/ir-to-client-js/emit-reactive.ts +4 -1
  89. package/src/ir-to-client-js/emit-registration.ts +2 -4
  90. package/src/ir-to-client-js/html-template.ts +58 -244
  91. package/src/ir-to-client-js/imports.ts +3 -0
  92. package/src/ir-to-client-js/index.ts +4 -4
  93. package/src/ir-to-client-js/markup-slots.ts +25 -0
  94. package/src/ir-to-client-js/safe-html.ts +243 -0
  95. package/src/ir-to-client-js/utils.ts +58 -10
  96. package/src/jsx-to-ir.ts +115 -20
  97. package/src/prop-rewrite.ts +8 -4
  98. package/src/props-binding.ts +37 -1
  99. package/src/ssr-defaults.ts +45 -0
  100. package/src/types.ts +5 -10
  101. package/src/__tests__/text-slot-escaping.test.ts +0 -65
@@ -68,9 +68,10 @@
68
68
  * markup, which is `preambleRegions` territory (refused separately).
69
69
  * - No `builderNames`. Same shape as above, pinned explicitly so the reason
70
70
  * names it rather than surfacing as a confusing "contains a call".
71
- * - The preamble does not read the loop's INDEX parameter. `applyItem` and
72
- * `applyOuter` have no index to give it the same reason
73
- * `ClassifiedLazyBinding.referencesIndex` refuses a binding.
71
+ * - (#2859 follow-up) The preamble MAY read the loop's index parameter it
72
+ * survives into `freeNames` below like any other read, so a binding that
73
+ * names this preamble local substitutes through to it and is classified
74
+ * the same way a direct index reference is (forced into `applyItem`).
74
75
  *
75
76
  * Member access is deliberately NOT banned even though a getter could in
76
77
  * principle run code: a binding expression like `class={row.cls}` is already
@@ -130,9 +131,6 @@ const NO = (reason: string): LazyPreambleAnalysis => ({ lazySafe: false, reason
130
131
  * Decide whether `preamble` may be re-executed inside a lazy row's apply
131
132
  * bodies, and if so what it declares and reads.
132
133
  *
133
- * `indexParam` is the loop's index parameter name as the emitter uses it
134
- * (`elem.index || '__idx'`); a preamble reading it is refused.
135
- *
136
134
  * `primableNames` are the component's signal getters and memos — the only
137
135
  * callees a preamble initializer may invoke (see the module docstring). Pass
138
136
  * the same names `LazyRowScopeInfo` carries, so "primable here" and "primable
@@ -140,7 +138,6 @@ const NO = (reason: string): LazyPreambleAnalysis => ({ lazySafe: false, reason
140
138
  */
141
139
  export function analyzeLazyPreamble(
142
140
  preamble: MapCallbackPreamble | undefined,
143
- indexParam: string,
144
141
  primableNames: ReadonlySet<string>,
145
142
  ): LazyPreambleAnalysis {
146
143
  if (!preamble) return NO_PREAMBLE
@@ -196,13 +193,14 @@ export function analyzeLazyPreamble(
196
193
  }
197
194
  }
198
195
 
199
- // The index parameter does not exist in `applyItem` / `applyOuter`, and
200
- // `createRow` receives it but the preamble must read the same values in
201
- // every body it runs in. Same reason `referencesIndex` refuses a binding.
196
+ // A preamble reading the loop index (#2859 follow-up, widening from a hard
197
+ // refusal) is no longer refused here: `indexParam` survives into
198
+ // `freeNames` below, and any binding that names this preamble local
199
+ // substitutes through to it (`classifyLazyBinding`'s preamble handling),
200
+ // which classifies a free `indexParam` the same way a direct reference
201
+ // would — forcing the binding into `applyItem` so a reorder re-evaluates
202
+ // it via `entry.index`.
202
203
  const readNames = extractFreeIdentifiersFromStatementText(text)
203
- if (readNames.has(indexParam) && !declaredNames.has(indexParam)) {
204
- return NO(`map-callback preamble reads the loop index parameter '${indexParam}'`)
205
- }
206
204
 
207
205
  const freeNames = new Set(readNames)
208
206
  for (const declared of declaredNames) freeNames.delete(declared)
@@ -284,9 +284,15 @@ export function lazyRowEligibility(args: LazyRowEligibilityArgs): LazyRowEligibi
284
284
 
285
285
  // Per-binding gates.
286
286
  for (const b of bindings) {
287
- if (b.referencesIndex) {
288
- return NO(`binding on slot ${b.slotId} references the loop index parameter`)
289
- }
287
+ // A binding that references the loop index (#2859 follow-up, widening
288
+ // from a hard refusal): `applyItem`/`applyOuter` have no index PARAMETER,
289
+ // but the reconciler tracks each entry's current position on `entry.index`
290
+ // (`LazyRowEntry.index`, `map-array-lazy.ts`) exactly like it tracks
291
+ // `entry.item` — a reorder is a position change the reconciler already
292
+ // detects, the same way an item change is. `classifyLazyBinding` forces
293
+ // `readsItem: true` for any binding with `referencesIndex`, so it is
294
+ // ALWAYS emitted into `applyItem`, and the runtime calls `applyItem` on a
295
+ // pure reorder too when `plan.indexDriven` (`LazyRowPlanData.readsIndex`).
290
296
  // An identifier set we could not compute at all is different in kind
291
297
  // from a name we CAN see but cannot prime. With `free === null` the
292
298
  // classifier knows nothing — not the outer reads, and not whether the
@@ -445,7 +451,11 @@ export function classifyLazyBinding(args: {
445
451
  return
446
452
  }
447
453
  if (name === indexParam) {
454
+ // The row's current position (#2859 follow-up) — the reconciler
455
+ // delivers it on `entry.index` exactly like it delivers the item on
456
+ // `entry.item`, so a binding that reads it is item-driven too.
448
457
  referencesIndex = true
458
+ readsItem = true
449
459
  return
450
460
  }
451
461
  if (INERT_BINDING_GLOBALS.has(name)) return
@@ -23,6 +23,7 @@ import type {
23
23
  TopLevelLoop,
24
24
  } from '../../types.ts'
25
25
  import type { IRLoopChildComponent } from '../../../types.ts'
26
+ import type { SafeHtml } from '../../safe-html.ts'
26
27
  import type { SkeletonSlotPaths } from '../../html-template.ts'
27
28
  import type { ReactiveEffectsPlan } from './reactive-effects.ts'
28
29
  import type { InnerLoopsPlan } from './inner-loop.ts'
@@ -83,15 +84,16 @@ interface DynamicLoopCommon extends LoopPlanCommon {
83
84
 
84
85
  /**
85
86
  * A resolved preamble-patched region (#2389), ready to emit: `valueExpr` is
86
- * already wrapped with the loop-param accessor and, for a `joinArrayChild`
87
- * source, already carries the array-join ternary — the exact value
88
- * `irToHtmlTemplate` computes for the same node in the row template, so a
89
- * same-key update's re-patch stays byte-identical to what a fresh mount
90
- * would render.
87
+ * already wrapped with the loop-param accessor and already went through the
88
+ * child-splice door (`safe-html.ts`) — the exact value `irToHtmlTemplate`
89
+ * computes for the same node in the row template, so a same-key update's
90
+ * re-patch stays byte-identical to what a fresh mount would render. Typed
91
+ * `SafeHtml` because the region writer is an `innerHTML` write: only a
92
+ * value the door (or a named producer) vouched for may reach it.
91
93
  */
92
94
  export interface PreambleRegionPlan {
93
95
  slotId: string
94
- valueExpr: string
96
+ valueExpr: SafeHtml
95
97
  }
96
98
 
97
99
  /** Per-item ref callback resolved against a child slot for emission (#1244). */
@@ -237,6 +239,8 @@ interface CompositeLoopVariant extends DynamicLoopCommon {
237
239
  loopParam: string
238
240
  /** Destructured-binding metadata for the loop param. */
239
241
  loopParamBindings: TopLevelLoop['paramBindings']
242
+ /** This loop's index param name, when it declares one (#2859). */
243
+ loopIndex: TopLevelLoop['index']
240
244
  /** Reactive effects rendered after the SSR/CSR split. */
241
245
  reactiveEffects: ReactiveEffectsPlan | null
242
246
  /**
@@ -17,6 +17,7 @@ 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
19
  import { irChildrenToJsExpr, jsxChildrenPropGetterExpr } from '../html-template.ts'
20
+ import { spliceChildValue } from '../safe-html.ts'
20
21
  import { emitListenerBlock } from './stringify/event-listener.ts'
21
22
  import { nameForRegistryRef } from '../component-scope.ts'
22
23
  import { BF_SCOPE, BF_HOST, BF_AT } from '@barefootjs/shared'
@@ -147,11 +148,12 @@ export function buildChildRefBindings(
147
148
  refs: readonly LoopChildRef[],
148
149
  loopParam: string,
149
150
  loopParamBindings: readonly LoopParamBinding[] | undefined,
151
+ loopIndex?: string | null,
150
152
  ): readonly LoopChildRefBinding[] {
151
153
  if (refs.length === 0) return []
152
154
  return refs.map(r => ({
153
155
  childSlotId: r.childSlotId,
154
- callback: wrapLoopParamAsAccessor(r.callback, loopParam, loopParamBindings),
156
+ callback: wrapLoopParamAsAccessor(r.callback, loopParam, loopParamBindings, loopIndex),
155
157
  }))
156
158
  }
157
159
 
@@ -241,23 +243,27 @@ export function destructureLoopParam(
241
243
  /**
242
244
  * Resolve `IRLoop.preambleRegions` (#2389) into ready-to-emit
243
245
  * `PreambleRegionPlan`s: wrap each source expression with the loop-param
244
- * accessor and, for a `joinArrayChild` source, apply the same array-join
245
- * ternary `irToHtmlTemplate` uses for the identical node in the row
246
- * template — so the region-patch effect's re-render matches what a fresh
247
- * mount would have produced. Shared by the top-level (`build-loop.ts`) and
248
- * branch (`build-branch-loop.ts`) plain-loop-plan builders.
246
+ * accessor and hand it to the same door (`spliceChildValue`) the row
247
+ * template's `irToHtmlTemplate` uses for the identical node so the
248
+ * region-patch effect's re-render (a `'markup'` claim writer, i.e. an
249
+ * `innerHTML` write) matches what a fresh mount would have produced, and
250
+ * the escape decision is made in exactly one place. Shared by the
251
+ * top-level (`build-loop.ts`) and branch (`build-branch-loop.ts`)
252
+ * plain-loop-plan builders.
249
253
  */
250
254
  export function buildPreambleRegionPlans(
251
255
  regions: readonly PreambleRegionSource[] | undefined,
252
256
  loopParam: string,
253
257
  loopParamBindings: readonly LoopParamBinding[] | undefined,
258
+ loopIndex?: string | null,
254
259
  ): readonly PreambleRegionPlan[] {
255
260
  if (!regions || regions.length === 0) return []
256
261
  return regions.map((r) => {
257
- const wrapped = wrapLoopParamAsAccessor(r.expr, loopParam, loopParamBindings)
258
- const valueExpr = r.joinArrayChild
259
- ? `Array.isArray(${wrapped}) ? ${wrapped}.join('') : (${wrapped} ?? '')`
260
- : `escapeText(${wrapped})`
262
+ const wrapped = wrapLoopParamAsAccessor(r.expr, loopParam, loopParamBindings, loopIndex)
263
+ // The region's row-template twin carries the same `slotId`, so the door
264
+ // takes the slotted arm; no `markupSlotIds` here a loop-row slot's
265
+ // initial render is plain `escapeText`, matching `irToHtmlTemplate`.
266
+ const valueExpr = spliceChildValue({ expr: r.expr, slotId: r.slotId, joinArrayChild: r.joinArrayChild }, wrapped, {})
261
267
  return { slotId: r.slotId, valueExpr }
262
268
  })
263
269
  }
@@ -270,8 +276,9 @@ export function buildComponentPropsExpr(
270
276
  comp: { props: Array<{ name: string; value: import('../../types.ts').AttrValue; isEventHandler: boolean }>, children?: import('../../types.ts').IRNode[] },
271
277
  loopParam?: string,
272
278
  loopParamBindings?: readonly LoopParamBinding[],
279
+ loopIndex?: string | null,
273
280
  ): string {
274
- const wrap = loopParam ? (expr: string) => wrapLoopParamAsAccessor(expr, loopParam, loopParamBindings) : (expr: string) => expr
281
+ const wrap = loopParam ? (expr: string) => wrapLoopParamAsAccessor(expr, loopParam, loopParamBindings, loopIndex) : (expr: string) => expr
275
282
  const entries = comp.props.map((p) => {
276
283
  if (p.isEventHandler) {
277
284
  const handlerExpr = attrValueToString(p.value) ?? 'undefined'
@@ -347,7 +354,20 @@ export function buildDepthLevels(
347
354
  }))
348
355
  }
349
356
 
350
- /** Emit a single addEventListener call for a child event on a given element. */
357
+ /**
358
+ * Emit a single addEventListener call for a child event on a given element.
359
+ *
360
+ * This is a DIRECT, one-time listener attach (composite/inner/conditional-arm
361
+ * loop shapes) — unlike the top-level delegated click dispatcher
362
+ * (`build-event-delegation.ts`), which re-derives the current index at click
363
+ * time via `arr.findIndex(...)` and is unaffected by this. A handler wired
364
+ * here closes over whatever `renderItem` bound its raw index parameter to at
365
+ * ROW CREATION time, so a reference to it needs the same accessor-call
366
+ * rewrite as every other loop-index read in this row (#2859) — otherwise a
367
+ * row that survives a same-key reorder keeps firing the handler with the
368
+ * stale, as-created index forever, the click-handler analogue of the
369
+ * render-output bug #2859 itself fixes.
370
+ */
351
371
  function emitEventSetup(
352
372
  ls: string[],
353
373
  indent: string,
@@ -356,8 +376,9 @@ function emitEventSetup(
356
376
  loopParam?: string,
357
377
  loopParamBindings?: readonly LoopParamBinding[],
358
378
  bodyIsMultiRoot: boolean = false,
379
+ loopIndex?: string | null,
359
380
  ): void {
360
- const handler = loopParam ? wrapLoopParamAsAccessor(ev.handler, loopParam, loopParamBindings) : ev.handler
381
+ const handler = loopParam ? wrapLoopParamAsAccessor(ev.handler, loopParam, loopParamBindings, loopIndex) : ev.handler
361
382
  emitListenerBlock(ls, indent, elVar, ev.childSlotId, '__e', ev.eventName, handler, 'dom', bodyIsMultiRoot)
362
383
  }
363
384
 
@@ -412,14 +433,15 @@ export function emitComponentAndEventSetup(
412
433
  loopParam?: string,
413
434
  loopParamBindings?: readonly LoopParamBinding[],
414
435
  bodyIsMultiRoot: boolean = false,
436
+ loopIndex?: string | null,
415
437
  ): void {
416
- const wrap = loopParam ? (expr: string) => wrapLoopParamAsAccessor(expr, loopParam, loopParamBindings) : (expr: string) => expr
438
+ const wrap = loopParam ? (expr: string) => wrapLoopParamAsAccessor(expr, loopParam, loopParamBindings, loopIndex) : (expr: string) => expr
417
439
  // Multi-root loop bodies (#1212) must search the item's sibling roots
418
440
  // and the pre-insertion `__bfExtras` stash; `upsertChildItem` wraps the
419
441
  // single-root `upsertChild` semantics in that walk.
420
442
  const upsertFn = bodyIsMultiRoot ? 'upsertChildItem' : 'upsertChild'
421
443
  for (const comp of comps) {
422
- const propsExpr = buildComponentPropsExpr(comp, loopParam, loopParamBindings)
444
+ const propsExpr = buildComponentPropsExpr(comp, loopParam, loopParamBindings, loopIndex)
423
445
  // Check if children are text-equivalent and reference the loop param — if so,
424
446
  // emit a createEffect to reactively update textContent when the signal changes.
425
447
  // Text-equivalent: expression, text, or conditional with text-only branches.
@@ -429,7 +451,8 @@ export function emitComponentAndEventSetup(
429
451
  const rawChildrenExpr = isTextOnly ? irChildrenToJsExpr(comp.children!) : null
430
452
  const childrenFreeIds = isTextOnly && comp.children ? irChildrenFreeIds(comp.children) : undefined
431
453
  const childrenRefsLoop = loopParam != null && rawChildrenExpr != null && childrenFreeIds != null
432
- && exprRefsLoopBinding(childrenFreeIds, { param: loopParam, paramBindings: loopParamBindings })
454
+ && (exprRefsLoopBinding(childrenFreeIds, { param: loopParam, paramBindings: loopParamBindings })
455
+ || (!!loopIndex && childrenFreeIds.has(loopIndex)))
433
456
 
434
457
  const slotIdLit = comp.slotId ? `'${comp.slotId}'` : 'null'
435
458
  const keyProp = comp.props.find(p => p.name === 'key')
@@ -449,7 +472,7 @@ export function emitComponentAndEventSetup(
449
472
  }
450
473
  }
451
474
  for (const ev of events) {
452
- emitEventSetup(ls, indent, elVar, ev, loopParam, loopParamBindings, bodyIsMultiRoot)
475
+ emitEventSetup(ls, indent, elVar, ev, loopParam, loopParamBindings, bodyIsMultiRoot, loopIndex)
453
476
  }
454
477
  }
455
478
 
@@ -53,6 +53,7 @@ export function stringifyCompositeLoop(lines: string[], plan: CompositeLoopPlan)
53
53
  innerLoops,
54
54
  loopParam,
55
55
  loopParamBindings,
56
+ loopIndex,
56
57
  reactiveEffects,
57
58
  childRefs,
58
59
  branchClearChildren,
@@ -124,7 +125,7 @@ export function stringifyCompositeLoop(lines: string[], plan: CompositeLoopPlan)
124
125
  // variant whose tail needs the row already connected.
125
126
  mountRow: true,
126
127
  })
127
- emitComponentAndEventSetup(lines, bodyIndent, '__el', compsArr, eventsArr, loopParam, loopParamBindings, bodyIsMultiRoot)
128
+ emitComponentAndEventSetup(lines, bodyIndent, '__el', compsArr, eventsArr, loopParam, loopParamBindings, bodyIsMultiRoot, loopIndex)
128
129
  if (innerLoops.length > 0) {
129
130
  stringifyInnerLoops(lines, innerLoops, bodyIndent, pc)
130
131
  }
@@ -191,6 +191,11 @@ export function stringifyLazyRowLoop(lines: string[], o: StringifyLazyRowOptions
191
191
  // --- createRow ---------------------------------------------------------
192
192
  const b1 = `${indent} `
193
193
  const b2 = `${indent} `
194
+ // Tells the runtime to also call `applyItem` on a pure reorder (#2859
195
+ // follow-up) — see `LazyRowPlan.indexDriven`'s docstring. Omitted (not just
196
+ // `false`) for every loop that never reads the index, byte-stable with
197
+ // this widening's predecessor.
198
+ if (lazyRow.readsIndex) lines.push(`${b1}indexDriven: true,`)
194
199
  lines.push(`${b1}createRow: (__e, ${o.indexParam}) => {`)
195
200
  // Always bound, even with no reactive bindings: the non-hoisted clone
196
201
  // interpolates the per-row template, which reads the param for at least
@@ -225,6 +230,9 @@ export function stringifyLazyRowLoop(lines: string[], o: StringifyLazyRowOptions
225
230
  } else {
226
231
  lines.push(`${b1}applyItem: (__e) => {`)
227
232
  lines.push(`${b2}const ${paramHead} = () => __e.item`)
233
+ // The row's current position (#2859 follow-up) — only when some binding
234
+ // here actually reads it, so every other loop's applyItem is unchanged.
235
+ if (lazyRow.readsIndex) lines.push(`${b2}const ${o.indexParam} = __e.index`)
228
236
  lines.push(`${b2}const __r = __e.refs ?? (__e.refs = [])`)
229
237
  lines.push(`${b2}const __l = __e.last ?? (__e.last = [])`)
230
238
  // Re-run the preamble only when a binding in THIS body reads a local it
@@ -255,6 +263,8 @@ export function stringifyLazyRowLoop(lines: string[], o: StringifyLazyRowOptions
255
263
  for (const g of lazyRow.outerPrimeGetters) lines.push(`${b2}${g}()`)
256
264
  lines.push(`${b2}for (const __e of __es) {`)
257
265
  lines.push(`${b3}const ${paramHead} = () => __e.item`)
266
+ // Same as `applyItem` — the row's current position (#2859 follow-up).
267
+ if (lazyRow.readsIndex) lines.push(`${b3}const ${o.indexParam} = __e.index`)
258
268
  lines.push(`${b3}const __r = __e.refs ?? (__e.refs = [])`)
259
269
  lines.push(`${b3}const __l = __e.last ?? (__e.last = [])`)
260
270
  // Per ENTRY, not once for the batch: the preamble reads the item, so its
@@ -486,6 +486,35 @@ export function resolveGetterAliases(
486
486
  return aliases
487
487
  }
488
488
 
489
+ /**
490
+ * Which names in `signals`/`memos` are eligible ALIAS-HOP TARGETS for
491
+ * `resolveGetterAliases`'s `isGetter` predicate — a non-module,
492
+ * non-env-reader signal getter, or a non-module memo name. Env-signal
493
+ * getters are excluded because they resolve through their own
494
+ * request-scoped binding (an adapter's `searchParamsFieldRef`-style
495
+ * lookup or equivalent), never a seeded/`out`-keyed value an alias could
496
+ * usefully point at.
497
+ *
498
+ * Shared by every `resolveGetterAliases` call site (#2813's `ssr-defaults.ts`
499
+ * seeding and the Go adapter's `rootFieldRef` field-routing) so the
500
+ * eligibility rule can't silently drift between them — the "one decision,
501
+ * two implementations" defect class CLAUDE.md calls out (pullfrog review
502
+ * on #2854).
503
+ */
504
+ export function collectAliasableGetterNames(
505
+ signals: readonly SignalInfo[],
506
+ memos: readonly MemoInfo[],
507
+ ): Set<string> {
508
+ const getterNames = new Set<string>()
509
+ for (const sig of signals) {
510
+ if (sig.getter && !sig.isModule && !sig.envReader) getterNames.add(sig.getter)
511
+ }
512
+ for (const memo of memos) {
513
+ if (!memo.isModule) getterNames.add(memo.name)
514
+ }
515
+ return getterNames
516
+ }
517
+
489
518
  /**
490
519
  * Build the CSR substitution env from the live `ClientJsContext`.
491
520
  *
@@ -11,6 +11,7 @@ import type { ClientJsContext } from './types.ts'
11
11
  import { toHtmlAttrName, varSlotId, PROPS_PARAM } from './utils.ts'
12
12
  import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from './control-flow/stringify/claim-plan.ts'
13
13
  import { createTemplateAwareStringProtector } from './html-template.ts'
14
+ import { DYNAMIC_ELEMENT_WRITER_KIND } from './markup-slots.ts'
14
15
  import { datePlugin, DATE_METHODS } from '../date-lowering.ts'
15
16
  import { toLocaleDatePlugin, foldedArgToClientJs } from '../to-locale-date-lowering.ts'
16
17
  import { tsNodeToParsedExpr } from '../expression-parser.ts'
@@ -393,9 +394,11 @@ export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): v
393
394
  // 'text') because the value may be a live Node (e.g. a JSX-returning
394
395
  // call like `{themeLogo(id)}`) — the writer splices a Node in by
395
396
  // identity, exactly the contract `__bfText` used to provide (#1663).
397
+ // The kind is a named constant so the initial-render side
398
+ // (`markup-slots.ts` → `escapeTextOrMarkup`) reads the same decision.
396
399
  let writer = ''
397
400
  if (normalElems.length > 0) {
398
- const slots: ClaimSlotSpec[] = normalElems.map(elem => ({ id: elem.slotId, kind: 'markup', path: [] }))
401
+ const slots: ClaimSlotSpec[] = normalElems.map(elem => ({ id: elem.slotId, kind: DYNAMIC_ELEMENT_WRITER_KIND, path: [] }))
399
402
  writer = claimWriterVarName(slots, varSlotId)
400
403
  lines.push(` const ${writer} = lazySlots(__scope, ${claimPlanLiteral(slots)})`)
401
404
  }
@@ -9,6 +9,7 @@ import type { ClientJsContext } from './types.ts'
9
9
  import { PROPS_PARAM } from './utils.ts'
10
10
  import { computeInlinability, toLegacyInlinability } from './compute-inlinability.ts'
11
11
  import { canGenerateStaticTemplate, irToComponentTemplate, generateCsrTemplate, createStringProtector } from './html-template.ts'
12
+ import { markupSlotIdsOf } from './markup-slots.ts'
12
13
  import { nameForRegistryRef } from './component-scope.ts'
13
14
  import { resolveRestSpreadNames } from './prop-handling.ts'
14
15
 
@@ -219,10 +220,7 @@ export function emitRegistrationAndHydration(
219
220
  // Build ComponentDef object for hydrate()
220
221
  const defParts: string[] = [`init: init${name}`]
221
222
  if (canGenerateStaticTemplate(_ir.root, propNamesForStaticCheck, inlinableConstants, unsafeLocalNames)) {
222
- // `ctx.dynamicElements` is the claim-plan-'markup' membership
223
- // (#2651) — see `generateCsrTemplate`'s identical derivation below
224
- // for why this is reused as-is rather than re-derived.
225
- const markupSlotIds = new Set(ctx.dynamicElements.map(e => e.slotId))
223
+ const markupSlotIds = markupSlotIdsOf(ctx)
226
224
  const templateHtml = irToComponentTemplate(_ir.root, inlinableConstants, restSpreadNames, ctx.propsObjectName, markupSlotIds, ctx.restPropsName)
227
225
  if (templateHtml) {
228
226
  defParts.push(buildTemplateDefPart(ctx, templateHtml))