@barefootjs/jsx 0.35.0 → 0.35.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/analyzer-context.d.ts.map +1 -1
  2. package/dist/index.js +731 -691
  3. package/dist/ir-to-client-js/collect-elements.d.ts +22 -2
  4. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  5. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts +14 -2
  7. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +6 -1
  10. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +3 -2
  13. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts +24 -44
  15. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +14 -0
  17. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +6 -14
  19. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts +1 -3
  21. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +19 -3
  23. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +5 -1
  26. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/emit-reactive.d.ts +54 -0
  28. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/reactivity.d.ts +62 -18
  31. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  32. package/dist/ir-to-client-js/types.d.ts +18 -2
  33. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  34. package/dist/reactivity-checker.d.ts +18 -0
  35. package/dist/reactivity-checker.d.ts.map +1 -1
  36. package/package.json +2 -2
  37. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +128 -10
  38. package/src/__tests__/binding-scope-ratchet.test.ts +26 -3
  39. package/src/__tests__/child-prop-fallback-wrap.test.ts +4 -2
  40. package/src/__tests__/compiler-stress-1244.test.ts +8 -8
  41. package/src/__tests__/issue-2868-cond-arm-tag-collision.test.ts +185 -0
  42. package/src/__tests__/issue-2869-attr-dedup-guard.test.ts +141 -0
  43. package/src/__tests__/loop-index-reactivity.test.ts +208 -0
  44. package/src/__tests__/map-body-brand-checker.test.ts +176 -0
  45. package/src/__tests__/nested-loop-index-param.test.ts +27 -5
  46. package/src/__tests__/nested-loop-non-outer-reactivity.test.ts +177 -0
  47. package/src/__tests__/nested-loop-plain.test.ts +39 -25
  48. package/src/__tests__/nested-loop-reactive-attrs.test.ts +12 -4
  49. package/src/__tests__/preamble-branch-arm-rerun.test.ts +135 -0
  50. package/src/__tests__/style-css-var-reactive.test.ts +4 -1
  51. package/src/analyzer-context.ts +12 -10
  52. package/src/ir-to-client-js/collect-elements.ts +110 -32
  53. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +2 -0
  54. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +28 -51
  55. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +7 -2
  56. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +33 -6
  57. package/src/ir-to-client-js/control-flow/plan/build-plain-row.ts +7 -1
  58. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +18 -6
  59. package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +24 -45
  60. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +14 -0
  61. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +20 -66
  62. package/src/ir-to-client-js/control-flow/stringify/insert.ts +4 -2
  63. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +12 -15
  64. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +53 -11
  65. package/src/ir-to-client-js/control-flow/stringify/loop.ts +4 -2
  66. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +29 -18
  67. package/src/ir-to-client-js/emit-reactive.ts +88 -5
  68. package/src/ir-to-client-js/html-template.ts +9 -1
  69. package/src/ir-to-client-js/reactivity.ts +73 -27
  70. package/src/ir-to-client-js/types.ts +19 -3
  71. package/src/jsx-to-ir.ts +21 -4
  72. package/src/reactivity-checker.ts +60 -0
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { varSlotId, DATA_BF_PH, keyAttrName, mapArrayKeyArgs, profileBindingId } from '../../utils.ts'
15
15
  import { emitComponentAndEventSetup } from '../shared.ts'
16
- import { emitAttrUpdate } from '../../emit-reactive.ts'
16
+ import { emitDedupedAttrUpdate, DEDUP_STORE_DECL } from '../../emit-reactive.ts'
17
17
  import { namespaceWrapForTemplate } from './template-parse.ts'
18
18
  import { emitListenerLine } from './event-listener.ts'
19
19
  import { nameForRegistryRef } from '../../component-scope.ts'
