@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.
- package/dist/adapters/test-adapter.d.ts.map +1 -1
- package/dist/index.js +179 -37
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +14 -0
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +0 -14
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +7 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +2 -2
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/types.d.ts +11 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +310 -188
- package/src/__tests__/adapter-output.test.ts +49 -0
- package/src/__tests__/client-js-generation.test.ts +5 -2
- package/src/__tests__/inline-jsx-callback.test.ts +95 -0
- package/src/__tests__/ir-jsx-props.test.ts +5 -2
- package/src/__tests__/loop-item-conditional-codegen.test.ts +81 -0
- package/src/__tests__/map-logical-jsx-helper.test.ts +159 -0
- package/src/__tests__/missing-key-in-list.test.ts +49 -0
- package/src/__tests__/reactive-attrs-in-map.test.ts +41 -0
- package/src/adapters/test-adapter.ts +16 -1
- package/src/ir-to-client-js/collect-elements.ts +3 -0
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +17 -0
- package/src/ir-to-client-js/control-flow/plan/loop.ts +14 -0
- package/src/ir-to-client-js/control-flow/stringify/insert.ts +7 -2
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +60 -0
- package/src/ir-to-client-js/emit-reactive.ts +12 -3
- package/src/ir-to-client-js/html-template.ts +29 -3
- package/src/ir-to-client-js/imports.ts +2 -2
- package/src/ir-to-client-js/reactivity.ts +17 -1
- package/src/ir-to-client-js/types.ts +7 -0
- package/src/ir-to-client-js/utils.ts +2 -1
- package/src/jsx-to-ir.ts +161 -12
- package/src/preprocess-inline-jsx-callbacks.ts +28 -10
- 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(`
|
|
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(`
|
|
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(`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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> })`
|