@barefootjs/jsx 0.33.0 → 0.33.1

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 (47) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/errors.d.ts +1 -0
  3. package/dist/errors.d.ts.map +1 -1
  4. package/dist/index.js +145 -40
  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-inner-loop.d.ts.map +1 -1
  7. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +8 -0
  8. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts +12 -0
  11. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +1 -0
  13. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/element-refs.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/imports.d.ts +2 -2
  18. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/phases/provider-and-child-inits.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/types.d.ts +21 -0
  21. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  22. package/package.json +2 -2
  23. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +2 -4
  24. package/src/__tests__/child-components-in-map.test.ts +11 -3
  25. package/src/__tests__/client-js-generation.test.ts +37 -1
  26. package/src/__tests__/inline-jsx-callback.test.ts +55 -0
  27. package/src/__tests__/ir-jsx-props.test.ts +148 -0
  28. package/src/__tests__/issue-2705-branch-inner-loop-container.test.ts +91 -0
  29. package/src/__tests__/markup-prop-brand.test.ts +49 -0
  30. package/src/__tests__/nested-loop-conditional.test.ts +20 -11
  31. package/src/__tests__/return-through-local-var.test.ts +269 -0
  32. package/src/analyzer.ts +71 -0
  33. package/src/errors.ts +17 -1
  34. package/src/ir-to-client-js/collect-elements.ts +31 -20
  35. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +19 -0
  36. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +22 -3
  37. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +5 -2
  38. package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +12 -0
  39. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +9 -0
  40. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +7 -1
  41. package/src/ir-to-client-js/element-refs.ts +8 -0
  42. package/src/ir-to-client-js/emit-reactive.ts +91 -23
  43. package/src/ir-to-client-js/imports.ts +5 -0
  44. package/src/ir-to-client-js/index.ts +4 -0
  45. package/src/ir-to-client-js/phases/provider-and-child-inits.ts +5 -1
  46. package/src/ir-to-client-js/types.ts +21 -0
  47. package/src/jsx-to-ir.ts +157 -8
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Regression tests for #2720: a component whose render is bound to a local
3
+ * const and returned by name —
4
+ *
5
+ * export function Button() {
6
+ * const __root = (<button>Go</button>)
7
+ * return __root
8
+ * }
9
+ *
10
+ * — previously produced `{files: [], errors: []}`: neither sound (nothing
11
+ * emitted) nor loud (nothing reported). Two structural variants of this
12
+ * shape need two separate detectors, both landing here:
13
+ *
14
+ * 1. **Flat** (statements are direct children of the component body, as
15
+ * written above): `ctx.jsxReturn` DOES get set (to the `__root`
16
+ * Identifier — `visitComponentBody`'s return handler captures any
17
+ * return expression, not just syntactic JSX), but return position never
18
+ * resolves an identifier through its initializer the way JSX-child
19
+ * position does via `jsxConstants` / `inlineableJsxConsts` (#547 /
20
+ * #1409) — so `transformJsxExpression`'s scalar-leaf case returns `null`
21
+ * and `buildIRRoot` (`jsx-to-ir.ts`) drops the component silently. Fixed
22
+ * there: recognize a bare Identifier at return position that names a
23
+ * local already proven to hold JSX by those two maps, and report BF027
24
+ * instead of dropping it.
25
+ *
26
+ * 2. **Nested-block** (`{ const __root = <jsx/>; return __root }` as a
27
+ * single block statement — the exact shape the #2481 mutation sweep's
28
+ * `block-body` mutation produces by wrapping the ORIGINAL return
29
+ * statement): the block is a direct child of the component body, so
30
+ * `visitComponentBody`'s opaque-block preservation (#930 — "a bare
31
+ * block at the top of a component body is inert side-effect scoping,
32
+ * preserve it verbatim, don't recurse") swallows it whole. Neither
33
+ * `jsxConstants` nor `jsxReturn` are EVER set, so the flat-case fix
34
+ * above never runs. Fixed in `analyzer.ts`'s `visitComponentBody`:
35
+ * before preserving such a block, `findBlockBodyReturnedJsxLocalName`
36
+ * checks whether it is exactly this "name the JSX, then return the
37
+ * name" shape and reports BF027 directly.
38
+ *
39
+ * The "faithful" fix (resolving the identifier through its initializer so
40
+ * the component actually compiles, for either variant) is tracked
41
+ * separately by #2720 and not implemented here — this PR is the loud
42
+ * stopgap only.
43
+ *
44
+ * Found by the #2481 mutation sweep's `block-body` mutation
45
+ * (`packages/adapter-tests/mutation/mutations.ts`, the nested-block shape
46
+ * above): 41/41 corpus fixtures reproduced this identically before this
47
+ * fix, classified `broken` with `refused` at 0. This fix flips them to
48
+ * `refused` (a pass under the sound-or-loud trichotomy).
49
+ */
50
+
51
+ import { describe, test, expect } from 'bun:test'
52
+ import { compileJSX } from '../compiler'
53
+ import { TestAdapter } from '../adapters/test-adapter'
54
+
55
+ const adapter = new TestAdapter()
56
+
57
+ describe('BF027: return-through-local-variable is not recognized as JSX (#2720)', () => {
58
+ test('function component: `const __root = (<jsx/>); return __root` reports BF027 instead of silently emitting nothing', () => {
59
+ const source = `
60
+ export function Button() {
61
+ const __root = (<button>Go</button>)
62
+ return __root
63
+ }
64
+ `
65
+ const result = compileJSX(source, 'Button.tsx', { adapter })
66
+
67
+ // Neither silent-drop nor silent-emit: no files, but a loud diagnostic.
68
+ expect(result.files).toHaveLength(0)
69
+ const bf027 = result.errors.find(e => e.code === 'BF027')
70
+ expect(bf027).toBeDefined()
71
+ expect(bf027!.severity).toBe('error')
72
+ expect(bf027!.message).toContain('Button')
73
+ expect(bf027!.message).toMatch(/not recognized as JSX/)
74
+ })
75
+
76
+ test('arrow component: same shape via `export const Button = () => {...}`', () => {
77
+ const source = `
78
+ export const Button = () => {
79
+ const __root = <button>Go</button>
80
+ return __root
81
+ }
82
+ `
83
+ const result = compileJSX(source, 'Button.tsx', { adapter })
84
+ expect(result.files).toHaveLength(0)
85
+ expect(result.errors.find(e => e.code === 'BF027')).toBeDefined()
86
+ })
87
+
88
+ test('non-root JSX initializer (ternary) through a local also reports BF027', () => {
89
+ const source = `
90
+ export function Button({ ok }: { ok: boolean }) {
91
+ const __root = ok ? <button>Go</button> : <span>No</span>
92
+ return __root
93
+ }
94
+ `
95
+ const result = compileJSX(source, 'Button.tsx', { adapter })
96
+ expect(result.files).toHaveLength(0)
97
+ expect(result.errors.find(e => e.code === 'BF027')).toBeDefined()
98
+ })
99
+
100
+ test('nested-block shape (the actual mutation-sweep output): `{ const __root = <jsx/>; return __root }` reports BF027', () => {
101
+ // This is the shape `packages/adapter-tests/mutation/mutations.ts`'s
102
+ // `blockBody` mutation actually produces (it wraps the ORIGINAL return
103
+ // statement in a new block rather than splicing the const/return in as
104
+ // top-level statements) — structurally distinct from the flat case
105
+ // above because the block is opaque to `visitComponentBody` (#930).
106
+ const source = `
107
+ export function Button() {
108
+ {
109
+ const __root = <button>Go</button>
110
+ return __root
111
+ }
112
+ }
113
+ `
114
+ const result = compileJSX(source, 'Button.tsx', { adapter })
115
+ expect(result.files).toHaveLength(0)
116
+ const bf027 = result.errors.find(e => e.code === 'BF027')
117
+ expect(bf027).toBeDefined()
118
+ expect(bf027!.message).toContain('Button')
119
+ })
120
+
121
+ test('nested-block shape with a ternary JSX initializer also reports BF027', () => {
122
+ const source = `
123
+ export function Button({ ok }: { ok: boolean }) {
124
+ {
125
+ const __root = ok ? <button>Go</button> : <span>No</span>
126
+ return __root
127
+ }
128
+ }
129
+ `
130
+ const result = compileJSX(source, 'Button.tsx', { adapter })
131
+ expect(result.files).toHaveLength(0)
132
+ expect(result.errors.find(e => e.code === 'BF027')).toBeDefined()
133
+ })
134
+
135
+ test('nested-block shape with a `.map()`-with-JSX-callback initializer also reports BF027', () => {
136
+ // `initializerShapeContainsJsx` stops at arrow boundaries, so this
137
+ // variant needs the same `isMapLikeCallWithJsx` check `collectConstant`
138
+ // uses (#1554) — without it this shape slipped back into the silent
139
+ // drop even after BF027 landed (Copilot review on #2726).
140
+ const source = `
141
+ export function List() {
142
+ {
143
+ const __root = ['a', 'b'].map((item) => <div>{item}</div>)
144
+ return __root
145
+ }
146
+ }
147
+ `
148
+ const result = compileJSX(source, 'List.tsx', { adapter })
149
+ expect(result.files).toHaveLength(0)
150
+ const bf027 = result.errors.find(e => e.code === 'BF027')
151
+ expect(bf027).toBeDefined()
152
+ expect(bf027!.message).toContain('List')
153
+ })
154
+
155
+ test('flat shape with a `.map()`-with-JSX-callback initializer reports BF027 (via inlineableJsxConsts)', () => {
156
+ const source = `
157
+ export function List() {
158
+ const __root = ['a', 'b'].map((item) => <div>{item}</div>)
159
+ return __root
160
+ }
161
+ `
162
+ const result = compileJSX(source, 'List.tsx', { adapter })
163
+ expect(result.files).toHaveLength(0)
164
+ expect(result.errors.find(e => e.code === 'BF027')).toBeDefined()
165
+ })
166
+
167
+ test('multi-component file: the broken sibling is flagged but the good sibling still compiles', () => {
168
+ const source = `
169
+ export function Good() { return <div>ok</div> }
170
+ export function Bad() {
171
+ const __root = <button>Go</button>
172
+ return __root
173
+ }
174
+ `
175
+ const result = compileJSX(source, 'Multi.tsx', { adapter })
176
+ const bf027 = result.errors.find(e => e.code === 'BF027')
177
+ expect(bf027).toBeDefined()
178
+ expect(bf027!.message).toContain('Bad')
179
+ // Good still produces output despite Bad's failure.
180
+ expect(result.files.length).toBeGreaterThan(0)
181
+ })
182
+
183
+ describe('control: direct JSX return keeps compiling clean', () => {
184
+ test('function component returning JSX directly has no BF027 and produces files', () => {
185
+ const source = `
186
+ export function Button() {
187
+ return (<button>Go</button>)
188
+ }
189
+ `
190
+ const result = compileJSX(source, 'Button.tsx', { adapter })
191
+ expect(result.errors.find(e => e.code === 'BF027')).toBeUndefined()
192
+ expect(result.files.length).toBeGreaterThan(0)
193
+ })
194
+ })
195
+
196
+ describe('no false positive: PascalCase exports that legitimately do not return JSX stay silent', () => {
197
+ test('a PascalCase function returning a plain object is untouched (not a component at all)', () => {
198
+ const source = `
199
+ export function CreateUser() {
200
+ return { name: 'x' }
201
+ }
202
+ `
203
+ const result = compileJSX(source, 'CreateUser.tsx', { adapter })
204
+ // Pre-existing behaviour for a non-component PascalCase export:
205
+ // no files, no errors. BF027 must not fire here — there is no local
206
+ // proven to hold JSX anywhere in this function.
207
+ expect(result.files).toHaveLength(0)
208
+ expect(result.errors.find(e => e.code === 'BF027')).toBeUndefined()
209
+ })
210
+
211
+ test('render-nothing literals (null / <></> / false) returned directly stay clean', () => {
212
+ const source = `
213
+ export function ReturnsNull() { return null }
214
+ export function ReturnsFragment() { return <></> }
215
+ export function ReturnsFalse(): any { return false }
216
+ `
217
+ const result = compileJSX(source, 'ReturnsNull.tsx', { adapter })
218
+ expect(result.errors.find(e => e.code === 'BF027')).toBeUndefined()
219
+ })
220
+
221
+ test('a local const unrelated to JSX does not spuriously trip BF027', () => {
222
+ const source = `
223
+ export function Button() {
224
+ const count = 1
225
+ return <button>{count}</button>
226
+ }
227
+ `
228
+ const result = compileJSX(source, 'Button.tsx', { adapter })
229
+ expect(result.errors.find(e => e.code === 'BF027')).toBeUndefined()
230
+ expect(result.files.length).toBeGreaterThan(0)
231
+ })
232
+
233
+ test('an ordinary top-level scoping block with no returned local is untouched', () => {
234
+ // A bare block used for legitimate imperative scoping ahead of the
235
+ // real render — #930's opaque-block preservation path — must not be
236
+ // mistaken for the #2720 shape just because SOME block sits at the
237
+ // top of the component body.
238
+ const source = `
239
+ export function Button() {
240
+ {
241
+ const x = 1
242
+ console.log(x)
243
+ }
244
+ return <button>Go</button>
245
+ }
246
+ `
247
+ const result = compileJSX(source, 'Button.tsx', { adapter })
248
+ expect(result.errors.find(e => e.code === 'BF027')).toBeUndefined()
249
+ expect(result.files.length).toBeGreaterThan(0)
250
+ })
251
+
252
+ test('a nested block whose returned identifier is not locally JSX-initialized stays silent', () => {
253
+ // The block's last statement returns `result`, but nothing in the
254
+ // block declares `result` as JSX — e.g. it is a prop or an outer
255
+ // local. Must not false-positive just because the shape ends in
256
+ // `return <identifier>`.
257
+ const source = `
258
+ export function Widget({ result }: { result: number }) {
259
+ {
260
+ const other = 1
261
+ return result
262
+ }
263
+ }
264
+ `
265
+ const compileResult = compileJSX(source, 'Widget.tsx', { adapter })
266
+ expect(compileResult.errors.find(e => e.code === 'BF027')).toBeUndefined()
267
+ })
268
+ })
269
+ })
package/src/analyzer.ts CHANGED
@@ -796,6 +796,29 @@ function visitComponentBody(node: ts.Node, ctx: AnalyzerContext): void {
796
796
  (ts.isBlock(node) && node.parent === ctx.componentBodyBlock)
797
797
  )
798
798
  ) {
799
+ // #2720: a bare top-level block whose ONLY job is naming the render
800
+ // value before returning it (`{ const __root = <jsx/>; return __root
801
+ // }`) would otherwise be swallowed whole by the opaque-block
802
+ // preservation above — this walk never recurses into it, so neither
803
+ // `jsxConstants` nor `jsxReturn` ever get set and the component
804
+ // silently produces zero files, zero diagnostics. Detect the shape
805
+ // before preserving it and report loudly instead.
806
+ if (ts.isBlock(node)) {
807
+ const returnedLocal = findBlockBodyReturnedJsxLocalName(node)
808
+ if (returnedLocal) {
809
+ ctx.errors.push(createError(
810
+ ErrorCodes.RETURN_VALUE_NOT_JSX,
811
+ getSourceLocation(node, ctx.sourceFile, ctx.filePath),
812
+ {
813
+ message:
814
+ `Component '${ctx.componentName ?? '(unknown)'}' return value is not recognized ` +
815
+ `as JSX — return the JSX expression directly instead of binding it to a local ` +
816
+ `variable first (\`return ${returnedLocal}\` after \`const ${returnedLocal} = ` +
817
+ `<jsx/>\` is not resolved at return position).`,
818
+ },
819
+ ))
820
+ }
821
+ }
799
822
  collectInitStatement(node, ctx)
800
823
  return
801
824
  }
