@barefootjs/jsx 0.5.0 → 0.5.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 (44) hide show
  1. package/dist/adapters/test-adapter.d.ts.map +1 -1
  2. package/dist/index.js +179 -37
  3. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  4. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  5. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +14 -0
  6. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  7. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/html-template.d.ts +0 -14
  10. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/imports.d.ts +2 -2
  12. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/types.d.ts +7 -0
  15. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/utils.d.ts +2 -2
  17. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  18. package/dist/types.d.ts +11 -0
  19. package/dist/types.d.ts.map +1 -1
  20. package/package.json +2 -2
  21. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +310 -188
  22. package/src/__tests__/adapter-output.test.ts +49 -0
  23. package/src/__tests__/client-js-generation.test.ts +5 -2
  24. package/src/__tests__/inline-jsx-callback.test.ts +95 -0
  25. package/src/__tests__/ir-jsx-props.test.ts +5 -2
  26. package/src/__tests__/loop-item-conditional-codegen.test.ts +81 -0
  27. package/src/__tests__/map-logical-jsx-helper.test.ts +159 -0
  28. package/src/__tests__/missing-key-in-list.test.ts +49 -0
  29. package/src/__tests__/reactive-attrs-in-map.test.ts +41 -0
  30. package/src/adapters/test-adapter.ts +16 -1
  31. package/src/ir-to-client-js/collect-elements.ts +3 -0
  32. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +17 -0
  33. package/src/ir-to-client-js/control-flow/plan/loop.ts +14 -0
  34. package/src/ir-to-client-js/control-flow/stringify/insert.ts +7 -2
  35. package/src/ir-to-client-js/control-flow/stringify/loop.ts +60 -0
  36. package/src/ir-to-client-js/emit-reactive.ts +12 -3
  37. package/src/ir-to-client-js/html-template.ts +29 -3
  38. package/src/ir-to-client-js/imports.ts +2 -2
  39. package/src/ir-to-client-js/reactivity.ts +17 -1
  40. package/src/ir-to-client-js/types.ts +7 -0
  41. package/src/ir-to-client-js/utils.ts +2 -1
  42. package/src/jsx-to-ir.ts +161 -12
  43. package/src/preprocess-inline-jsx-callbacks.ts +28 -10
  44. package/src/types.ts +12 -0
@@ -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.
package/src/jsx-to-ir.ts CHANGED
@@ -1703,6 +1703,33 @@ function containsJsxInExpression(node: ts.Node): boolean {
1703
1703
  return ts.forEachChild(node, containsJsxInExpression) ?? false
1704
1704
  }
1705
1705
 
1706
+ /**
1707
+ * Check if an expression calls a module/local JSX-returning helper (one
1708
+ * tracked in `jsxFunctions` / `jsxMultiReturnFunctions` for IR-level
1709
+ * inlining). Used alongside `containsJsxInExpression` so a map callback
1710
+ * body like `cond && themeLogo(t.id)` is recognised as renderable JSX
1711
+ * control flow even though it has no inline JSX literal (#1665).
1712
+ */
1713
+ function callsJsxHelper(node: ts.Node, ctx: TransformContext): boolean {
1714
+ let found = false
1715
+ const visit = (n: ts.Node): void => {
1716
+ if (found) return
1717
+ if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
1718
+ const name = n.expression.text
1719
+ if (
1720
+ ctx.analyzer.jsxFunctions.has(name) ||
1721
+ ctx.analyzer.jsxMultiReturnFunctions.has(name)
1722
+ ) {
1723
+ found = true
1724
+ return
1725
+ }
1726
+ }
1727
+ ts.forEachChild(n, visit)
1728
+ }
1729
+ visit(node)
1730
+ return found
1731
+ }
1732
+
1706
1733
  function containsAwaitExpression(node: ts.Node): boolean {
1707
1734
  if (ts.isAwaitExpression(node)) return true
1708
1735
  if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node)) return false
