@barefootjs/jsx 0.5.0 → 0.5.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 (57) hide show
  1. package/dist/adapters/test-adapter.d.ts.map +1 -1
  2. package/dist/analyzer-context.d.ts +8 -1
  3. package/dist/analyzer-context.d.ts.map +1 -1
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/expression-parser.d.ts.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +250 -59
  9. package/dist/ir-to-client-js/collect-elements.d.ts +11 -1
  10. package/dist/ir-to-client-js/collect-elements.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/loop.d.ts +14 -0
  13. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/html-template.d.ts +0 -14
  17. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/imports.d.ts +2 -2
  19. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/types.d.ts +7 -0
  22. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/utils.d.ts +2 -2
  24. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  25. package/dist/types.d.ts +17 -0
  26. package/dist/types.d.ts.map +1 -1
  27. package/package.json +2 -2
  28. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +310 -188
  29. package/src/__tests__/adapter-output.test.ts +49 -0
  30. package/src/__tests__/child-components-in-map.test.ts +43 -0
  31. package/src/__tests__/client-js-generation.test.ts +5 -2
  32. package/src/__tests__/inline-jsx-callback.test.ts +95 -0
  33. package/src/__tests__/ir-jsx-props.test.ts +5 -2
  34. package/src/__tests__/loop-item-conditional-codegen.test.ts +81 -0
  35. package/src/__tests__/map-logical-jsx-helper.test.ts +159 -0
  36. package/src/__tests__/missing-key-in-list.test.ts +49 -0
  37. package/src/__tests__/reactive-attrs-in-map.test.ts +41 -0
  38. package/src/adapters/test-adapter.ts +16 -1
  39. package/src/analyzer-context.ts +59 -13
  40. package/src/analyzer.ts +8 -0
  41. package/src/expression-parser.ts +16 -1
  42. package/src/index.ts +2 -0
  43. package/src/ir-to-client-js/collect-elements.ts +37 -15
  44. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +17 -0
  45. package/src/ir-to-client-js/control-flow/plan/loop.ts +14 -0
  46. package/src/ir-to-client-js/control-flow/stringify/insert.ts +7 -2
  47. package/src/ir-to-client-js/control-flow/stringify/loop.ts +60 -0
  48. package/src/ir-to-client-js/emit-reactive.ts +12 -3
  49. package/src/ir-to-client-js/html-template.ts +29 -3
  50. package/src/ir-to-client-js/imports.ts +2 -2
  51. package/src/ir-to-client-js/reactivity.ts +17 -1
  52. package/src/ir-to-client-js/types.ts +7 -0
  53. package/src/ir-to-client-js/utils.ts +2 -1
  54. package/src/jsx-to-ir.ts +161 -12
  55. package/src/preprocess-inline-jsx-callbacks.ts +28 -10
  56. package/src/scanner/__tests__/js-scanner.fuzz.test.ts +202 -0
  57. package/src/types.ts +18 -0
@@ -20,6 +20,7 @@ import type {
20
20
  CompilerError,
21
21
  SourceLocation,
22
22
  ParamInfo,
23
+ PropertyInfo,
23
24
  ReactiveFactoryInfo,
24
25
  } from './types'
25
26
  import { type ExcludeRange, collectAllTypeRanges, reconstructWithoutTypes } from './strip-types'