@@ -886,6 +909,54 @@ export function unwrapJsxTransparent(expr: ts.Expression): ts.Expression {
886
909
  return current
887
910
  }
888
911
 
912
+ /**
913
+ * BF027 (#2720) shape detector: a block whose last statement returns a
914
+ * bare identifier, where some earlier statement in the SAME block declares
915
+ * that identifier as a `const`/`let` initialized to JSX (root JSX, or JSX
916
+ * nested in a ternary/`&&`/`||`/`??`) — `{ const __root = <jsx/>; return
917
+ * __root }`. Mirrors the same two "does this initializer hold JSX" checks
918
+ * `collectConstant` uses to populate `jsxConstants` / `inlineableJsxConsts`
919
+ * for ordinary top-level locals, applied here to a nested block that would
920
+ * otherwise never be walked (it is preserved whole as an opaque init
921
+ * statement, see #930). Returns the identifier's name on a match, else
922
+ * null — deliberately narrow (exact "name, then return that name" shape)
923
+ * so an ordinary block scoping unrelated imperative logic is untouched.
924
+ */
925
+ function findBlockBodyReturnedJsxLocalName(block: ts.Block): string | null {
926
+ const stmts = block.statements
927
+ const last = stmts[stmts.length - 1]
928
+ if (!last || !ts.isReturnStatement(last) || !last.expression) return null
929
+ const returned = unwrapJsxTransparent(last.expression)
930
+ if (!ts.isIdentifier(returned)) return null
931
+ const name = returned.text
932
+
933
+ for (const stmt of stmts) {
934
+ if (!ts.isVariableStatement(stmt)) continue
935
+ for (const decl of stmt.declarationList.declarations) {
936
+ if (!ts.isIdentifier(decl.name) || decl.name.text !== name || !decl.initializer) continue
937
+ let init: ts.Expression = decl.initializer
938
+ while (ts.isParenthesizedExpression(init)) init = init.expression
939
+ if (
940
+ ts.isJsxElement(init) ||
941
+ ts.isJsxSelfClosingElement(init) ||
942
+ ts.isJsxFragment(init) ||
943
+ initializerShapeContainsJsx(init) ||
944
+ // `initializerShapeContainsJsx` deliberately stops at arrow
945
+ // boundaries, so a `.map()`/`.flatMap()` whose CALLBACK returns JSX
946
+ // needs the same dedicated check `collectConstant` uses to admit
947
+ // that shape into `inlineableJsxConsts` (#1554) — without it,
948
+ // `{ const __root = items.map(i => <div/>); return __root }` slips
949
+ // past BF027 back into the silent-drop path (Copilot review on
950
+ // #2726).
951
+ isMapLikeCallWithJsx(init)
952
+ ) {
953
+ return name
954
+ }
955
+ }
956
+ }
957
+ return null
958
+ }
959
+
889
960
  /**
890
961
  * Extract JSX element from an expression, handling parenthesized
891
962
  * expressions and TS type-only wrappers (`as`, `satisfies`, `!`,
package/src/errors.ts CHANGED
@@ -22,11 +22,24 @@ export const ErrorCodes = {
22
22
  // Signal/Memo errors (BF011-BF019)
23
23
  SIGNAL_OUTSIDE_COMPONENT: 'BF011',
24
24
 
25
- // JSX errors (BF021-BF029)
25
+ // JSX errors (BF021-BF029). BF022 was retired (see
26
+ // `invalid-jsx-attribute.audit.test.ts`) and BF026 is reserved by
27
+ // `spec/callback-fidelity.md` for a future `.map()`-callback-shape
28
+ // diagnostic — BF027 is the next free slot.
26
29
  UNSUPPORTED_JSX_PATTERN: 'BF021',
27
30
  MISSING_KEY_IN_LIST: 'BF023',
28
31
  MISSING_KEY_IN_NESTED_LIST: 'BF024',
29
32
  UNSUPPORTED_DESTRUCTURE_REST: 'BF025',
33
+ // The component's return statement resolves to a bare identifier that
34
+ // refers to a local `const`/`let` whose initializer IS JSX (or a
35
+ // JSX-shaped ternary/`&&`/`||`/`??`), e.g. `const __root = <div/>; return
36
+ // __root`. JSX-child position resolves such identifiers through
37
+ // `jsxConstants` / `inlineableJsxConsts` (#547 / #1409), but return
38
+ // position deliberately does not (see `transformExpressionInner`'s
39
+ // docstring) — so the dispatcher's scalar-leaf fallback silently produces
40
+ // no IR and no diagnostic (#2720). Loud stopgap until the analyzer learns
41
+ // to resolve the identifier at return position too.
42
+ RETURN_VALUE_NOT_JSX: 'BF027',
30
43
 
31
44
  // Component errors (BF043-BF049)
32
45
  PROPS_DESTRUCTURING: 'BF043',
@@ -152,6 +165,9 @@ const errorMessages: Record<ErrorCode, string> = {
152
165
  // stable.
153
166
  'Computed property key in .map() callback destructure is not supported. Rewrite the callback to destructure explicit bindings (e.g., `({ a, b }) => ...`) so the compiler can rewrite references to per-item signal accessors.',
154
167
 
168
+ [ErrorCodes.RETURN_VALUE_NOT_JSX]:
169
+ "Component's return value is not recognized as JSX — return the JSX expression directly instead of binding it to a local variable first.",
170
+
155
171
  [ErrorCodes.PROPS_DESTRUCTURING]:
156
172
  'Props destructuring in function parameters breaks reactivity. Use props object directly.',
157
173
  [ErrorCodes.SIGNAL_GETTER_NOT_CALLED]:
@@ -325,24 +325,47 @@ export function collectInnerLoops(
325
325
  // param) silently dropped its text-child update effect while the
326
326
  // sibling attribute effect (ungated) still fired. Refs need to fire
327
327
  // on every renderItem invocation (#1244).
328
- // - events / conditionals: only in `collectBindings` (branch)
329
- // mode; the legacy non-branch path didn't wire them on
330
- // `NestedLoop` because event delegation handles them through
331
- // the parent's bindings instead.
328
+ // - events: only in `collectBindings` (branch) mode; the
329
+ // legacy non-branch path didn't wire them on `NestedLoop`
330
+ // because event delegation handles them through the parent's
331
+ // bindings instead.
332
+ // - conditionals: collected for EVERY inner loop, branch or not
333
+ // (#2706) — see the `stopAtReactiveConditionals: true` note
334
+ // below for why this must not be gated the same way events are.
332
335
  const bindings: LoopChildBindings = emptyLoopChildBindings()
333
336
  // Hoisted: one Set per loop, not one per child (Copilot review).
334
337
  const innerPreambleNames = preambleNamesOf(n)
335
338
  if (ctx) {
336
339
  for (const child of n.children) {
337
- bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, n.param, n.paramBindings, false, innerPreambleNames, n.index))
338
- bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, n.param, n.paramBindings, false, innerPreambleNames, n.index))
340
+ // `stopAtReactiveConditionals: true` (#2347's parameter, #2706's
341
+ // fix here) a per-item conditional inside THIS loop's own row
342
+ // now always gets its own `bindings.conditionals` entry (below)
343
+ // and its own `insert()`, regardless of branch/general mode.
344
+ // Descending past it here too (the pre-#2706 default) would
345
+ // double-bind: once via insert()'s bindEvents, once via this
346
+ // flat `insideConditional`-flagged reclaim-on-every-run effect
347
+ // — the latter is also unsound on its own, since nothing
348
+ // guarantees the branch's marker is mounted the moment this
349
+ // effect first runs (issue-2706-nested-loop-conditional-slot
350
+ // .test.ts's original repro).
351
+ bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, n.param, n.paramBindings, true, innerPreambleNames, n.index))
352
+ bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, n.param, n.paramBindings, true, innerPreambleNames, n.index))
339
353
  bindings.refs.push(...collectLoopChildRefs(child))
340
354
  }
355
+ bindings.conditionals.push(...collectLoopChildConditionals(
356
+ { type: 'fragment', children: n.children, loc: n.loc } as unknown as IRNode,
357
+ ctx,
358
+ siblingOffsets,
359
+ n.param,
360
+ n.paramBindings,
361
+ innerPreambleNames,
362
+ n.index,
363
+ ))
341
364
  }
342
365
 
343
366
  // Per-item bindings for branch-mode callers (child components,
344
- // events, nested conditionals) — matches the pre-Phase 2
345
- // `collectBranchInnerLoops` behaviour.
367
+ // events) — matches the pre-Phase 2 `collectBranchInnerLoops`
368
+ // behaviour. Conditionals are collected above, uniformly.
346
369
  let childComponents: import('../types.ts').IRLoopChildComponent[] | undefined
347
370
  if (collectBindings) {
348
371
  // skipConditionals=true: components inside conditional branches
@@ -369,18 +392,6 @@ export function collectInnerLoops(
369
392
  for (const child of n.children) {
370
393
  bindings.events.push(...collectLoopChildEventsWithNesting(child))
371
394
  }
372
-
373
- if (ctx) {
374
- bindings.conditionals.push(...collectLoopChildConditionals(
375
- { type: 'fragment', children: n.children, loc: n.loc } as unknown as IRNode,
376
- ctx,
377
- siblingOffsets,
378
- n.param,
379
- n.paramBindings,
380
- innerPreambleNames,
381
- n.index,
382
- ))
383
- }
384
395
  }
385
396
 
386
397
  result.push({
@@ -30,6 +30,8 @@ import {
30
30
  } from '../../utils.ts'
31
31
  import { buildChildRefBindings, buildStaticChildRefBindings } from '../shared.ts'
32
32
  import { renderPreamble, irToHtmlTemplate } from '../../html-template.ts'
33
+ import { buildLoopChildConditionalsPlan } from './build-loop-child-arm.ts'
34
+ import type { LoopChildConditionalPlan } from './loop-child-arm.ts'
33
35
 
34
36
  /**
35
37
  * Mirror of the helper in `build-loop-child-arm.ts` — kept local to avoid
@@ -160,6 +162,7 @@ function buildReactiveEmit(
160
162
  outerLoopParamBindings?: readonly LoopParamBinding[],
161
163
  ): InnerLoopReactiveEmit {
162
164
  const wrapInner = (expr: string) => wrapLoopParamAsAccessor(expr, inner.param, inner.paramBindings)
165
+ const wrapBoth = (expr: string) => wrapLoopParamAsAccessor(wrapOuter(expr), inner.param, inner.paramBindings)
163
166
  const { head: paramHead, unwrap: paramUnwrap } = destructureLoopParam(inner.param, inner.paramBindings)
164
167
  const wrappedKey = inner.key
165
168
  ? wrapLoopParamAsAccessor(inner.key, inner.param, inner.paramBindings)
@@ -249,6 +252,21 @@ function buildReactiveEmit(
249
252
 
250
253
  const childRefs = buildChildRefBindings(inner.bindings.refs, inner.param, inner.paramBindings)
251
254
 
255
+ // Per-item conditionals inside THIS loop's own row (#2706) — same
256
+ // insert()-parity treatment the top-level loop's row conditionals
257
+ // already get (`buildLoopReactiveEffectsPlan`), now extended to a
258
+ // NESTED loop's row too. `scopeVar` is the row's own element
259
+ // (`__innerEl<uidSuffix>`, matching `stringifyInnerLoops`'s emission),
260
+ // and `wrapBoth` matches every other per-item expression in this emit
261
+ // (outer accessor, then inner accessor).
262
+ const conditionals: LoopChildConditionalPlan[] = buildLoopChildConditionalsPlan({
263
+ conditionals: inner.bindings.conditionals,
264
+ scopeVar: `__innerEl${uidSuffix}`,
265
+ wrap: wrapBoth,
266
+ loopParam: inner.param,
267
+ loopParamBindings: inner.paramBindings,
268
+ })
269
+
252
270
  return {
253
271
  mode: 'reactive',
254
272
  keyFn: loopKeyFn(inner),
@@ -261,6 +279,7 @@ function buildReactiveEmit(
261
279
  events,
262
280
  reactiveTexts,
263
281
  reactiveAttrs,
282
+ conditionals,
264
283
  childRefs,
265
284
  }
266
285
  }
@@ -190,6 +190,14 @@ export interface BuildBranchInnerLoopsArgs {
190
190
  innerLoops: readonly NestedLoop[] | undefined
191
191
  /** The variable expression naming the parent scope element (e.g. `__branchScope`). */