@@ -1893,7 +1920,7 @@ function transformJsxExpression(
1893
1920
  if (
1894
1921
  (node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken ||
1895
1922
  node.operatorToken.kind === ts.SyntaxKind.BarBarToken) &&
1896
- containsJsxInExpression(node.right)
1923
+ (containsJsxInExpression(node.right) || callsJsxHelper(node.right, ctx))
1897
1924
  ) {
1898
1925
  return transformNullishCoalescing(node, ctx)
1899
1926
  }
@@ -2642,18 +2669,34 @@ function checkLoopKey(
2642
2669
  }
2643
2670
  while (ts.isParenthesizedExpression(body)) body = body.expression
2644
2671
 
2672
+ // Check a JSX operand (unwrapping parentheses) if it is an element.
2673
+ function checkJsxOperand(node: ts.Node): void {
2674
+ let n = node
2675
+ while (ts.isParenthesizedExpression(n)) n = n.expression
2676
+ if (ts.isJsxElement(n)) checkOpening(n.openingElement)
2677
+ else if (ts.isJsxSelfClosingElement(n)) checkOpening(n)
2678
+ }
2679
+
2645
2680
  if (ts.isConditionalExpression(body)) {
2646
2681
  // Check both branches independently
2647
- const whenTrue = body.whenTrue
2648
- const whenFalse = body.whenFalse
2649
- let wt: ts.Node = whenTrue
2650
- let wf: ts.Node = whenFalse
2651
- while (ts.isParenthesizedExpression(wt)) wt = wt.expression
2652
- while (ts.isParenthesizedExpression(wf)) wf = wf.expression
2653
- if (ts.isJsxElement(wt)) checkOpening(wt.openingElement)
2654
- else if (ts.isJsxSelfClosingElement(wt)) checkOpening(wt)
2655
- if (ts.isJsxElement(wf)) checkOpening(wf.openingElement)
2656
- else if (ts.isJsxSelfClosingElement(wf)) checkOpening(wf)
2682
+ checkJsxOperand(body.whenTrue)
2683
+ checkJsxOperand(body.whenFalse)
2684
+ return
2685
+ }
2686
+
2687
+ // Logical `cond && <jsx>` / `cond || <jsx>` / `a ?? <jsx>` whole-item
2688
+ // conditionals (#1665). The JSX operand renders 0-or-1 element per
2689
+ // iteration and still needs a key for correct reconciliation, exactly
2690
+ // like a ternary branch. Without this case the binary-expression body
2691
+ // silently skipped key validation.
2692
+ if (
2693
+ ts.isBinaryExpression(body) &&
2694
+ (body.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken ||
2695
+ body.operatorToken.kind === ts.SyntaxKind.BarBarToken ||
2696
+ body.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken)
2697
+ ) {
2698
+ checkJsxOperand(body.left)
2699
+ checkJsxOperand(body.right)
2657
2700
  return
2658
2701
  }
2659
2702
 
@@ -2679,6 +2722,69 @@ function loopBodyIsMultiRoot(children: IRNode[]): boolean {
2679
2722
  return loopBodyIsMultiRoot(only.children)
2680
2723
  }
2681
2724
 
2725
+ /**
2726
+ * True when a conditional branch does NOT render exactly one root element —
2727
+ * the element-less side of a whole-item conditional. Covers the empty branch
2728
+ * of `cond && <li/>` (`null`) and `cond ? <li/> : null`, and the scalar side
2729
+ * of `expr || <li/>` / `expr ?? <li/>` (the left operand renders as text or
2730
+ * nothing, never a tracked element). Any such branch makes the loop item
2731
+ * render 0-or-1 element across states, which the element-tracking `mapArray`
2732
+ * cannot represent — the loop must use anchored emission instead.
2733
+ *
2734
+ * Element / component branches return `false`; a fragment is element-like
2735
+ * only when it flattens to exactly one element child.
2736
+ */
2737
+ function branchHasNoElement(node: IRNode): boolean {
2738
+ if (node.type === 'element' || node.type === 'component') return false
2739
+ if (node.type === 'conditional') {
2740
+ return branchHasNoElement(node.whenTrue) || branchHasNoElement(node.whenFalse)
2741
+ }
2742
+ if (node.type === 'fragment') {
2743
+ const real = node.children.filter(
2744
+ (c) => !(c.type === 'text' && typeof c.value === 'string' && !c.value.trim())
2745
+ )
2746
+ return real.length !== 1 || branchHasNoElement(real[0])
2747
+ }
2748
+ // expression, text, and everything else: not a single tracked element.
2749
+ return true
2750
+ }
2751
+
2752
+ /**
2753
+ * When the loop body is a single whole-item conditional with an element-less
2754
+ * branch (the #1665 shapes: `&&`, `|| <jsx>`, `?? <jsx>`, `? <jsx> : null`),
2755
+ * return that conditional so the caller can route the loop through anchored
2756
+ * emission. Returns `null` for single-element bodies and for both-branch-
2757
+ * element ternaries (which always render exactly one element and stay on the
2758
+ * legacy `mapArray` path).
2759
+ */
2760
+ function loopBodyItemConditional(children: IRNode[]): IRConditional | null {
2761
+ const real = children.filter(
2762
+ (c) => !(c.type === 'text' && typeof c.value === 'string' && !c.value.trim())
2763
+ )
2764
+ if (real.length !== 1) return null
2765
+ const only = real[0]
2766
+ if (only.type !== 'conditional') return null
2767
+ if (branchHasNoElement(only.whenTrue) || branchHasNoElement(only.whenFalse)) {
2768
+ return only
2769
+ }
2770
+ return null
2771
+ }
2772
+
2773
+ /**
2774
+ * Hoist a key expression out of a whole-item conditional for `mapArray`'s
2775
+ * keyFn. Ignores element-less branches (they carry no element and thus no
2776
+ * key) and requires the rendering branch(es) to agree on the key expression.
2777
+ * Returns `null` when no key can be determined.
2778
+ */
2779
+ function extractItemConditionalKey(cond: IRConditional): string | null {
2780
+ const a = branchHasNoElement(cond.whenTrue) ? null : extractLoopKey(cond.whenTrue)
2781
+ const b = branchHasNoElement(cond.whenFalse) ? null : extractLoopKey(cond.whenFalse)
2782
+ if (a !== null && b !== null) {
2783
+ return normalizeKeyExpr(a) === normalizeKeyExpr(b) ? a : null
2784
+ }
2785
+ return a ?? b
2786
+ }
2787
+
2682
2788
  function transformMapCall(
2683
2789
  node: ts.CallExpression,
2684
2790
  ctx: TransformContext,
@@ -2947,6 +3053,36 @@ function transformMapCall(
2947
3053
  }
2948
3054
  if (index) ctx.loopParams.add(index)
2949
3055
 
3056
+ // Logical control flow (`cond && <X/>`, `a ?? themeLogo()`) as the map
3057
+ // body. This is not a JSX literal, ternary, or block, so without this
3058
+ // the dispatch below leaves `children` empty and the whole `.map(...)`
3059
+ // falls through to the reactive-text path — emitting the callback
3060
+ // verbatim. That left inline JSX uncompiled and module-level JSX
3061
+ // helpers undeclared (ReferenceError at hydration, #1665). Route the
3062
+ // logical body through the shared JSX expression transformer, which
3063
+ // lowers it into an IRConditional and inlines any JSX helper, exactly
3064
+ // like the ternary form.
3065
+ //
3066
+ // Scoped deliberately to logical operators that actually render JSX
3067
+ // (inline literal or a tracked helper call): a bare call body
3068
+ // (`map(t => renderItem(t))`) stays on the existing reactive-text path
3069
+ // that #546 owns, and a scalar logical body (`t.active && t.label`)
3070
+ // keeps rendering its value.
3071
+ const tryTransformRenderableBody = (expr: ts.Expression): void => {
3072
+ if (!ts.isBinaryExpression(expr)) return
3073
+ const op = expr.operatorToken.kind
3074
+ if (
3075
+ op !== ts.SyntaxKind.AmpersandAmpersandToken &&
3076
+ op !== ts.SyntaxKind.BarBarToken &&
3077
+ op !== ts.SyntaxKind.QuestionQuestionToken
3078
+ ) {
3079
+ return
3080
+ }
3081
+ if (!containsJsxInExpression(expr) && !callsJsxHelper(expr, ctx)) return
3082
+ const transformed = transformJsxExpression(expr, ctx, isClientOnly)
3083
+ if (transformed) children = [transformed]
3084
+ }
3085
+
2950
3086
  // Transform callback body
2951
3087
  const body = callback.body
2952
3088
  if (ts.isJsxElement(body) || ts.isJsxSelfClosingElement(body) || ts.isJsxFragment(body)) {
@@ -2973,6 +3109,8 @@ function transformMapCall(
2973
3109
  } else if (method === 'flatMap' && ts.isArrayLiteralExpression(inner)) {
2974
3110
  // flatMap arrow with array literal: items.flatMap(item => ([<A/>, <B/>]))
2975
3111
  children = transformArrayLiteralChildren(inner, ctx)
3112
+ } else {
3113
+ tryTransformRenderableBody(inner)
2976
3114
  }
2977
3115
  } else if (method === 'flatMap' && ts.isArrayLiteralExpression(body)) {
2978
3116
  // flatMap arrow with array literal: items.flatMap(item => [<A/>, <B/>])
@@ -3025,6 +3163,8 @@ function transformMapCall(
3025
3163
  if (method === 'flatMap' && children.length === 0) {
3026
3164
  flatMapCallback = buildFlatMapCallback(callback, body, ctx)
3027
3165
  }
3166
+ } else {
3167
+ tryTransformRenderableBody(body)
3028
3168
  }
3029
3169
 
3030
3170
  // Unregister loop params
@@ -3055,7 +3195,15 @@ function transformMapCall(
3055
3195
  // same `key={EXPR}`, that EXPR is lifted out to mapArray's keyFn so a
3056
3196
  // shape change (e.g. `<polygon>` ↔ `<circle>`) replaces the DOM node
3057
3197
  // instead of mutating attributes on the wrong tag.
3058
- const key = children.length > 0 ? extractLoopKey(children[0]) : null
3198
+ // Whole-item conditional bodies (#1665): the loop item is a single
3199
+ // conditional whose at-least-one branch renders nothing, so an item shows
3200
+ // 0-or-1 element. The key lives inside the rendering branch, so hoist it
3201
+ // from there; a flag routes the loop through anchored emission downstream.
3202
+ const itemConditional = children.length > 0 ? loopBodyItemConditional(children) : null
3203
+ const bodyIsItemConditional = itemConditional !== null
3204
+ const key = bodyIsItemConditional
3205
+ ? extractItemConditionalKey(itemConditional!)
3206
+ : (children.length > 0 ? extractLoopKey(children[0]) : null)
3059
3207
 
3060
3208
  // Extract childComponent info if the loop body is a single component
3061
3209
  // This enables createComponent-based rendering with proper prop passing
@@ -3137,6 +3285,7 @@ function transformMapCall(
3137
3285
  callsReactiveGetters: callsReactive || undefined,
3138
3286
  hasFunctionCalls: hasCalls || undefined,
3139
3287
  bodyIsMultiRoot: bodyIsMultiRoot || undefined,
3288
+ bodyIsItemConditional: bodyIsItemConditional || undefined,
3140
3289
  childComponent,
3141
3290
  nestedComponents,
3142
3291
  filterPredicate,
@@ -128,22 +128,40 @@ function runSinglePass(
128
128
  }
129
129
 
130
130
  function visit(node: ts.Node): void {
131
+ // `renderNode={(n) => <div/>}` — arrow in JsxAttribute position.
131
132
  if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression) {
132
- let expr: ts.Expression = node.initializer.expression
133
- while (ts.isParenthesizedExpression(expr)) expr = expr.expression
134
- if (ts.isArrowFunction(expr) && arrowBodyContainsJsx(expr)) {
135
- const handled = handleInlineArrow(expr)
136
- if (handled) {
137
- // Don't dive into the arrow's body in this pass — the next
138
- // fixpoint iteration will see the synthesized component at
139
- // module scope and process any nested inline arrows there.
140
- return
141
- }
133
+ if (tryHandleArrowValue(node.initializer.expression)) {
134
+ // Don't dive into the arrow's body in this pass — the next
135
+ // fixpoint iteration will see the synthesized component at
136
+ // module scope and process any nested inline arrows there.
137
+ return
142
138
  }
143
139
  }
140
+ // `{ piconic: () => <BrandLogo/> }` — arrow as an object-literal
141
+ // property value (e.g. a `Record<K, () => JSX>` lookup map). Without
142
+ // this the JSX leaks untransformed into both the SSR template and the
143
+ // client bundle (#1663).
144
+ if (ts.isPropertyAssignment(node) && node.initializer) {
145
+ if (tryHandleArrowValue(node.initializer)) return
146
+ }
144
147
  ts.forEachChild(node, visit)
145
148
  }
146
149
 
150
+ /**
151
+ * If `initializer` is (a parenthesized chain wrapping) an arrow function
152
+ * whose body contains JSX, hoist it into a synthesized component and
153
+ * record the replacement. Returns true when the arrow was successfully
154
+ * hoisted, so the caller can skip recursing into the arrow body.
155
+ */
156
+ function tryHandleArrowValue(initializer: ts.Expression): boolean {
157
+ let expr: ts.Expression = initializer
158
+ while (ts.isParenthesizedExpression(expr)) expr = expr.expression
159
+ if (ts.isArrowFunction(expr) && arrowBodyContainsJsx(expr)) {
160
+ return handleInlineArrow(expr)
161
+ }
162
+ return false
163
+ }
164
+
147
165
  function handleInlineArrow(arrow: ts.ArrowFunction): boolean {
148
166
  const paramNames = collectArrowParamNames(arrow)
149
167
  const free = collectFreeIdentifiers(arrow)
package/src/types.ts CHANGED
@@ -532,6 +532,18 @@ export interface IRLoop {
532
532
  */
533
533
  bodyIsMultiRoot?: boolean
534
534
 
535
+ /**
536
+ * True when the loop body is a single whole-item conditional whose at
537
+ * least one branch renders no element (`arr.map(t => cond && <li/>)` or
538
+ * `cond ? <li/> : null`), so an item renders 0-or-1 element per pass
539
+ * (#1665). Drives anchored emission: per-item `<!--bf-loop-i:KEY-->`
540
+ * anchors in the template and a `mapArrayAnchored` call whose renderItem
541
+ * lets `insert()` own the (possibly empty) content. Single-element bodies
542
+ * and both-branch-element ternaries set this false and keep the legacy
543
+ * `mapArray` emission.
544
+ */
545
+ bodyIsItemConditional?: boolean
546
+
535
547
  /**
536
548
  * Raw JS of pre-return statements in block body .map() callback.
537
549
  * Example: `items.map(item => { const label = item.name.toUpperCase(); return <li>{label}</li> })`