@@ -46,15 +46,25 @@ export function stringifyBranchReactiveAttrs(
46
46
  lines: string[],
47
47
  plan: readonly ReactiveAttrSlot[],
48
48
  indent: string,
49
- pc?: string,
49
+ pc: string | undefined,
50
+ /** The row's preamble re-run statement, when this scope has one (#2596/#2447) — see `conditionGetterExpr`'s docstring for the shared contract. */
51
+ mapPreambleWrapped: string | undefined,
50
52
  ): void {
51
53
  for (const slot of plan) {
52
54
  const varName = `__ra_${varSlotId(slot.slotId)}`
53
55
  lines.push(`${indent}{ const ${varName} = qsa(__branchScope, '[bf="${slot.slotId}"]')`)
56
+ lines.push(`${indent}${DEDUP_STORE_DECL}`)
54
57
  lines.push(`${indent}if (${varName}) {`)
58
+ let ordinal = 0
55
59
  for (const attr of slot.attrs) {
56
60
  lines.push(`${indent} __disposers.push(createDisposableEffect(() => {`)
57
- for (const stmt of emitAttrUpdate(varName, attr.attrName, attr.wrappedExpression, attr.meta)) {
61
+ // One effect per attr, so an attr reading a preamble local needs the
62
+ // declarations inside ITS OWN effect — same rule, same line, as
63
+ // `emitAttrSlotsGranular` (reactive-effects.ts, #2447 follow-up).
64
+ if (attr.readsPreamble && mapPreambleWrapped) {
65
+ lines.push(`${indent} ${mapPreambleWrapped}`)
66
+ }
67
+ for (const stmt of emitDedupedAttrUpdate(varName, attr.attrName, attr.wrappedExpression, attr.meta, ordinal++)) {
58
68
  lines.push(`${indent} ${stmt}`)
59
69
  }
60
70
  lines.push(`${indent} }${profileBindingId(pc, slot.slotId)}))`)
@@ -156,6 +166,8 @@ export function stringifyBranchInnerLoops(
156
166
  [...inner.legacyEvents],
157
167
  inner.outerLoopParam,
158
168
  inner.outerLoopParamBindings,
169
+ false,
170
+ inner.outerLoopIndex,
159
171
  )
160
172
  }
161
173
  const conditionalTexts = inner.reactiveTexts.filter(t => t.insideConditional)
@@ -177,7 +189,13 @@ export function stringifyBranchInnerLoops(
177
189
  }
178
190
  }
179
191
  if (inner.nestedConditionals.length > 0) {
180
- stringifyLoopChildConditionals(lines, inner.nestedConditionals, `${indent} `, pc)
192
+ // An inner loop's own conditionals are classified against the INNER
193
+ // loop's preamble names, not the outer row's — `BranchInnerLoop`
194
+ // carries no re-runnable preamble string for that scope, so there is
195
+ // nothing correct to inject here. The `readsPreamble && mapPreambleWrapped`
196
+ // guard downstream makes this `undefined` a no-op, exactly as before
197
+ // this fix (inner-loop preambles are out of scope — see PR notes).
198
+ stringifyLoopChildConditionals(lines, inner.nestedConditionals, `${indent} `, pc, undefined)
181
199
  }
182
200
  lines.push(`${indent} return __bel${uid}`)
183
201
  // #2753 Shape B: see the identical comment in `inner-loop.ts` —
@@ -198,33 +216,55 @@ export function stringifyLoopChildConditionals(
198
216
  lines: string[],
199
217
  conditionals: readonly LoopChildConditionalPlan[],
200
218
  indent: string,
201
- pc?: string,
219
+ pc: string | undefined,
220
+ /** The row's preamble re-run statement, when this scope has one (#2596/#2447). */
221
+ mapPreambleWrapped: string | undefined,
202
222
  ): void {
203
223
  for (const cond of conditionals) {
204
- stringifyLoopChildConditional(lines, cond, indent, pc)
224
+ stringifyLoopChildConditional(lines, cond, indent, pc, mapPreambleWrapped)
205
225
  }
206
226
  }
207
227
 
228
+ /**
229
+ * The `() => cond` getter handed to `insert()`. A condition flagged
230
+ * `readsPreamble` (#2596) re-runs the row preamble INSIDE the getter —
231
+ * `insert()` re-invokes this closure on every dependency change to decide
232
+ * the branch, and a preamble local is a plain per-row `const`, not a signal
233
+ * `insert()` can see through on its own. ONE implementation for both the
234
+ * outer (`emitOuterConditional`, reactive-effects.ts) and nested
235
+ * (`stringifyLoopChildConditional`, here) levels so the two can't drift.
236
+ */
237
+ export function conditionGetterExpr(
238
+ wrappedCondition: string,
239
+ readsPreamble: boolean | undefined,
240
+ mapPreambleWrapped: string | undefined,
241
+ ): string {
242
+ return readsPreamble && mapPreambleWrapped
243
+ ? `() => { ${mapPreambleWrapped}; return (${wrappedCondition}) }`
244
+ : `() => ${wrappedCondition}`
245
+ }
246
+
208
247
  function stringifyLoopChildConditional(
209
248
  lines: string[],
210
249
  cond: LoopChildConditionalPlan,
211
250
  indent: string,
212
251
  pc: string | undefined,
252
+ mapPreambleWrapped: string | undefined,
213
253
  ): void {
214
254
  const armIndent = `${indent} `
215
255
  // Body-form arrows wire `__bfSlot` captures into the runtime so live
216
256
  // `Node` returns from Child-position interpolations are spliced into
217
257
  // the parsed fragment instead of being stringified by the surrounding
218
258
  // template literal (#1213).
219
- lines.push(`${indent}insert(${cond.scopeVar}, '${cond.slotId}', () => ${cond.wrappedCondition}, {`)
259
+ lines.push(`${indent}insert(${cond.scopeVar}, '${cond.slotId}', ${conditionGetterExpr(cond.wrappedCondition, cond.readsPreamble, mapPreambleWrapped)}, {`)
220
260
  lines.push(`${indent} template: () => { const __slots = []; return { html: \`${cond.whenTrueTemplateHtml}\`, slots: __slots } },`)
221
261
  lines.push(`${indent} bindEvents: (__branchScope, { isFirstRun: __bfFirstRun = false } = {}) => {`)
222
- stringifyLoopChildArm(lines, cond.whenTrueArm, armIndent, pc)
262
+ stringifyLoopChildArm(lines, cond.whenTrueArm, armIndent, pc, mapPreambleWrapped)
223
263
  lines.push(`${indent} }`)
224
264
  lines.push(`${indent}}, {`)
225
265
  lines.push(`${indent} template: () => { const __slots = []; return { html: \`${cond.whenFalseTemplateHtml}\`, slots: __slots } },`)
226
266
  lines.push(`${indent} bindEvents: (__branchScope, { isFirstRun: __bfFirstRun = false } = {}) => {`)
227
- stringifyLoopChildArm(lines, cond.whenFalseArm, armIndent, pc)
267
+ stringifyLoopChildArm(lines, cond.whenFalseArm, armIndent, pc, mapPreambleWrapped)
228
268
  lines.push(`${indent} }`)
229
269
  lines.push(`${indent}}${profileBindingId(pc, cond.slotId)})`)
230
270
  }
@@ -242,6 +282,8 @@ export function stringifyLoopChildArm(
242
282
  arm: LoopChildArmPlan,
243
283
  armIndent: string,
244
284
  pc: string | undefined,
285
+ /** The row's preamble re-run statement, when this scope has one (#2596/#2447) — forwarded to this arm's own reactive attrs and nested conditionals. */
286
+ mapPreambleWrapped: string | undefined,
245
287
  ): void {
246
288
  stringifyBranchEventBindings(lines, arm.events, armIndent)
247
289
  stringifyBranchChildComponentInits(lines, arm.childComponents, armIndent)
@@ -257,7 +299,7 @@ export function stringifyLoopChildArm(
257
299
  if (!hasDisposables) return
258
300
 
259
301
  lines.push(`${armIndent}const __disposers = []`)
260
- stringifyBranchReactiveAttrs(lines, arm.attrs, armIndent, pc)
302
+ stringifyBranchReactiveAttrs(lines, arm.attrs, armIndent, pc, mapPreambleWrapped)
261
303
 
262
304
  // Nested conditionals: each inner `insert()` is wrapped in its own
263
305
  // disposable effect so disposing THIS entry cascades to whatever
@@ -265,7 +307,7 @@ export function stringifyLoopChildArm(
265
307
  // conditional's nested-conditional handling in stringify/insert.ts).
266
308
  for (const cond of arm.nestedConditionals) {
267
309
  lines.push(`${armIndent}__disposers.push(createDisposableEffect(() => {`)
268
- stringifyLoopChildConditional(lines, cond, `${armIndent} `, pc)
310
+ stringifyLoopChildConditional(lines, cond, `${armIndent} `, pc, mapPreambleWrapped)
269
311
  lines.push(`${armIndent}}))`)
270
312
  }
271
313
 
@@ -30,7 +30,7 @@
30
30
  */
31
31
 
32
32
  import { emitRefCall, varSlotId, profileBindingId } from '../../utils.ts'
33
- import { emitAttrUpdate } from '../../emit-reactive.ts'
33
+ import { emitDedupedAttrUpdate, DEDUP_STORE_DECL } from '../../emit-reactive.ts'
34
34
  import { stringifyReactiveEffects } from './reactive-effects.ts'
35
35
  import { emitTemplateCloneInline, emitLoopItemElementSetup, emitHoistedTemplateDecl, hoistedCloneExpr, namespaceWrapForTemplate, multiRootNamespaceWrapForTemplate, wrapHtmlForNamespace } from './template-parse.ts'
36
36
  import { buildSkeletonPathPlan, type SkeletonPathPlan } from './skeleton-paths.ts'
@@ -452,13 +452,15 @@ export function stringifyStaticLoop(lines: string[], plan: StaticLoopPlan): void
452
452
  lines.push(` }`)
453
453
  }
454
454
  lines.push(` if (__iterEl) {`)
455
+ if (attrsBySlot.length > 0) lines.push(` ${DEDUP_STORE_DECL}`)
456
+ let ordinal = 0
455
457
  for (const [slotId, attrs] of attrsBySlot) {
456
458
  const varName = `__t_${varSlotId(slotId)}`
457
459
  lines.push(` const ${varName} = qsa(__iterEl, '[bf="${slotId}"]')`)
458
460
  lines.push(` if (${varName}) {`)
459
461
  for (const attr of attrs) {
460
462
  lines.push(` createEffect(() => {`)
461
- for (const stmt of emitAttrUpdate(varName, attr.attrName, attr.expression, attr)) {
463
+ for (const stmt of emitDedupedAttrUpdate(varName, attr.attrName, attr.expression, attr, ordinal++)) {
462
464
  lines.push(` ${stmt}`)
463
465
  }
464
466
  lines.push(` }${profileBindingId(pc, slotId)})`)
@@ -32,8 +32,8 @@
32
32
  */
33
33
 
34
34
  import { varSlotId, profileBindingId } from '../../utils.ts'
35
- import { emitAttrUpdate } from '../../emit-reactive.ts'
36
- import { stringifyLoopChildArm } from './loop-child-arm.ts'
35
+ import { emitDedupedAttrUpdate, DEDUP_STORE_DECL } from '../../emit-reactive.ts'
36
+ import { stringifyLoopChildArm, conditionGetterExpr } from './loop-child-arm.ts'
37
37
  import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from './claim-plan.ts'
38
38
  import type {
39
39
  NestedConditionalPlan,
@@ -103,7 +103,11 @@ export interface StringifyReactiveEffectsOptions {
103
103
  * The loop callback's pre-return preamble (already wrapped with the loop
104
104
  * param accessor), re-run once per row-effect tick — BEFORE any preamble
105
105
  * region read — so `preambleRegions`' `valueExpr`s see freshly recomputed
106
- * locals. Only meaningful alongside `preambleRegions`.
106
+ * locals. Also consumed by every OTHER `readsPreamble` site in this row's
107
+ * scope: row-level attrs (`emitAttrSlotsGranular`/`emitConsolidatedRowEffect`),
108
+ * outer condition getters (`emitOuterConditional`), and — forwarded
109
+ * through `stringifyLoopChildArm` (#2596 follow-up) — that row's branch
110
+ * arms' own reactive attrs and nested condition getters.
107
111
  */
108
112
  mapPreambleWrapped?: string
109
113
  }
@@ -160,7 +164,9 @@ function emitAttrSlotsGranular(
160
164
  const varName = `__ra_${varSlotId(slot.slotId)}`
161
165
  const lookupExpr = attrLookupExpr(slot.slotId, varName, elVar, lookup, elementIndexBySlot)
162
166
  lines.push(`${indent}{ const ${varName} = ${lookupExpr}`)
167
+ lines.push(`${indent}${DEDUP_STORE_DECL}`)
163
168
  lines.push(`${indent}if (${varName}) {`)
169
+ let ordinal = 0
164
170
  for (const attr of slot.attrs) {
165
171
  lines.push(`${indent} createEffect(() => {`)
166
172
  // Profile mode keeps one effect per attr, so an attr reading a preamble
@@ -171,7 +177,7 @@ function emitAttrSlotsGranular(
171
177
  if (attr.readsPreamble && mapPreambleWrapped) {
172
178
  lines.push(`${indent} ${mapPreambleWrapped}`)
173
179
  }
174
- for (const stmt of emitAttrUpdate(varName, attr.attrName, attr.wrappedExpression, attr.meta)) {
180
+ for (const stmt of emitDedupedAttrUpdate(varName, attr.attrName, attr.wrappedExpression, attr.meta, ordinal++)) {
175
181
  lines.push(`${indent} ${stmt}`)
176
182
  }
177
183
  lines.push(`${indent} }${bindingBfId(slot.slotId)})`)
@@ -233,11 +239,14 @@ function attrLookupExpr(
233
239
  * dispatches per-slot on `kind`), so a row pays for at most one `lazySlots`
234
240
  * closure + one claim `Map` instead of one per category.
235
241
  *
236
- * Every attr's write statements are wrapped in their own `{ }` block (not
237
- * just each slot's) `emitAttrUpdate`'s 'value'-attr shape emits a bare
238
- * `const __val = …` with no block of its own, and merging multiple attrs
239
- * into one function body (previously each had its own arrow-function scope)
240
- * would let two 'value' attrs in the same row effect collide on `__val`.
242
+ * Every attr write goes through `emitDedupedAttrUpdate` (#2869): a
243
+ * self-contained `{ }` block (so two 'value' attrs in the same row effect
244
+ * can't collide on `emitAttrUpdate`'s bare `const __val`) behind a
245
+ * previous-value guard on the row's ONE `__l` store, ordinals sequential
246
+ * across every slot in the row. Without the guard, fusing bindings into one
247
+ * effect meant a change to ANY binding rewrote ALL of them on every rerun —
248
+ * `untrack()` on one binding's read couldn't stop a sibling binding's
249
+ * legitimate change from re-triggering this effect and re-writing it anyway.
241
250
  */
242
251
  function emitConsolidatedRowEffect(
243
252
  lines: string[],
@@ -258,6 +267,11 @@ function emitConsolidatedRowEffect(
258
267
  const varName = `__ra_${varSlotId(slot.slotId)}`
259
268
  lines.push(`${indent}const ${varName} = ${attrLookupExpr(slot.slotId, varName, elVar, lookup, elementIndexBySlot)}`)
260
269
  }
270
+ // Row-wide dedup store (#2869) — one `__l`, shared by every attr write
271
+ // below via a sequential ordinal, so a sibling binding's legitimate
272
+ // rerun of this shared effect can't force an unchanged binding to
273
+ // rewrite too.
274
+ if (attrSlots.length > 0) lines.push(`${indent}${DEDUP_STORE_DECL}`)
261
275
 
262
276
  // 2. ONE claimed-slot writer covering outer texts (kind 'text') and
263
277
  // preamble regions (kind 'markup') — mixed-kind plans are supported by
@@ -293,15 +307,14 @@ function emitConsolidatedRowEffect(
293
307
  if (mapPreambleWrapped && (preambleRegions.length > 0 || attrsReadPreamble(attrSlots))) {
294
308
  lines.push(`${indent} ${mapPreambleWrapped}`)
295
309
  }
310
+ let ordinal = 0
296
311
  for (const slot of attrSlots) {
297
312
  const varName = `__ra_${varSlotId(slot.slotId)}`
298
313
  lines.push(`${indent} if (${varName}) {`)
299
314
  for (const attr of slot.attrs) {
300
- lines.push(`${indent} {`)
301
- for (const stmt of emitAttrUpdate(varName, attr.attrName, attr.wrappedExpression, attr.meta)) {
302
- lines.push(`${indent} ${stmt}`)
315
+ for (const stmt of emitDedupedAttrUpdate(varName, attr.attrName, attr.wrappedExpression, attr.meta, ordinal++)) {
316
+ lines.push(`${indent} ${stmt}`)
303
317
  }
304
- lines.push(`${indent} }`)
305
318
  }
306
319
  lines.push(`${indent} }`)
307
320
  }
@@ -354,18 +367,16 @@ function emitOuterConditional(
354
367
  // scope here (it's a plain per-row `const`, not a signal `insert()` can see
355
368
  // through on its own). Same treatment as `readsPreamble` attrs
356
369
  // (`emitAttrUpdate`'s callers) get ahead of their own write.
357
- const conditionGetter = cond.readsPreamble && mapPreambleWrapped
358
- ? `() => { ${mapPreambleWrapped}; return (${cond.wrappedCondition}) }`
359
- : `() => ${cond.wrappedCondition}`
370
+ const conditionGetter = conditionGetterExpr(cond.wrappedCondition, cond.readsPreamble, mapPreambleWrapped)
360
371
  lines.push(`${indent}insert(${elVar}, '${cond.slotId}', ${conditionGetter}, {`)
361
372
  lines.push(`${indent} template: () => { const __slots = []; return { html: \`${cond.whenTrueTemplateHtml}\`, slots: __slots } },`)
362
373
  lines.push(`${indent} bindEvents: (__branchScope, { isFirstRun: __bfFirstRun = false } = {}) => {`)
363
- stringifyLoopChildArm(lines, cond.whenTrueArm, armIndent, pc)
374
+ stringifyLoopChildArm(lines, cond.whenTrueArm, armIndent, pc, mapPreambleWrapped)
364
375
  lines.push(`${indent} }`)
365
376
  lines.push(`${indent}}, {`)
366
377
  lines.push(`${indent} template: () => { const __slots = []; return { html: \`${cond.whenFalseTemplateHtml}\`, slots: __slots } },`)
367
378
  lines.push(`${indent} bindEvents: (__branchScope, { isFirstRun: __bfFirstRun = false } = {}) => {`)
368
- stringifyLoopChildArm(lines, cond.whenFalseArm, armIndent, pc)
379
+ stringifyLoopChildArm(lines, cond.whenFalseArm, armIndent, pc, mapPreambleWrapped)
369
380
  lines.push(`${indent} }`)
370
381
  lines.push(`${indent}}${profileBindingId(pc, cond.slotId)})`)
371
382
  }
@@ -85,6 +85,9 @@ function emitChildValueMirrorStatements(target: string, expression: string): str
85
85
  * Generate JS statements to update a DOM attribute reactively.
86
86
  * Centralizes the attribute-type dispatch (value, class, boolean, presence, generic)
87
87
  * so that new AttrMeta flags are handled in one place.
88
+ *
89
+ * Per-kind WRITE dispatch only — an effect body must never call this
90
+ * directly, only through `emitDedupedAttrUpdate` below (#2869).
88
91
  */
89
92
  export function emitAttrUpdate(target: string, attrName: string, expression: string, meta: AttrMeta): string[] {
90
93
  const htmlName = toHtmlAttrName(attrName)
@@ -126,6 +129,81 @@ export function emitAttrUpdate(target: string, attrName: string, expression: str
126
129
  ]
127
130
  }
128
131
 
132
+ /**
133
+ * Last-value store every deduped attribute write in one emitting block
134
+ * shares — `const __l = []`, declared ONCE in the block that creates the
135
+ * effect(s), so the effect closure (a stable object across its own reruns)
136
+ * captures it and no other effect can see it. Each binding owns a fixed
137
+ * numeric ordinal into it. The lazy row graph (`control-flow/stringify/
138
+ * lazy-row.ts`) declares the same `__l` from the reconciler's `entry.last`
139
+ * instead — `applyItem` / `applyOuter` are separate invocations with no
140
+ * closure of their own to own the array — but the guard, the temp name,
141
+ * the ordinal scheme and the record-after-write rule below are shared with
142
+ * it (#2869).
143
+ */
144
+ export const DEDUP_STORE_DECL = 'const __l = []'
145
+
146
+ /**
147
+ * `__l`-backed dedup test. `in` (not a truthiness check) so a legitimately
148
+ * `undefined` value still records and still writes once; `!(N in __l)` on
149
+ * the first run is what makes the first write unconditional — the same
150
+ * "dedup, no trust-first-run" contract `claim-slots.ts` documents for
151
+ * 'markup' slots. Moved here from `control-flow/stringify/lazy-row.ts`
152
+ * so the eager paths below and the lazy row graph share one guard (#2869).
153
+ */
154
+ export function dedupGuard(ordinal: number): string {
155
+ return `!(${ordinal} in __l) || !Object.is(__l[${ordinal}], __x)`
156
+ }
157
+
158
+ /**
159
+ * The ONLY way an effect body should write a reactive attribute (#2869).
160
+ * Wraps `emitAttrUpdate`'s per-kind write in a previous-value guard:
161
+ * compute once into `__x`, write only when `guard` holds, then record
162
+ * `__x` into `__l[ordinal]` whether or not the write happened, so the
163
+ * stored value always tracks the latest computation. Without this, every
164
+ * rerun of a shared effect — a keyed row's fused effect (#2869's report),
165
+ * or even a plain per-slot effect covering two attrs on one element — rewrote
166
+ * every attribute in it on every rerun, no matter which binding actually
167
+ * changed. `untrack()` cannot fix this on its own: it only suppresses
168
+ * dependency REGISTRATION for the read inside it, not re-execution of the
169
+ * surrounding effect when an unrelated sibling binding legitimately changes
170
+ * — so an `<iframe srcdoc={untrack(...)}>` kept reloading every time a
171
+ * sibling attribute in the same row effect changed.
172
+ *
173
+ * Self-contained block: `__x` (and `emitAttrUpdate`'s own `__v` / bare
174
+ * `const __val`) never leak into the caller's scope, so several bindings
175
+ * can sit in one effect body without a wrapper block of their own.
176
+ *
177
+ * Compares the RAW value, exactly like the lazy-row path always has: an
178
+ * object-valued `style` / `dangerouslySetInnerHTML` is a fresh object per
179
+ * run and simply keeps writing as before (no regression, no gain — a
180
+ * follow-up could compare the coerced value instead).
181
+ *
182
+ * `guard` is overridable for the lazy row graph's `__seed`-run DOM
183
+ * read-compare (`__seed ? <DOM read-compare> : <dedupGuard>`) and `null`
184
+ * for its `createRow` (fresh clone: write unconditionally, still record).
185
+ */
186
+ export function emitDedupedAttrUpdate(
187
+ target: string,
188
+ attrName: string,
189
+ expression: string,
190
+ meta: AttrMeta,
191
+ ordinal: number,
192
+ guard: string | null = dedupGuard(ordinal),
193
+ ): string[] {
194
+ const write = emitAttrUpdate(target, attrName, '__x', meta)
195
+ const lines = [`{ const __x = ${expression}`]
196
+ if (guard) {
197
+ lines.push(`if (${guard}) {`)
198
+ for (const stmt of write) lines.push(` ${stmt}`)
199
+ lines.push(`}`)
200
+ } else {
201
+ lines.push(...write)
202
+ }
203
+ lines.push(`__l[${ordinal}] = __x }`)
204
+ return lines
205
+ }
206
+
129
207
  /**
130
208
  * Rewrite destructured prop names in an expression to `(props.xxx ?? default)`.
131
209
  * Only applies when the component uses destructured props (not props.xxx style).
@@ -494,20 +572,22 @@ export function emitReactiveAttributeUpdates(lines: string[], ctx: ClientJsConte
494
572
 
495
573
  for (const [slotId, attrs] of attrsBySlot) {
496
574
  const v = varSlotId(slotId)
575
+ lines.push(` { ${DEDUP_STORE_DECL}`)
497
576
  lines.push(` createEffect(() => {`)
498
577
  lines.push(` if (_${v}) {`)
578
+ let ordinal = 0
499
579
  for (const attr of attrs) {
500
580
  // Catalogued-lowering MUST run before the bare-prop-name rewrite:
501
581
  // the matcher needs the source-form receiver (a bare identifier or
502
582
  // `props.x`), the exact two shapes `resolveReceiverType` supports —
503
583
  // same ordering `jsx-to-ir.ts`'s static-template path documents.
504
584
  const expression = rewriteDestructuredPropsInExpr(lower(attr.expression), ctx)
505
- for (const stmt of emitAttrUpdate(`_${v}`, attr.attrName, expression, attr)) {
585
+ for (const stmt of emitDedupedAttrUpdate(`_${v}`, attr.attrName, expression, attr, ordinal++)) {
506
586
  lines.push(` ${stmt}`)
507
587
  }
508
588
  }
509
589
  lines.push(` }`)
510
- lines.push(` }${bindingIdArg(ctx, slotId)})`)
590
+ lines.push(` }${bindingIdArg(ctx, slotId)}) }`)
511
591
  lines.push('')
512
592
  }
513
593
  }
@@ -580,6 +660,7 @@ export function emitReactiveChildProps(lines: string[], ctx: ClientJsContext): v
580
660
  if (ctx.reactiveChildProps.length > 0) {
581
661
  lines.push('')
582
662
  lines.push(` // Reactive child component props`)
663
+ lines.push(` { ${DEDUP_STORE_DECL}`)
583
664
  lines.push(` createEffect(() => {`)
584
665
 
585
666
  const propsByComponent = new Map<string, typeof ctx.reactiveChildProps>()
@@ -591,6 +672,7 @@ export function emitReactiveChildProps(lines: string[], ctx: ClientJsContext): v
591
672
  propsByComponent.get(key)!.push(prop)
592
673
  }
593
674
 
675
+ let ordinal = 0
594
676
  for (const [, props] of propsByComponent) {
595
677
  const first = props[0]
596
678
  // The component's own `comment: true` root child IS `__scope` — query
@@ -609,10 +691,11 @@ export function emitReactiveChildProps(lines: string[], ctx: ClientJsContext): v
609
691
  // `value` is the CHILD-ROOT MIRROR case, not a developer-authored
610
692
  // attribute — route it through the no-SSR-fallback helper instead
611
693
  // of `emitAttrUpdate`'s generic (attribute-fallback) dispatch;
612
- // see `emitChildValueMirrorStatements`'s docstring (#2716).
694
+ // see `emitChildValueMirrorStatements`'s docstring (#2716). It has
695
+ // its own DOM-compare and consumes no dedup ordinal (#2869).
613
696
  const stmts = toHtmlAttrName(prop.attrName) === 'value'
614
697
  ? emitChildValueMirrorStatements(varName, prop.expression)
615
- : emitAttrUpdate(varName, prop.attrName, prop.expression, prop)
698
+ : emitDedupedAttrUpdate(varName, prop.attrName, prop.expression, prop, ordinal++)
616
699
  for (const stmt of stmts) {
617
700
  lines.push(` ${stmt}`)
618
701
  }
@@ -620,6 +703,6 @@ export function emitReactiveChildProps(lines: string[], ctx: ClientJsContext): v
620
703
  lines.push(` }`)
621
704
  }
622
705
 
623
- lines.push(` }${bindingIdArg(ctx, ctx.reactiveChildProps[0]?.slotId ?? undefined)})`)
706
+ lines.push(` }${bindingIdArg(ctx, ctx.reactiveChildProps[0]?.slotId ?? undefined)}) }`)
624
707
  }
625
708
  }
@@ -911,7 +911,15 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
911
911
  // emit sites (`irToComponentTemplateWithOpts`,
912
912
  // `generateCsrTemplateWithOpts`), which already pick the
913
913
  // prop-rewritten variant.
914
- const expr = attrValueToString(p.value, { useTemplate: true }) ?? 'undefined'
914
+ // Also wrap through `wrapExpr` (#2868 follow-up): this
915
+ // `renderChild(...)` call can itself live inside a loop row's
916
+ // conditional-branch HTML (a child component rendered by
917
+ // `.map()`-row type), so a prop reading the row's own loop
918
+ // param/index must read the live accessor, same as every
919
+ // other expression position `irToHtmlTemplate` renders —
920
+ // previously masked by the post-hoc `wrapLoopParamAsAccessor`
921
+ // pass over the whole branch string, which #2868 removed.
922
+ const expr = wrapExpr(attrValueToString(p.value, { useTemplate: true }) ?? 'undefined')
915
923
  return `${quotePropName(p.name)}: ${expr}`
916
924
  }
917
925
  }
@@ -38,15 +38,27 @@ import { identifierCallPattern } from '../identifier-pattern.ts'
38
38
  * `index` field through before this fix (see each call site's own
39
39
  * `undefined`/omitted-arg default), so `undefined` here still means "not
40
40
  * guarded," not "no index param."
41
+ *
42
+ * `parentScope` (#2861): stacks this loop-row frame on top of an
43
+ * ENCLOSING loop's own scope, rather than always starting from
44
+ * `BindingScope.EMPTY` — for a nested `.map()` inside another `.map()`'s
45
+ * JSX body. `BindingScope.lookup`/`valueBoundNames` search innermost-first
46
+ * across every frame, so a row expression referencing the OUTER loop's
47
+ * item/index (with no dependency on the INNER loop's own item/index or any
48
+ * signal) is still granted a slot and classified reactive, instead of
49
+ * silently returning `'none'` because only the inner-most frame was ever
50
+ * visible. Omitted (or `undefined`) for a loop with no enclosing loop —
51
+ * identical to the pre-#2861 shape.
41
52
  */
42
53
  export function buildLoopRowScope(
43
54
  loopParam?: string,
44
55
  loopParamBindings?: readonly LoopParamBinding[],
45
56
  preambleNames?: ReadonlySet<string>,
46
57
  loopIndex?: string | null,
58
+ parentScope?: BindingScope,
47
59
  ): BindingScope | undefined {
48
60
  if (!loopParam) return undefined
49
- return BindingScope.EMPTY.enterLoopRow({
61
+ return (parentScope ?? BindingScope.EMPTY).enterLoopRow({
50
62
  param: loopParam,
51
63
  paramBindings: loopParamBindings,
52
64
  index: loopIndex,
@@ -274,14 +286,28 @@ function needsEffectWrapperCore(
274
286
  /**
275
287
  * Why a given expression should be treated as reactive inside a loop item.
276
288
  *
277
- * Surfaces the two distinct reasons loop-child collectors care about:
289
+ * Surfaces the three distinct reasons loop-child collectors care about:
278
290
  * - `signal-or-memo-or-prop` — the expression reads a signal getter, a memo,
279
291
  * or a prop name (what `needsEffectWrapper` already classifies).
280
- * - `loop-param` — the expression reads the loop parameter, which becomes a
281
- * per-item signal accessor at runtime; the string-level `needsEffectWrapper`
282
- * does not know about loop params.
292
+ * - `loop-param` — the expression reads the loop's item (or a destructured
293
+ * binding of it), which becomes a per-item signal accessor at runtime;
294
+ * the string-level `needsEffectWrapper` does not know about loop params.
295
+ * - `loop-index` (#2861) — the expression reads ONLY the loop's index
296
+ * parameter (`.map((item, i) => ...)`'s `i`), with no item/signal/memo/prop
297
+ * read anywhere else in it (e.g. bare `{i}`, `class={i % 2 === 0 ? …}`).
298
+ * `mapArray`/`mapArrayAnchored` hand `renderItem` an index ACCESSOR
299
+ * exactly like the item accessor (#2859/#2860), and `mapArrayLazy` tracks
300
+ * a row's position on `entry.index` exactly like `entry.item` — but
301
+ * before this classification existed, nothing ever put such an
302
+ * expression in front of that machinery in the first place: Phase 1
303
+ * (`jsx-to-ir.ts`'s `referencesLoopParam`) already grants it a patchable
304
+ * slot via `BindingScope.valueBoundNames()` (item/index/destructure
305
+ * together), while this function — Phase 2 — had no equivalent case for
306
+ * the index alone, so the slot was written once at row-creation time and
307
+ * never revisited.
283
308
  *
284
- * `none` means neither applies and the collector can skip the expression.
309
+ * `none` means none of the three applies and the collector can skip the
310
+ * expression.
285
311
  *
286
312
  * Consolidates the duplicated reactive-classification check shared by
287
313
  * `collectLoopChildReactiveTexts`, `collectLoopChildReactiveAttrs`, and
@@ -291,37 +317,53 @@ export type ReactivitySource =
291
317
  | { kind: 'none' }
292
318
  | { kind: 'signal-or-memo-or-prop' }
293
319
  | { kind: 'loop-param'; param: string }
320
+ | { kind: 'loop-index'; param: string }
294
321
 
295
322
  /**
296
323
  * Classify a (constant-expanded) expression as reactive inside a loop item.
297
- * See `ReactivitySource` for the two reasons we care about. Loop-param
298
- * matching takes precedence so `loop-param` is reported even when the
299
- * expression also reads a signal — the `kind` is purely informational and
300
- * collectors only care about `kind !== 'none'`.
324
+ * See `ReactivitySource` for the three reasons we care about. Loop-param
325
+ * and loop-index matching take precedence so one of them is reported even
326
+ * when the expression also reads a signal — the `kind` is purely
327
+ * informational and collectors only care about `kind !== 'none'`.
328
+ *
329
+ * Takes the row's `BindingScope` (#2482) rather than a raw `(loopParam,
330
+ * loopParamBindings)` pair so this reads the EXACT SAME query Phase 1's
331
+ * `referencesLoopParam` reads (`scope.valueBoundNames()` — item/index/
332
+ * destructure together) to decide whether an expression gets a patchable
333
+ * slot at all. `undefined` means no enclosing loop.
334
+ *
335
+ * For destructured `.map()` callbacks (#951), a `'destructure'`-sourced
336
+ * binding is reported as `loop-param` — the pattern text itself (e.g.
337
+ * `{ id, label }`) never word-matches on a bare binding like `id`, so
338
+ * iterating `valueBoundNames()` (rather than testing the raw param text)
339
+ * is what catches a destructured reference at all.
301
340
  *
302
- * For destructured `.map()` callbacks (#951), `loopParamBindings` lists each
303
- * destructured binding name; any reference to one of those names is treated
304
- * as a `loop-param` hit. The pattern text itself (e.g. `{ id, label }`)
305
- * never word-matches on a bare binding like `id`, so the straight
306
- * `loopParam` check misses destructured references without this widening.
341
+ * Item/destructure bindings are checked before the index so `loop-param`
342
+ * wins when an expression reads both (e.g. `` `${item.id}-${i}` ``) this
343
+ * only affects which `kind` is reported, since every caller of this
344
+ * function besides the two `ReactivitySource`-typed union members simply
345
+ * tests `.kind !== 'none'`.
307
346
  */
308
347
  export function classifyReactivity(
309
348
  expr: string,
310
349
  ctx: ClientJsContext,
311
- loopParam?: string,
312
- loopParamBindings?: readonly LoopParamBinding[],
350
+ scope: BindingScope | undefined,
313
351
  freeIdentifiers?: ReadonlySet<string>,
314
352
  ): ReactivitySource {
315
353
  const has = (name: string): boolean =>
316
354
  freeIdentifiers ? freeIdentifiers.has(name) : tokenContainsIdent(expr, name)
317
- if (loopParamBindings && loopParamBindings.length > 0) {
318
- for (const b of loopParamBindings) {
319
- if (has(b.name)) {
320
- return { kind: 'loop-param', param: loopParam ?? b.name }
355
+ if (scope) {
356
+ let indexHit: string | undefined
357
+ for (const name of scope.valueBoundNames()) {
358
+ if (!has(name)) continue
359
+ const source = scope.lookup(name)?.binding.source
360
+ if (source === 'index') {
361
+ indexHit ??= name
362
+ continue
321
363
  }
364
+ return { kind: 'loop-param', param: name }
322
365
  }
323
- } else if (loopParam && has(loopParam)) {
324
- return { kind: 'loop-param', param: loopParam }
366
+ if (indexHit) return { kind: 'loop-index', param: indexHit }
325
367
  }
326
368
  if (needsEffectWrapper(expr, ctx, freeIdentifiers)) {
327
369
  return { kind: 'signal-or-memo-or-prop' }
@@ -666,9 +708,11 @@ export function collectLoopChildReactiveTexts(
666
708
  stopAtReactiveConditionals = false,
667
709
  preambleNames?: ReadonlySet<string>,
668
710
  loopIndex?: string | null,
711
+ /** Enclosing loop's own scope, for a NESTED loop (#2861) — see `buildLoopRowScope`. */
712
+ parentScope?: BindingScope,
669
713
  ): LoopChildReactiveText[] {
670
714
  const texts: LoopChildReactiveText[] = []
671
- const scope = buildLoopRowScope(loopParam, loopParamBindings, preambleNames, loopIndex)
715
+ const scope = buildLoopRowScope(loopParam, loopParamBindings, preambleNames, loopIndex, parentScope)
672
716
  walkIR(node, false, {
673
717
  // Skip loop/async/if-statement subtrees — the original walker omitted
674
718
  // them; they have their own scopes (inner-loop reconciliation, async
@@ -696,7 +740,7 @@ export function collectLoopChildReactiveTexts(
696
740
  // `classifyReactivity` can't see through) still gets an update
697
741
  // effect instead of silently freezing at its SSR value (#2282).
698
742
  const reactive =
699
- classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind !== 'none'
743
+ classifyReactivity(expanded.expr, ctx, scope, expanded.freeIds).kind !== 'none'
700
744
  || decideWrapFromAstFlags(n).wrap
701
745
  if (!reactive) return
702
746
  texts.push({
@@ -746,9 +790,11 @@ export function collectLoopChildReactiveAttrs(
746
790
  stopAtReactiveConditionals = false,
747
791
  preambleNames?: ReadonlySet<string>,
748
792
  loopIndex?: string | null,
793
+ /** Enclosing loop's own scope, for a NESTED loop (#2861) — see `buildLoopRowScope`. */
794
+ parentScope?: BindingScope,
749
795
  ): LoopChildReactiveAttr[] {
750
796
  const attrs: LoopChildReactiveAttr[] = []
751
- const scope = buildLoopRowScope(loopParam, loopParamBindings, preambleNames, loopIndex)
797
+ const scope = buildLoopRowScope(loopParam, loopParamBindings, preambleNames, loopIndex, parentScope)
752
798
  traverseElements(node, (el) => {
753
799
  if (el.slotId) {
754
800
  for (const attr of el.attrs) {
@@ -799,7 +845,7 @@ export function collectLoopChildReactiveAttrs(
799
845
  preambleNames.size > 0 &&
800
846
  anyNameIn(expanded.freeIds ?? extractFreeIdentifiersFromText(expanded.expr), preambleNames)
801
847
  const reactive =
802
- classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind !== 'none'
848
+ classifyReactivity(expanded.expr, ctx, scope, expanded.freeIds).kind !== 'none'
803
849
  || readsPreamble
804
850
  || attr.callsReactiveGetters
805
851
  || attr.hasFunctionCalls