192
192
  scopeVar: string
193
+ /**
194
+ * The enclosing conditional's own slot id — every call site of this
195
+ * builder originates from a conditional branch's arm, so this is always
196
+ * a real id. Used as the `containerExpr` fallback (`findCondContainer`,
197
+ * #2705) for an inner loop whose IR never got a `containerSlotId` of its
198
+ * own (its wrapper element sits outside the branch's IR subtree).
199
+ */
200
+ condSlotId: string
193
201
  /** Outer loop param identifier (the conditional's enclosing loop). */
194
202
  outerLoopParam: string
195
203
  /** Outer loop param destructuring metadata. */
@@ -214,6 +222,7 @@ export function buildBranchInnerLoopsPlan(
214
222
  const {
215
223
  innerLoops,
216
224
  scopeVar,
225
+ condSlotId,
217
226
  outerLoopParam,
218
227
  outerLoopParamBindings,
219
228
  wrapOuter,
@@ -230,10 +239,15 @@ export function buildBranchInnerLoopsPlan(
230
239
 
231
240
  const csl = inner.containerSlotId
232
241
  // Inner loop's container: host-side `bf="<slot>"` slot marker first,
233
- // then (bf-h, bf-m) when the container is itself a child scope.
242
+ // then (bf-h, bf-m) when the container is itself a child scope. When
243
+ // neither exists — the loop's own IR never got a `containerSlotId`
244
+ // because its wrapper element sits outside the branch's IR subtree
245
+ // (#2705) — resolve via the conditional's OWN comment marker instead
246
+ // of falling back to the whole branch scope, which may be several
247
+ // elements wider than the loop's actual container.
234
248
  const containerExpr = csl
235
249
  ? `(${scopeVar}.querySelector('[bf="${csl}"]') ?? ${scopeVar}.querySelector(\`[${BF_HOST}="\${__scopeId}"][${BF_AT}="${csl}"]\`) ?? ${scopeVar})`
236
- : scopeVar
250
+ : `findCondContainer(${scopeVar}, '${condSlotId}')`
237
251
 
238
252
  const { head: paramHead, unwrap: paramUnwrap } = destructureLoopParam(inner.param, inner.paramBindings)
239
253
  const wrappedKey = inner.key
@@ -372,12 +386,14 @@ export function buildLoopChildConditionalsPlan(
372
386
  wrap,
373
387
  loopParam,
374
388
  loopParamBindings,
389
+ condId: cond.slotId,
375
390
  }),
376
391
  whenFalseArm: buildLoopChildArmPlan({
377
392
  branch: cond.whenFalse,
378
393
  wrap,
379
394
  loopParam,
380
395
  loopParamBindings,
396
+ condId: cond.slotId,
381
397
  }),
382
398
  })