@@ -301,6 +302,46 @@ export function getSourceLocation(
301
302
  // Type Helpers
302
303
  // =============================================================================
303
304
 
305
+ /**
306
+ * Extract structured {@link PropertyInfo} entries from the members of an object
307
+ * type literal or interface declaration. Shared so the type-literal branch of
308
+ * {@link typeNodeToTypeInfo} and interface-definition collection produce
309
+ * identical field shapes.
310
+ */
311
+ export function membersToProperties(
312
+ members: ts.NodeArray<ts.TypeElement>,
313
+ sourceFile: ts.SourceFile
314
+ ): PropertyInfo[] {
315
+ return members
316
+ .filter(ts.isPropertySignature)
317
+ .map((member) => ({
318
+ name: propertyNameText(member.name, sourceFile),
319
+ type: typeNodeToTypeInfo(member.type, sourceFile) ?? {
320
+ kind: 'unknown' as const,
321
+ raw: 'unknown',
322
+ },
323
+ optional: !!member.questionToken,
324
+ readonly: !!member.modifiers?.some(
325
+ (m) => m.kind === ts.SyntaxKind.ReadonlyKeyword
326
+ ),
327
+ }))
328
+ }
329
+
330
+ /**
331
+ * The source-level name of an object/interface member. String- and numeric-
332
+ * literal keys are returned unquoted (`{ "id": ... }` → `id`), so consumers see
333
+ * the same name whether the key was written as an identifier or a string —
334
+ * `getText()` would otherwise keep the quotes.
335
+ */
336
+ function propertyNameText(
337
+ name: ts.PropertyName | undefined,
338
+ sourceFile: ts.SourceFile
339
+ ): string {
340
+ if (!name) return ''
341
+ if (ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text
342
+ return name.getText(sourceFile)
343
+ }
344
+
304
345
  export function typeNodeToTypeInfo(
305
346
  typeNode: ts.TypeNode | undefined,
306
347
  sourceFile: ts.SourceFile
@@ -352,24 +393,29 @@ export function typeNodeToTypeInfo(
352
393
  return {
353
394
  kind: 'object',
354
395
  raw,
355
- properties: typeNode.members
356
- .filter(ts.isPropertySignature)
357
- .map((member) => ({
358
- name: member.name?.getText(sourceFile) ?? '',
359
- type: typeNodeToTypeInfo(member.type, sourceFile) ?? {
360
- kind: 'unknown',
361
- raw: 'unknown',
362
- },
363
- optional: !!member.questionToken,
364
- readonly: !!member.modifiers?.some(
365
- (m) => m.kind === ts.SyntaxKind.ReadonlyKeyword
366
- ),
367
- })),
396
+ properties: membersToProperties(typeNode.members, sourceFile),
368
397
  }
369
398
  }
370
399
 
371
400
  // Type reference (named type)
372
401
  if (ts.isTypeReferenceNode(typeNode)) {
402
+ // Normalise the generic array forms `Array<T>` / `ReadonlyArray<T>` to the
403
+ // same `kind: 'array'` shape as `T[]`, so every consumer sees one array
404
+ // representation regardless of how the source spelled it.
405
+ const refName = ts.isIdentifier(typeNode.typeName) ? typeNode.typeName.text : ''
406
+ if (
407
+ (refName === 'Array' || refName === 'ReadonlyArray') &&
408
+ typeNode.typeArguments?.length === 1
409
+ ) {
410
+ return {
411
+ kind: 'array',
412
+ raw,
413
+ elementType: typeNodeToTypeInfo(typeNode.typeArguments[0], sourceFile) ?? {
414
+ kind: 'unknown',
415
+ raw: 'unknown',
416
+ },
417
+ }
418
+ }
373
419
  return {
374
420
  kind: 'interface',
375
421
  raw,
package/src/analyzer.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  createAnalyzerContext,
17
17
  getSourceLocation,
18
18
  typeNodeToTypeInfo,
19
+ membersToProperties,
19
20
  isComponentFunction,
20
21
  isArrowComponentFunction,
21
22
  } from './analyzer-context'
@@ -1782,6 +1783,7 @@ function collectInterfaceDefinition(
1782
1783
  kind: 'interface',
1783
1784
  name: node.name.text,
1784
1785
  definition: node.getText(ctx.sourceFile),
1786
+ properties: membersToProperties(node.members, ctx.sourceFile),
1785
1787
  loc: getSourceLocation(node, ctx.sourceFile, ctx.filePath),
1786
1788
  })
1787
1789
  }
@@ -1790,10 +1792,16 @@ function collectTypeAliasDefinition(
1790
1792
  node: ts.TypeAliasDeclaration,
1791
1793
  ctx: AnalyzerContext
1792
1794
  ): void {
1795
+ // Only object-type aliases carry structured fields; other aliases
1796
+ // (string-literal unions, etc.) have no field set to record.
1797
+ const properties = ts.isTypeLiteralNode(node.type)
1798
+ ? membersToProperties(node.type.members, ctx.sourceFile)
1799
+ : undefined
1793
1800
  ctx.typeDefinitions.push({
1794
1801
  kind: 'type',
1795
1802
  name: node.name.text,
1796
1803
  definition: node.getText(ctx.sourceFile),
1804
+ properties,
1797
1805
  loc: getSourceLocation(node, ctx.sourceFile, ctx.filePath),
1798
1806
  })
1799
1807
  }
@@ -214,6 +214,21 @@ const UNSUPPORTED_METHODS = new Set([
214
214
  // `bf_lower` / `bf_upper` (Go) and Perl's native `lc` / `uc` (Mojo).
215
215
  // `trim` lowers via the `array-method` IR + `bf_trim` (Go) and a
216
216
  // Perl regex strip (Mojo).
217
+ //
218
+ // #1448 follow-up — String methods that have NO lowering yet. These
219
+ // were previously absent from this gate, so `isSupported` reported
220
+ // them "supported" and the adapters emitted a raw method call
221
+ // (`{{.Name.StartsWith "a"}}` on Go, `$name->{startsWith}('a')` on
222
+ // Mojo) with no build diagnostic — a silent footgun that only
223
+ // surfaced as a crash at template-render time. Listing them here
224
+ // makes the build fail loudly with BF101 (the same treatment the
225
+ // unsupported array methods above get), pointing users at the
226
+ // `/* @client */` escape hatch. Each name drops off as its lowering
227
+ // lands. See #1448 "Unsupported string methods" Tier B / Tier C.
228
+ 'split', 'startsWith', 'endsWith', 'replace', 'replaceAll',
229
+ 'repeat', 'padStart', 'padEnd',
230
+ 'charAt', 'charCodeAt', 'codePointAt', 'normalize',
231
+ 'substring', 'substr', 'match', 'matchAll', 'search',
217
232
  ])
218
233
 
219
234
  // =============================================================================
@@ -1724,7 +1739,7 @@ function checkSupport(expr: ParsedExpr): SupportResult {
1724
1739
  return {
1725
1740
  supported: false,
1726
1741
  level: 'L5_UNSUPPORTED',
1727
- reason: `Higher-order method '${methodName}()' requires client-side evaluation. Use @client directive or pre-compute in Go.`,
1742
+ reason: `Method '${methodName}()' has no template lowering and requires client-side evaluation. Wrap the expression in /* @client */ to defer it to hydration, or pre-compute the value before rendering.`,
1728
1743
  }
1729
1744
  }
1730
1745
  }
package/src/index.ts CHANGED
@@ -40,7 +40,9 @@ export type {
40
40
  IRTemplatePart,
41
41
  IRProp,
42
42
  ParamInfo,
43
+ PropertyInfo,
43
44
  TypeInfo,
45
+ TypeDefinition,
44
46
  SourceLocation,
45
47
  CompilerError,
46
48
  } from './types'
@@ -20,31 +20,50 @@ function producesDomChild(node: IRNode): boolean {
20
20
 
21
21
  /**
22
22
  * Pre-pass: for every loop node in the IR tree, record the number of non-loop
23
- * DOM siblings that appear before it in its parent element. Read when
23
+ * DOM siblings that appear before it in its parent container. Read when
24
24
  * constructing TopLevelLoop and NestedLoop so the client JS can offset
25
25
  * children[idx] access past statically-rendered siblings.
26
26
  *
27
+ * Counting must happen for every container whose children render as a
28
+ * contiguous run of DOM siblings into the same parent — not just `element`.
29
+ * A loop nested directly inside a component (`<Wrapper><span/>{xs.map(...)}`
30
+ * </Wrapper>`), fragment, provider, or async boundary has its preceding
31
+ * static sibling rendered as a sibling of the loop's items too, so
32
+ * `children[idx]` access is shifted exactly as it is under an element parent
33
+ * (#1688). Before this, a static sibling before a `.map()` inside a
34
+ * (self-portaling) component dropped the first item's nested child component
35
+ * during hydration because the offset was silently zero.
36
+ *
27
37
  * Computed once up front (instead of during collection) so the offset data
28
38
  * lives in an explicit value rather than a module-level WeakMap mutated by
29
39
  * two separate traversals.
30
40
  */
31
41
  export function computeLoopSiblingOffsets(root: IRNode): Map<IRLoop, number> {
32
42
  const offsets = new Map<IRLoop, number>()
33
- walkIR(root, null, {
34
- element: ({ node: el, descend }) => {
35
- let nonLoopCount = 0
36
- for (const child of el.children) {
37
- if (child.type === 'loop') {
38
- if (nonLoopCount > 0) offsets.set(child, nonLoopCount)
39
- } else if (producesDomChild(child)) {
40
- nonLoopCount++
41
- }
43
+ const recordChildren = (children: IRNode[]): void => {
44
+ let nonLoopCount = 0
45
+ for (const child of children) {
46
+ if (child.type === 'loop') {
47
+ if (nonLoopCount > 0) offsets.set(child, nonLoopCount)
48
+ } else if (producesDomChild(child)) {
49
+ nonLoopCount++
42
50
  }
43
- descend()
44
- },
45
- // All container kinds (fragment / component / provider / async / loop /
46
- // conditional / if-statement) rely on walkIR's default descent with the
47
- // same scope. Leaves (text / expression / slot) are no-ops.
51
+ }
52
+ }
53
+ const containerVisit = ({ node, descend }: { node: { children: IRNode[] }; descend: () => void }): void => {
54
+ recordChildren(node.children)
55
+ descend()
56
+ }
57
+ walkIR(root, null, {
58
+ element: containerVisit,
59
+ component: containerVisit,
60
+ fragment: containerVisit,
61
+ provider: containerVisit,
62
+ async: containerVisit,
63
+ // `loop` / `conditional` / `if-statement` are not flat sibling
64
+ // containers (their children are item bodies / branches), and leaves
65
+ // (text / expression / slot) have no children — all rely on walkIR's
66
+ // default descent with the same scope.
48
67
  })
49
68
  return offsets
50
69
  }
@@ -231,6 +250,7 @@ export function collectInnerLoops(
231
250
  key: n.key,
232
251
  markerId: n.markerId,
233
252
  bodyIsMultiRoot: n.bodyIsMultiRoot,
253
+ bodyIsItemConditional: n.bodyIsItemConditional,
234
254
  iterationShape: n.iterationShape,
235
255
  containerSlotId: scope.parentSlotId,
236
256
  template,
@@ -564,6 +584,7 @@ export function collectElements(
564
584
  key: l.key,
565
585
  markerId: l.markerId,
566
586
  bodyIsMultiRoot: l.bodyIsMultiRoot,
587
+ bodyIsItemConditional: l.bodyIsItemConditional,
567
588
  iterationShape: l.iterationShape,
568
589
  template,
569
590
  staticItemTemplate,
@@ -898,6 +919,7 @@ function collectBranchLoops(
898
919
  key: n.key,
899
920
  markerId: n.markerId,
900
921
  bodyIsMultiRoot: n.bodyIsMultiRoot,
922
+ bodyIsItemConditional: n.bodyIsItemConditional,
901
923
  iterationShape: n.iterationShape,
902
924
  template: childTemplate,
903
925
  containerSlotId: containerSlot,
@@ -56,6 +56,15 @@ export interface BuildLoopPlanOptions {
56
56
  * described above and returns the discriminated `LoopPlan`.
57
57
  */
58
58
  export function buildLoopPlan(elem: TopLevelLoop, opts: BuildLoopPlanOptions): LoopPlan {
59
+ // Whole-item conditional bodies (#1665) render 0-or-1 element per item, so
60
+ // they need anchored `mapArrayAnchored` emission regardless of whether the
61
+ // array is static or dynamic. Routing both through the plain (anchored)
62
+ // path keeps `const arr` and `signal()` behaviour identical — a static
63
+ // array's per-item conditional still toggles reactively instead of freezing
64
+ // in the SSR-time `forEach` (which has no conditional handling at all).
65
+ if (elem.bodyIsItemConditional) {
66
+ return buildPlainLoopPlan(elem)
67
+ }
59
68
  if (elem.isStaticArray) {
60
69
  return buildStaticLoopPlan(elem, opts.unsafeLocalNames)
61
70
  }
@@ -92,6 +101,14 @@ export function buildPlainLoopPlan(elem: TopLevelLoop): PlainLoopPlan {
92
101
  reactiveEffects: hasReactive ? buildLoopReactiveEffectsPlan(elem) : null,
93
102
  childRefs: buildChildRefBindings(elem.bindings.refs, elem.param, elem.paramBindings),
94
103
  bodyIsMultiRoot: elem.bodyIsMultiRoot ?? false,
104
+ anchored: elem.bodyIsItemConditional ?? false,
105
+ // Fall back to the iteration index when the loop has no key. A whole-item
106
+ // conditional without a key is a BF023 error, but the emitted client JS
107
+ // must still parse — an empty `anchorKeyExpr` would produce
108
+ // `createComment(`bf-loop-i:${}`)` (a SyntaxError that breaks the whole
109
+ // bundle). `elem.index || '__idx'` matches `indexParam` above, so the
110
+ // anchor value stays consistent with the renderItem's own index param.
111
+ anchorKeyExpr: elem.key ? wrap(elem.key) : (elem.index || '__idx'),
95
112
  }
96
113
  }
97
114
 
@@ -95,6 +95,20 @@ interface PlainLoopVariant extends DynamicLoopCommon {
95
95
  * `<!--bf-loop-i-->` marker emission, and `qsaItem` slot lookups (#1212).
96
96
  */
97
97
  bodyIsMultiRoot: boolean
98
+ /**
99
+ * True when the loop body is a whole-item conditional (#1665). Switches
100
+ * emission to `mapArrayAnchored`: the renderItem returns a fragment headed
101
+ * by a `<!--bf-loop-i:KEY-->` anchor and seeded with the conditional's
102
+ * markers, and `insert(anchor, …)` (not `insert(__el, …)`) owns the
103
+ * possibly-empty content.
104
+ */
105
+ anchored: boolean
106
+ /**
107
+ * Key expression wrapped as a loop-param accessor (`t().id`), used to bake
108
+ * the per-item `bf-loop-i:KEY` anchor value inside the anchored renderItem.
109
+ * Empty when the loop has no key (only meaningful when `anchored`).
110
+ */
111
+ anchorKeyExpr: string
98
112
  }
99
113
 
100
114
  /**
@@ -163,10 +163,15 @@ function emitArmBody(
163
163
 
164
164
  for (const te of body.textEffects) {
165
165
  const v = varSlotId(te.slotId)
166
- lines.push(`${indent}const [__el_${v}] = $t(__branchScope, '${te.slotId}')`)
166
+ // Route through `__bfText` so a JSX-valued expression (`{cond && logo(id)}`)
167
+ // re-splices the live element by identity instead of stringifying it to
168
+ // "[object HTMLElement]" — the branch template already spliced it via
169
+ // `__bfSlot`, and this effect re-renders it when its deps change (#1663).
170
+ // The `let` tracker carries the replaced node across reactive re-runs.
171
+ lines.push(`${indent}let __anchor_${v} = $t(__branchScope, '${te.slotId}')[0]`)
167
172
  lines.push(`${indent}__disposers.push(createDisposableEffect(() => {`)
168
173
  lines.push(`${indent} const __val = ${te.expression}`)
169
- lines.push(`${indent} if (__el_${v} && !__val?.__isSlot) __el_${v}.nodeValue = String(__val ?? '')`)
174
+ lines.push(`${indent} __anchor_${v} = __bfText(__anchor_${v}, __val)`)
170
175
  lines.push(`${indent}}))`)
171
176
  }
172
177
 
@@ -120,8 +120,19 @@ export function stringifyPlainLoop(
120
120
  reactiveEffects,
121
121
  childRefs,
122
122
  bodyIsMultiRoot,
123
+ anchored,
124
+ anchorKeyExpr,
123
125
  } = plan
124
126
 
127
+ // Whole-item conditional loops (#1665) render 0-or-1 element per item, so
128
+ // they route through `mapArrayAnchored`. The renderItem returns a fragment
129
+ // headed by a `<!--bf-loop-i:KEY-->` anchor and seeded with the
130
+ // conditional's markers; `insert(__anchor, …)` then owns the content.
131
+ if (anchored) {
132
+ stringifyAnchoredLoop(lines, plan, topIndent, anchorKeyExpr)
133
+ return
134
+ }
135
+
125
136
  // `childRefs` need `__el` as a handle to invoke the user's callback inside
126
137
  // the factory, so non-empty refs force the multi-line layout the same way
127
138
  // reactive effects do (#1244).
@@ -155,6 +166,55 @@ export function stringifyPlainLoop(
155
166
  lines.push(`${topIndent}}, '${markerId}')`)
156
167
  }
157
168
 
169
+ /**
170
+ * Emit a whole-item conditional loop via `mapArrayAnchored` (#1665).
171
+ *
172
+ * The renderItem identifies each item by an always-present
173
+ * `<!--bf-loop-i:KEY-->` anchor instead of a root element (which the item may
174
+ * not have). On CSR it returns a `DocumentFragment` of
175
+ * `[anchor, bf-cond-start, bf-cond-end]` so `insert()`'s first run has the
176
+ * markers to populate; on hydration (`__existing` is the SSR anchor Comment)
177
+ * it returns that anchor and `insert()` adopts the SSR-rendered content. The
178
+ * conditional itself is emitted by the shared reactive-effects stringifier
179
+ * with `elVar: '__anchor'`, so `insert(__anchor, …)` range-scopes the
180
+ * toggle to this item.
181
+ */
182
+ function stringifyAnchoredLoop(
183
+ lines: string[],
184
+ plan: PlainLoopPlan,
185
+ topIndent: string,
186
+ anchorKeyExpr: string,
187
+ ): void {
188
+ const {
189
+ containerVar, markerId, arrayExpr, keyFn,
190
+ paramHead, paramUnwrap, indexParam, mapPreambleWrapped, reactiveEffects,
191
+ } = plan
192
+
193
+ // The single whole-item conditional supplies the slot id used to seed the
194
+ // CSR markers so `insert()`'s first run can find and populate them.
195
+ const condSlot = reactiveEffects?.conditionals[0]?.slotId ?? null
196
+
197
+ lines.push(`${topIndent}mapArrayAnchored(() => ${arrayExpr}, ${containerVar}, ${keyFn}, (${paramHead}, ${indexParam}, __existing) => {`)
198
+ const bodyIndent = topIndent + ' '
199
+ if (paramUnwrap) lines.push(`${bodyIndent}${paramUnwrap}`)
200
+ if (mapPreambleWrapped) lines.push(`${bodyIndent}${mapPreambleWrapped}`)
201
+ lines.push(`${bodyIndent}const __anchor = __existing ?? document.createComment(\`bf-loop-i:\${${anchorKeyExpr}}\`)`)
202
+ lines.push(`${bodyIndent}let __frag = null`)
203
+ lines.push(`${bodyIndent}if (!__existing) {`)
204
+ lines.push(`${bodyIndent} __frag = document.createDocumentFragment()`)
205
+ lines.push(`${bodyIndent} __frag.appendChild(__anchor)`)
206
+ if (condSlot) {
207
+ lines.push(`${bodyIndent} __frag.appendChild(document.createComment('bf-cond-start:${condSlot}'))`)
208
+ lines.push(`${bodyIndent} __frag.appendChild(document.createComment('bf-cond-end:${condSlot}'))`)
209
+ }
210
+ lines.push(`${bodyIndent}}`)
211
+ if (reactiveEffects !== null) {
212
+ stringifyReactiveEffects(lines, reactiveEffects, { indent: bodyIndent, elVar: '__anchor', bodyIsMultiRoot: false })
213
+ }
214
+ lines.push(`${bodyIndent}return __frag ?? __anchor`)
215
+ lines.push(`${topIndent}}, '${markerId}')`)
216
+ }
217
+
158
218
  export function stringifyStaticLoop(lines: string[], plan: StaticLoopPlan): void {
159
219
  const { containerVar, arrayExpr, param, indexParam, childIndexExpr, attrsBySlot, texts, childRefs, csrMaterialize } = plan
160
220
  const hasAttrs = attrsBySlot.length > 0
@@ -92,18 +92,27 @@ export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): v
92
92
  const normalElems = elems.filter(e => !e.insideConditional)
93
93
 
94
94
  if (normalElems.length > 0 || conditionalElems.length > 0) {
95
+ // Persistent slot trackers for non-conditional elements. `__bfText`
96
+ // returns the node now occupying the slot; a JSX-valued expression
97
+ // (`{themeLogo(id)}`) replaces the text node with a live element, so
98
+ // the next reactive run must operate on that element, not the stale
99
+ // text node (#1663). Primitive values keep the same text node.
100
+ for (const elem of normalElems) {
101
+ const v = varSlotId(elem.slotId)
102
+ lines.push(` let __anchor_${v} = _${v}`)
103
+ }
95
104
  lines.push(` createEffect(() => {`)
96
105
  if (normalElems.length > 0) {
97
106
  // Expression is always evaluated for non-conditional elements
98
107
  lines.push(` const __val = ${expr}`)
99
108
  for (const elem of normalElems) {
100
109
  const v = varSlotId(elem.slotId)
101
- lines.push(` if (_${v} && !__val?.__isSlot) _${v}.nodeValue = String(__val ?? '')`)
110
+ lines.push(` __anchor_${v} = __bfText(__anchor_${v}, __val)`)
102
111
  }
103
112
  for (const elem of conditionalElems) {
104
113
  const v = varSlotId(elem.slotId)
105
114
  lines.push(` const [__el_${v}] = $t(__scope, '${elem.slotId}')`)
106
- lines.push(` if (__el_${v} && !__val?.__isSlot) __el_${v}.nodeValue = String(__val ?? '')`)
115
+ lines.push(` __bfText(__el_${v}, __val)`)
107
116
  }
108
117
  } else {
109
118
  // Only conditional elements — evaluate expression unconditionally
@@ -118,7 +127,7 @@ export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): v
118
127
  for (const elem of conditionalElems) {
119
128
  const v = varSlotId(elem.slotId)
120
129
  lines.push(` const [__el_${v}] = $t(__scope, '${elem.slotId}')`)
121
- lines.push(` if (__el_${v} && !__val?.__isSlot) __el_${v}.nodeValue = String(__val ?? '')`)
130
+ lines.push(` __bfText(__el_${v}, __val)`)
122
131
  }
123
132
  }
124
133
  lines.push(` })`)
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { AttrValue, IRAttribute, IRNode } from '../types'
6
6
  import { isBooleanAttr } from '../html-constants'
7
- import { toHtmlAttrName, attrValueToString, quotePropName, PROPS_PARAM, DATA_BF_PH, keyAttrName, loopStartMarker, loopEndMarker, freeIdsFromRefs, setIntersects, wrapExprWithLoopParams } from './utils'
7
+ import { toHtmlAttrName, attrValueToString, quotePropName, PROPS_PARAM, DATA_BF_PH, keyAttrName, loopStartMarker, loopEndMarker, loopItemMarker, freeIdsFromRefs, setIntersects, wrapExprWithLoopParams } from './utils'
8
8
  import type { LoopParamSpec } from './utils'
9
9
  import { nameForRegistryRef } from './component-scope'
10
10
  import { assertNever } from './walker'
@@ -411,6 +411,17 @@ function buildSpreadAttrsMergeCall(args: {
411
411
  * `generateCsrTemplate` (case `'component'`). Set to `true` when generating
412
412
  * the per-iteration `staticItemTemplate` for static loops.
413
413
  */
414
+ /**
415
+ * Build the per-item `<!--bf-loop-i:KEY-->` anchor comment for a whole-item
416
+ * conditional loop (#1665), where `keyExpr` is the loop's per-item key
417
+ * expression (e.g. `t.id`). Emits a live `${keyExpr}` interpolation so each
418
+ * rendered item carries its own key — `loopItemMarker` is reserved for
419
+ * already-evaluated key strings (runtime / static contexts).
420
+ */
421
+ function itemAnchorTemplate(keyExpr: string): string {
422
+ return `<!--${loopItemMarker('${' + keyExpr + '}')}-->`
423
+ }
424
+
414
425
  export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, loopDepth = 0, loopParams?: ReadonlyArray<string | LoopParamSpec>, branchSlotsVar?: string, insideLoop = false, inHoistedChildren = false): string {
415
426
  const recurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, insideLoop, inHoistedChildren)
416
427
  const wrapExpr = (expr: string) => wrapExprWithLoopParams(expr, loopParams)
@@ -558,7 +569,16 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
558
569
  // Case 1 — childComponent body materialize); propagating it through
559
570
  // every nested loop regressed form-builder's inner-loop Select wiring.
560
571
  const innerRecurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar, insideLoop)
561
- const childTemplate = node.children.map(innerRecurse).join('')
572
+ let childTemplate = node.children.map(innerRecurse).join('')
573
+ // Whole-item conditional loops (#1665): prepend an always-present
574
+ // `<!--bf-loop-i:KEY-->` anchor before each item's (possibly empty)
575
+ // conditional content. `mapArrayAnchored` tracks items by this anchor,
576
+ // so an item that renders no element still keeps its identity and slot.
577
+ // The key is a per-item expression, so the marker carries a live
578
+ // `${KEY}` interpolation (not the literal key text).
579
+ if (node.bodyIsItemConditional && node.key) {
580
+ childTemplate = `${itemAnchorTemplate(node.key)}${childTemplate}`
581
+ }
562
582
  const indexParam = node.index ? `, ${node.index}` : ''
563
583
  // Apply chained sort / filter for the SSR-mirror template (#1448
564
584
  // Tier B). Pre-Tier-B this just used `node.array` directly,
@@ -1499,7 +1519,13 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
1499
1519
  }
1500
1520
 
1501
1521
  case 'loop': {
1502
- const childTemplate = node.children.map(recurseInLoop).join('')
1522
+ let childTemplate = node.children.map(recurseInLoop).join('')
1523
+ // Whole-item conditional loops (#1665): prepend the per-item
1524
+ // `<!--bf-loop-i:KEY-->` anchor so `mapArrayAnchored` can track items
1525
+ // that render no element. Mirrors the `irToHtmlTemplate` loop case.
1526
+ if (node.bodyIsItemConditional && node.key) {
1527
+ childTemplate = `${itemAnchorTemplate(node.key)}${childTemplate}`
1528
+ }
1503
1529
  const indexParam = node.index ? `, ${node.index}` : ''
1504
1530
  // An init-scope-only array would `undefined.map(...)` ⇒ TypeError.
1505
1531
  // Substitute an empty array; init's reconcile pass populates the loop
@@ -7,12 +7,12 @@ import type { ComponentIR, IRNode } from '../types'
7
7
  // All exports from @barefootjs/client/runtime that may be used in generated code
8
8
  export const RUNTIME_IMPORT_CANDIDATES = [
9
9
  'createSignal', 'createMemo', 'createEffect', 'onCleanup', 'onMount',
10
- 'hydrate', 'insert', 'reconcileElements', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'createDisposableEffect',
10
+ 'hydrate', 'insert', 'reconcileElements', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'mapArrayAnchored', 'createDisposableEffect',
11
11
  'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild', 'updateClientMarker',
12
12
  'createPortal',
13
13
  'provideContext', 'createContext', 'useContext',
14
14
  'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss',
15
- 'qsa', 'qsaItem', 'qsaChildScope', 'qsaChildScopes', 'upsertChildItem', '__slot', '__bfSlot',
15
+ 'qsa', 'qsaItem', 'qsaChildScope', 'qsaChildScopes', 'upsertChildItem', '__slot', '__bfSlot', '__bfText',
16
16
  ] as const
17
17
 
18
18
  /** @deprecated Use RUNTIME_IMPORT_CANDIDATES */
@@ -574,7 +574,23 @@ export function collectLoopChildReactiveAttrs(
574
574
  // SSR template strips the attribute (html-template) and no
575
575
  // hydrate-time binding is emitted, leaving the per-item
576
576
  // attribute permanently unset.
577
- if (!attr.clientOnly && classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind === 'none') continue
577
+ //
578
+ // `classifyReactivity` only proves reactivity for the loop item
579
+ // accessor or a *directly* read signal/memo/prop. It does NOT see
580
+ // through an opaque helper that reads an outer signal by index
581
+ // (e.g. `widthAt(i)` where `const widthAt = (i) => items()[i].w`).
582
+ // The top-level attribute path (`decideWrapForAttr`) wraps those
583
+ // anyway via the Solid-style AST-flag fallback (#940); without the
584
+ // same fallback here, the identical binding on a per-item element
585
+ // freezes at its SSR value (#1673). Apply the same `callsReactiveGetters`
586
+ // / `hasFunctionCalls` fallback so the loop-child path matches the
587
+ // top-level one — a harmless over-wrap at worst (an effect that
588
+ // subscribes to nothing runs once).
589
+ const reactive =
590
+ classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind !== 'none'
591
+ || attr.callsReactiveGetters
592
+ || attr.hasFunctionCalls
593
+ if (!attr.clientOnly && !reactive) continue
578
594
  attrs.push({
579
595
  childSlotId: el.slotId,
580
596
  attrName: attr.name,
@@ -200,6 +200,13 @@ export interface LoopCore {
200
200
  * key tracks all of its DOM nodes (#1212).
201
201
  */
202
202
  bodyIsMultiRoot?: boolean
203
+ /**
204
+ * True when the loop body is a single whole-item conditional whose at
205
+ * least one branch renders no element (#1665). Routes the loop through
206
+ * the anchored emission path (`mapArrayAnchored` + per-item
207
+ * `<!--bf-loop-i:KEY-->` anchors) so 0-or-1-element items reconcile.
208
+ */
209
+ bodyIsItemConditional?: boolean
203
210
  /**
204
211
  * Pre-computed free identifiers referenced by the `array` expression
205
212
  * (#1267). Populated during IR build from the originating AST node so
@@ -21,10 +21,11 @@ import {
21
21
  BF_LOOP_END,
22
22
  loopStartMarker,
23
23
  loopEndMarker,
24
+ loopItemMarker,
24
25
  toHTMLAttrName as toHtmlAttrName,
25
26
  } from '@barefootjs/shared'
26
27
 
27
- export { DATA_KEY, DATA_KEY_PREFIX, DATA_BF_PH, BF_LOOP_START, BF_LOOP_END, loopStartMarker, loopEndMarker, toHtmlAttrName }
28
+ export { DATA_KEY, DATA_KEY_PREFIX, DATA_BF_PH, BF_LOOP_START, BF_LOOP_END, loopStartMarker, loopEndMarker, loopItemMarker, toHtmlAttrName }
28
29
 
29
30
  /**
30
31
  * Parameter name for the props object in generated init/template functions.