383
399
  }
@@ -439,10 +455,12 @@ interface BuildLoopChildArmArgs {
439
455
  wrap: (expr: string) => string
440
456
  loopParam: string
441
457
  loopParamBindings?: readonly LoopParamBinding[]
458
+ /** The enclosing conditional's own slot id — threaded to `buildBranchInnerLoopsPlan`'s `condSlotId` (#2705). */
459
+ condId: string
442
460
  }
443
461
 
444
462
  function buildLoopChildArmPlan(args: BuildLoopChildArmArgs): LoopChildArmPlan {
445
- const { branch, wrap, loopParam, loopParamBindings } = args
463
+ const { branch, wrap, loopParam, loopParamBindings, condId } = args
446
464
  return {
447
465
  events: buildBranchEventBindingsPlan({
448
466
  events: branch.events,
@@ -455,6 +473,7 @@ function buildLoopChildArmPlan(args: BuildLoopChildArmArgs): LoopChildArmPlan {
455
473
  innerLoops: buildBranchInnerLoopsPlan({
456
474
  innerLoops: branch.innerLoops,
457
475
  scopeVar: '__branchScope',
476
+ condSlotId: condId,
458
477
  outerLoopParam: loopParam,
459
478
  outerLoopParamBindings: loopParamBindings,
460
479
  wrapOuter: wrap,
@@ -110,8 +110,8 @@ export function buildReactiveEffectsPlan(
110
110
  wrappedCondition: wrap(cond.condition),
111
111
  whenTrueTemplateHtml: addCondAttrToTemplate(wrap(cond.whenTrueHtml), cond.slotId),
112
112
  whenFalseTemplateHtml: addCondAttrToTemplate(wrap(cond.whenFalseHtml), cond.slotId),
113
- whenTrueArm: buildOuterArm(cond.whenTrue, wrap, loopParam, loopParamBindings, profileComponentName),
114
- whenFalseArm: buildOuterArm(cond.whenFalse, wrap, loopParam, loopParamBindings, profileComponentName),
113
+ whenTrueArm: buildOuterArm(cond.whenTrue, wrap, loopParam, loopParamBindings, cond.slotId, profileComponentName),
114
+ whenFalseArm: buildOuterArm(cond.whenFalse, wrap, loopParam, loopParamBindings, cond.slotId, profileComponentName),
115
115
  ...(cond.readsPreamble && { readsPreamble: true }),
116
116
  })
117
117
  }
@@ -130,6 +130,8 @@ function buildOuterArm(
130
130
  wrap: (expr: string) => string,
131
131
  loopParam: string,
132
132
  loopParamBindings: readonly LoopParamBinding[] | undefined,
133
+ /** The conditional's own slot id — threaded to `buildBranchInnerLoopsPlan`'s `condSlotId` (#2705). */
134
+ condSlotId: string,
133
135
  profileComponentName?: string,
134
136
  ): LoopChildArmPlan {
135
137
  return {
@@ -145,6 +147,7 @@ function buildOuterArm(
145
147
  innerLoops: buildBranchInnerLoopsPlan({
146
148
  innerLoops: branch.innerLoops,
147
149
  scopeVar: '__branchScope',
150
+ condSlotId,
148
151
  outerLoopParam: loopParam,
149
152
  outerLoopParamBindings: loopParamBindings,
150
153
  wrapOuter: wrap,
@@ -17,6 +17,7 @@ import type {
17
17
  LoopParamBinding,
18
18
  } from '../../../types.ts'
19
19
  import type { LoopChildRefBinding } from './loop.ts'
20
+ import type { LoopChildConditionalPlan } from './loop-child-arm.ts'
20
21
 
21
22
  /**
22
23
  * Body-entry statements emitted in order at the top of a `mapArray`
@@ -148,6 +149,17 @@ export interface InnerLoopReactiveEmit {
148
149
  reactiveTexts: readonly InnerLoopText[]
149
150
  /** Pre-wrapped reactive attribute effects for the inner-item body. */
150
151
  reactiveAttrs: readonly InnerLoopReactiveAttr[]
152
+ /**
153
+ * Per-item conditionals inside THIS loop's own row (#2706) — each gets
154
+ * its own `insert()` call, the same insert()-parity the top-level loop's
155
+ * row conditionals already have (`ReactiveEffectsPlan.conditionals`).
156
+ * Before this field existed, a per-item conditional inside a nested
157
+ * loop's row was baked into the static row template ONCE at row
158
+ * creation and never revisited — silently frozen against later signal
159
+ * changes — while its reactive text still (unsoundly) assumed insert()
160
+ * kept the branch's marker around.
161
+ */
162
+ conditionals: readonly LoopChildConditionalPlan[]
151
163
  /** Pre-wrapped imperative ref callbacks for the inner-item body (#1244). */
152
164
  childRefs: readonly LoopChildRefBinding[]
153
165
  }
@@ -14,6 +14,7 @@
14
14
  * <indent> emitComponentAndEventSetup(...)
15
15
  * <indent> recurse on childLevels
16
16
  * <indent> reactive text effects
17
+ * <indent> per-item conditionals: insert() over __innerEl<uid> (#2706)
17
18
  * <indent> return __innerEl<uid>
18
19
  * <indent>}) }
19
20
  *
@@ -36,6 +37,7 @@ import { emitAttrUpdate } from '../../emit-reactive.ts'
36
37
  import { emitMultiRootTemplateCloneLines, namespaceWrapForTemplate } from './template-parse.ts'
37
38
  import { emitLoopChildRefs } from './loop.ts'
38
39
  import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from './claim-plan.ts'
40
+ import { stringifyLoopChildConditionals } from './loop-child-arm.ts'
39
41
  import type {
40
42
  InnerLoopPlan,
41
43
  InnerLoopsPlan,
@@ -139,6 +141,13 @@ function emitReactive(lines: string[], inner: InnerLoopPlan, indent: string, pc:
139
141
  }
140
142
  lines.push(`${indent} }${profileBindingId(pc, attr.slotId)}) }`)
141
143
  }
144
+ // Per-item conditionals inside THIS loop's own row (#2706) — each is a
145
+ // real `insert()` over `__innerEl<uid>`, not a bake-once-at-creation
146
+ // ternary. Mirrors `stringifyBranchInnerLoops`'s identical call for a
147
+ // branch-scoped inner loop's own conditionals.
148
+ if (emit.conditionals.length > 0) {
149
+ stringifyLoopChildConditionals(lines, emit.conditionals, `${indent} `, pc)
150
+ }
142
151
  // Imperative ref callbacks fire on every renderItem invocation, which
143
152
  // means every mount: SSR hydration, initial CSR creation, and same-key
144
153
  // remount after unmount (#1244).