@barefootjs/jsx 0.26.3 → 0.26.4
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/index.js +433 -55
- package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +12 -1
- package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.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/event-delegation.d.ts +16 -1
- package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +27 -0
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/shared.d.ts +12 -1
- package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +19 -0
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +49 -1
- 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 +26 -1
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/types.d.ts +47 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +187 -37
- package/src/__tests__/client-js-generation.test.ts +8 -2
- package/src/__tests__/compiler-runtime-contract.test.ts +4 -4
- package/src/__tests__/delegated-handler-preamble.test.ts +153 -0
- package/src/__tests__/flatmap-segments.test.ts +182 -0
- package/src/__tests__/map-body-no-silent-divergence.test.ts +45 -0
- package/src/__tests__/preamble-region-patch.test.ts +150 -0
- package/src/__tests__/static-loop-csr-materialize.test.ts +3 -2
- package/src/ir-to-client-js/build-references.ts +16 -0
- package/src/ir-to-client-js/collect-elements.ts +66 -10
- package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +12 -1
- package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +11 -3
- package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +19 -3
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +36 -0
- package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +16 -1
- package/src/ir-to-client-js/control-flow/plan/loop.ts +28 -0
- package/src/ir-to-client-js/control-flow/shared.ts +26 -1
- package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +25 -3
- package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +67 -19
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +65 -1
- package/src/ir-to-client-js/html-template.ts +115 -6
- package/src/ir-to-client-js/imports.ts +1 -1
- package/src/ir-to-client-js/reactivity.ts +6 -0
- package/src/ir-to-client-js/types.ts +24 -0
- package/src/jsx-to-ir.ts +374 -8
- package/src/types.ts +49 -0
|
@@ -74,6 +74,26 @@ function indexBindingLine(handler: string, indexParam: string | null, indexExpr:
|
|
|
74
74
|
return `const ${indexParam} = ${indexExpr}`
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Splice-only-when-referenced (#3, BUG-3 fix part 2): a preamble is dead
|
|
79
|
+
* weight for an event whose handler never reads one of the names it
|
|
80
|
+
* declares — the common case (an unused array builder like `cells` in the
|
|
81
|
+
* BUG-3 repro). Only emit `mapPreamble` when `handler`'s free identifiers
|
|
82
|
+
* intersect `declaredNames`; otherwise the delegated handler pays nothing
|
|
83
|
+
* for a preamble it never uses. `declaredNames` is empty whenever
|
|
84
|
+
* `mapPreamble` is `null`, so the `!mapPreamble` short-circuit is mostly
|
|
85
|
+
* redundant but keeps this correct-by-construction if that ever changes.
|
|
86
|
+
*/
|
|
87
|
+
function preambleLineForHandler(
|
|
88
|
+
mapPreamble: string | null,
|
|
89
|
+
declaredNames: readonly string[],
|
|
90
|
+
handler: string,
|
|
91
|
+
): string | null {
|
|
92
|
+
if (!mapPreamble || declaredNames.length === 0) return null
|
|
93
|
+
const free = extractFreeIdentifiersFromText(handler)
|
|
94
|
+
return declaredNames.some((name) => free.has(name)) ? mapPreamble : null
|
|
95
|
+
}
|
|
96
|
+
|
|
77
97
|
export function stringifyEventDelegation(lines: string[], plan: EventDelegationPlan): void {
|
|
78
98
|
const { containerVar, events, itemLookup, profileComponentName } = plan
|
|
79
99
|
const eventsByName = new Map<string, LoopChildEvent[]>()
|
|
@@ -133,7 +153,8 @@ function emitKeyedLookup(
|
|
|
133
153
|
handlerCall: string,
|
|
134
154
|
lookup: KeyedItemLookup,
|
|
135
155
|
): void {
|
|
136
|
-
const { arrayExpr, param, keyWithItem, mapPreamble, hasBindings, indexParam } = lookup
|
|
156
|
+
const { arrayExpr, param, keyWithItem, mapPreamble, mapPreambleDeclaredNames, hasBindings, indexParam } = lookup
|
|
157
|
+
const preambleLine = preambleLineForHandler(mapPreamble, mapPreambleDeclaredNames, ev.handler)
|
|
137
158
|
|
|
138
159
|
if (ev.nestedLoops.length === 0) {
|
|
139
160
|
// Single-level keyed lookup.
|
|
@@ -146,15 +167,28 @@ function emitKeyedLookup(
|
|
|
146
167
|
ls.push(` const __bfLoopItem = ${arrayExpr}.find(item => String(${keyWithItem}) === key)`)
|
|
147
168
|
ls.push(` if (__bfLoopItem) {`)
|
|
148
169
|
ls.push(` const ${param} = __bfLoopItem`)
|
|
149
|
-
if (
|
|
170
|
+
if (preambleLine) ls.push(` ${preambleLine}`)
|
|
150
171
|
if (idxLine) ls.push(` ${idxLine}`)
|
|
151
|
-
|
|
172
|
+
// Leading `;` (not just relying on the preceding line's own
|
|
173
|
+
// semicolon): `handlerCall` always starts with `(` — an ASI hazard
|
|
174
|
+
// pre-existing in this branch even with neither optional line above
|
|
175
|
+
// (`const ${param} = __bfLoopItem` has no trailing `;`), which glues
|
|
176
|
+
// the call onto it as `__bfLoopItem(...)` and throws
|
|
177
|
+
// `TypeError: __bfLoopItem is not a function`. Defend at the one
|
|
178
|
+
// emission point rather than chasing every preceding-line shape.
|
|
179
|
+
ls.push(` ;${handlerCall}`)
|
|
152
180
|
ls.push(` }`)
|
|
153
181
|
} else {
|
|
182
|
+
// The preamble (when referenced) and the handler call both run INSIDE
|
|
183
|
+
// the item null guard — a `.find()` miss (stale-DOM race, e.g. the
|
|
184
|
+
// clicked row's key no longer in the current array) must short-circuit
|
|
185
|
+
// before a preamble that dereferences the item ever runs (BUG-4).
|
|
154
186
|
ls.push(` const ${param} = ${arrayExpr}.find(item => String(${keyWithItem}) === key)`)
|
|
155
|
-
|
|
156
|
-
if (
|
|
157
|
-
|
|
187
|
+
ls.push(` if (${param}) {`)
|
|
188
|
+
if (preambleLine) ls.push(` ${preambleLine}`)
|
|
189
|
+
if (idxLine) ls.push(` ${idxLine}`)
|
|
190
|
+
ls.push(` ;${handlerCall}`)
|
|
191
|
+
ls.push(` }`)
|
|
158
192
|
}
|
|
159
193
|
ls.push(` }`)
|
|
160
194
|
return
|
|
@@ -187,10 +221,14 @@ function emitKeyedLookup(
|
|
|
187
221
|
}
|
|
188
222
|
const outerGuard = hasBindings ? '__bfLoopItem' : param
|
|
189
223
|
const allParams = [outerGuard, ...ev.nestedLoops.map(n => n.param)]
|
|
190
|
-
|
|
224
|
+
// Preamble and idx binding run INSIDE the combined item guard (BUG-4) —
|
|
225
|
+
// a nested `.find()` miss must short-circuit before the preamble runs.
|
|
191
226
|
const idxLine = indexBindingLine(ev.handler, indexParam, `${arrayExpr}.findIndex(item => String(${keyWithItem}) === outerKey)`)
|
|
192
|
-
|
|
193
|
-
|
|
227
|
+
ls.push(` if (${allParams.join(' && ')}) {`)
|
|
228
|
+
if (preambleLine) ls.push(` ${preambleLine}`)
|
|
229
|
+
if (idxLine) ls.push(` ${idxLine}`)
|
|
230
|
+
ls.push(` ;${handlerCall}`)
|
|
231
|
+
ls.push(` }`)
|
|
194
232
|
}
|
|
195
233
|
|
|
196
234
|
function emitDynamicIndexLookup(
|
|
@@ -199,7 +237,8 @@ function emitDynamicIndexLookup(
|
|
|
199
237
|
handlerCall: string,
|
|
200
238
|
lookup: DynamicIndexItemLookup,
|
|
201
239
|
): void {
|
|
202
|
-
const { arrayExpr, param, mapPreamble, hasBindings, indexParam } = lookup
|
|
240
|
+
const { arrayExpr, param, mapPreamble, mapPreambleDeclaredNames, hasBindings, indexParam } = lookup
|
|
241
|
+
const preambleLine = preambleLineForHandler(mapPreamble, mapPreambleDeclaredNames, ev.handler)
|
|
203
242
|
const idxLine = indexBindingLine(ev.handler, indexParam, 'idx')
|
|
204
243
|
ls.push(` const li = ${varSlotId(ev.childSlotId)}El.closest('li, [bf-i]')`)
|
|
205
244
|
ls.push(` if (li && li.parentElement) {`)
|
|
@@ -208,15 +247,19 @@ function emitDynamicIndexLookup(
|
|
|
208
247
|
ls.push(` const __bfLoopItem = ${arrayExpr}[idx]`)
|
|
209
248
|
ls.push(` if (__bfLoopItem) {`)
|
|
210
249
|
ls.push(` const ${param} = __bfLoopItem`)
|
|
211
|
-
if (
|
|
250
|
+
if (preambleLine) ls.push(` ${preambleLine}`)
|
|
212
251
|
if (idxLine) ls.push(` ${idxLine}`)
|
|
213
|
-
ls.push(`
|
|
252
|
+
ls.push(` ;${handlerCall}`)
|
|
214
253
|
ls.push(` }`)
|
|
215
254
|
} else {
|
|
255
|
+
// Preamble and idx binding run INSIDE the item null guard (BUG-4) — an
|
|
256
|
+
// out-of-range index (stale-DOM race) must short-circuit first.
|
|
216
257
|
ls.push(` const ${param} = ${arrayExpr}[idx]`)
|
|
217
|
-
|
|
218
|
-
if (
|
|
219
|
-
|
|
258
|
+
ls.push(` if (${param}) {`)
|
|
259
|
+
if (preambleLine) ls.push(` ${preambleLine}`)
|
|
260
|
+
if (idxLine) ls.push(` ${idxLine}`)
|
|
261
|
+
ls.push(` ;${handlerCall}`)
|
|
262
|
+
ls.push(` }`)
|
|
220
263
|
}
|
|
221
264
|
ls.push(` }`)
|
|
222
265
|
}
|
|
@@ -228,7 +271,8 @@ function emitStaticIndexLookup(
|
|
|
228
271
|
lookup: StaticIndexItemLookup,
|
|
229
272
|
containerVar: string,
|
|
230
273
|
): void {
|
|
231
|
-
const { arrayExpr, param, mapPreamble, offset, indexParam } = lookup
|
|
274
|
+
const { arrayExpr, param, mapPreamble, mapPreambleDeclaredNames, offset, indexParam } = lookup
|
|
275
|
+
const preambleLine = preambleLineForHandler(mapPreamble, mapPreambleDeclaredNames, ev.handler)
|
|
232
276
|
const idxLine = indexBindingLine(ev.handler, indexParam, '__idx')
|
|
233
277
|
ls.push(` let __el = ${varSlotId(ev.childSlotId)}El`)
|
|
234
278
|
ls.push(` while (__el.parentElement && __el.parentElement !== ${containerVar}) __el = __el.parentElement`)
|
|
@@ -236,8 +280,12 @@ function emitStaticIndexLookup(
|
|
|
236
280
|
const idxOffset = buildLoopChildIndexSubtraction(offset ?? undefined)
|
|
237
281
|
ls.push(` const __idx = Array.from(${containerVar}.children).indexOf(__el)${idxOffset}`)
|
|
238
282
|
ls.push(` const ${param} = ${arrayExpr}[__idx]`)
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
283
|
+
// Preamble and idx binding run INSIDE the item null guard (BUG-4) — an
|
|
284
|
+
// out-of-range index (stale-DOM race) must short-circuit first.
|
|
285
|
+
ls.push(` if (${param}) {`)
|
|
286
|
+
if (preambleLine) ls.push(` ${preambleLine}`)
|
|
287
|
+
if (idxLine) ls.push(` ${idxLine}`)
|
|
288
|
+
ls.push(` ;${handlerCall}`)
|
|
289
|
+
ls.push(` }`)
|
|
242
290
|
ls.push(` }`)
|
|
243
291
|
}
|
|
@@ -32,6 +32,7 @@ import { buildSkeletonPathPlan, type SkeletonPathPlan } from './skeleton-paths.t
|
|
|
32
32
|
import { stringifyComponentLoop } from './component-loop.ts'
|
|
33
33
|
import { stringifyCompositeLoop } from './composite-loop.ts'
|
|
34
34
|
import type { LoopChildRefBinding, LoopPlan, PlainLoopPlan, StaticLoopPlan } from '../plan/types.ts'
|
|
35
|
+
import type { PreambleRegionPlan } from '../plan/loop.ts'
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
38
|
* Emit `(callback)(__rf)` for each ref on a per-item slot, looking up the
|
|
@@ -64,6 +65,40 @@ export function emitLoopChildRefs(
|
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Emit the region-patch effect for each preamble-patched region (#2389 —
|
|
70
|
+
* `arr.map(t => { const cells = []; ...; return <tr>{cells}<td>{t.name}</td></tr> })`).
|
|
71
|
+
* The effect re-runs the (loop-param-accessor-wrapped) preamble on every
|
|
72
|
+
* reactive tick so it re-reads the current per-item signal, recomputes the
|
|
73
|
+
* region's value, and — past the FIRST run (which only records, trusting
|
|
74
|
+
* the SSR/CSR mount-time content already in the DOM) — patches the DOM
|
|
75
|
+
* range via `patchSlotRange(__el, 'sN', html)`. The marker lookup lives
|
|
76
|
+
* inside `patchSlotRange`, so a row that never changes pays zero lookup
|
|
77
|
+
* cost at mount/adoption.
|
|
78
|
+
*
|
|
79
|
+
* Non-empty `regions` forces the caller's multi-line renderItem layout (the
|
|
80
|
+
* effect needs `__el` as a query root), mirroring `emitLoopChildRefs`.
|
|
81
|
+
*/
|
|
82
|
+
export function emitPreambleRegionEffects(
|
|
83
|
+
lines: string[],
|
|
84
|
+
regions: readonly PreambleRegionPlan[],
|
|
85
|
+
mapPreambleWrapped: string,
|
|
86
|
+
opts: { indent: string; elVar: string },
|
|
87
|
+
): void {
|
|
88
|
+
if (regions.length === 0) return
|
|
89
|
+
const { indent, elVar } = opts
|
|
90
|
+
for (const region of regions) {
|
|
91
|
+
const v = varSlotId(region.slotId)
|
|
92
|
+
lines.push(`${indent}{ let __last_${v}`)
|
|
93
|
+
lines.push(`${indent}createEffect(() => {`)
|
|
94
|
+
if (mapPreambleWrapped) lines.push(`${indent} ${mapPreambleWrapped}`)
|
|
95
|
+
lines.push(`${indent} const __html_${v} = ${region.valueExpr}`)
|
|
96
|
+
lines.push(`${indent} if (__last_${v} === undefined) { __last_${v} = __html_${v}; return }`)
|
|
97
|
+
lines.push(`${indent} if (__html_${v} !== __last_${v}) { __last_${v} = __html_${v}; patchSlotRange(${elVar}, '${region.slotId}', __html_${v}) }`)
|
|
98
|
+
lines.push(`${indent}}) }`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
67
102
|
/**
|
|
68
103
|
* Single dispatch over `LoopPlan` (#1253). Narrows on `plan.kind` and
|
|
69
104
|
* delegates to the per-variant stringifier. Callers should consume this
|
|
@@ -128,8 +163,33 @@ export function stringifyPlainLoop(
|
|
|
128
163
|
bodyIsMultiRoot,
|
|
129
164
|
anchored,
|
|
130
165
|
anchorKeyExpr,
|
|
166
|
+
preambleRegions,
|
|
131
167
|
} = plan
|
|
132
168
|
|
|
169
|
+
// flatMap descriptor mode: the accessor flattens the source through the
|
|
170
|
+
// callback body (each leaf a `({ k, h })` descriptor), mapArray keys on
|
|
171
|
+
// `d.k`, and each item element is built from — and patched against — its
|
|
172
|
+
// rendered HTML. Hydration adopts the SSR leaf in place (`__last` seeds
|
|
173
|
+
// undefined so the first effect run records without patching — trust SSR,
|
|
174
|
+
// same contract as plain rows); a later same-key HTML change patches the
|
|
175
|
+
// element wholesale via `patchLeaf` (flatMap leaves carry no per-slot
|
|
176
|
+
// wiring by construction — the compiler refuses leaves that would).
|
|
177
|
+
if (plan.flatMapLeafItem) {
|
|
178
|
+
const loopBfIdArg = plan.profileLoopId ? `, ${JSON.stringify(plan.profileLoopId)}` : ''
|
|
179
|
+
lines.push(`${topIndent}mapArray(() => ${arrayExpr}, ${containerVar}, ${keyFn}, (__bfD, ${indexParam}, __existing) => {`)
|
|
180
|
+
lines.push(`${topIndent} let __el = __existing`)
|
|
181
|
+
lines.push(`${topIndent} if (!__el) { const __tpl = document.createElement('template'); __tpl.innerHTML = __bfD().h; __el = __tpl.content.firstElementChild }`)
|
|
182
|
+
lines.push(`${topIndent} let __last = __existing ? undefined : __bfD().h`)
|
|
183
|
+
lines.push(`${topIndent} createEffect(() => {`)
|
|
184
|
+
lines.push(`${topIndent} const __html = __bfD().h`)
|
|
185
|
+
lines.push(`${topIndent} if (__last === undefined) { __last = __html; return }`)
|
|
186
|
+
lines.push(`${topIndent} if (__html !== __last) { __last = __html; patchLeaf(__el, __html) }`)
|
|
187
|
+
lines.push(`${topIndent} })`)
|
|
188
|
+
lines.push(`${topIndent} return __el`)
|
|
189
|
+
lines.push(`${topIndent}}, '${markerId}'${loopBfIdArg})`)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
133
193
|
// Whole-item conditional loops (#1665) render 0-or-1 element per item, so
|
|
134
194
|
// they route through `mapArrayAnchored`. The renderItem returns a fragment
|
|
135
195
|
// headed by a `<!--bf-loop-i:KEY-->` anchor and seeded with the
|
|
@@ -158,7 +218,10 @@ export function stringifyPlainLoop(
|
|
|
158
218
|
// the factory, so non-empty refs force the multi-line layout the same way
|
|
159
219
|
// reactive effects do (#1244).
|
|
160
220
|
const loopBfId = plan.profileLoopId ? `, ${JSON.stringify(plan.profileLoopId)}` : ''
|
|
161
|
-
|
|
221
|
+
// Preamble-patched regions (#2389) need `__el` as a query root for their
|
|
222
|
+
// effect, so non-empty regions force the multi-line layout — same
|
|
223
|
+
// precedent as `childRefs` above.
|
|
224
|
+
if (reactiveEffects === null && !bodyIsMultiRoot && childRefs.length === 0 && preambleRegions.length === 0) {
|
|
162
225
|
// Single-line renderItem (no reactive effects, single root, no refs).
|
|
163
226
|
const unwrapInline = paramUnwrap ? `${paramUnwrap} ` : ''
|
|
164
227
|
const preamble = mapPreambleWrapped ? `${mapPreambleWrapped}; ` : ''
|
|
@@ -217,6 +280,7 @@ export function stringifyPlainLoop(
|
|
|
217
280
|
})
|
|
218
281
|
}
|
|
219
282
|
emitLoopChildRefs(lines, childRefs, { indent: bodyIndent, elVar: '__el', bodyIsMultiRoot, elementIndexBySlot: pathPlan?.elementIndexBySlot })
|
|
283
|
+
emitPreambleRegionEffects(lines, preambleRegions, mapPreambleWrapped, { indent: bodyIndent, elVar: '__el' })
|
|
220
284
|
lines.push(`${bodyIndent}return __el`)
|
|
221
285
|
lines.push(`${topIndent}}, '${markerId}'${loopBfId})`)
|
|
222
286
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* IR → HTML template string generation and validation.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { AttrValue, IRAttribute, IRNode, IRProp, MapCallbackPreamble } from '../types.ts'
|
|
5
|
+
import type { AttrValue, FlatMapCallback, IRAttribute, IRNode, IRProp, MapCallbackPreamble } from '../types.ts'
|
|
6
6
|
import { isBooleanAttr } from '../html-constants.ts'
|
|
7
7
|
import { toHtmlAttrName, attrValueToString, quotePropName, PROPS_PARAM, DATA_BF_PH, keyAttrName, loopStartMarker, loopEndMarker, loopItemMarker, freeIdsFromRefs, setIntersects, wrapExprWithLoopParams } from './utils.ts'
|
|
8
8
|
import type { LoopParamSpec } from './utils.ts'
|
|
@@ -276,9 +276,14 @@ function templateAttrExpr(attrName: string, valExpr: string, presenceOrUndefined
|
|
|
276
276
|
// `data-key` / `data-key-N` is a reconciliation contract — every loop item
|
|
277
277
|
// must carry one. Emit unconditionally; if the user passes `key={undefined}`
|
|
278
278
|
// we want it to surface as `data-key="undefined"` (and ultimately a runtime
|
|
279
|
-
// assertion in mapArray) rather than silently fall back to "no key"
|
|
279
|
+
// assertion in mapArray) rather than silently fall back to "no key" —
|
|
280
|
+
// `escapeAttr(undefined)` stringifies to exactly that. The value is escaped
|
|
281
|
+
// like every other dynamic attribute: SSR adapters escape it (their
|
|
282
|
+
// template engines do), so an unescaped `"` in a key — surfaced by the
|
|
283
|
+
// flatmap-expression-body fixture's adversarial keys — corrupted the
|
|
284
|
+
// client-assembled HTML and diverged from the SSR bytes.
|
|
280
285
|
if (attrName === 'data-key' || attrName.startsWith('data-key-')) {
|
|
281
|
-
return `${attrName}="\${${valExpr}}"`
|
|
286
|
+
return `${attrName}="\${${escapeAttrValueExpr(valExpr)}}"`
|
|
282
287
|
}
|
|
283
288
|
return `\${(${valExpr}) != null ? '${attrName}="' + ${escapeAttrValueExpr(valExpr)} + '"' : ''}`
|
|
284
289
|
}
|
|
@@ -575,6 +580,12 @@ export function renderPreamble(
|
|
|
575
580
|
transformJs?: (text: string) => string
|
|
576
581
|
/** Context-appropriate leaf renderer (an irToHtmlTemplate variant). */
|
|
577
582
|
renderLeaf: (ir: IRNode) => string
|
|
583
|
+
/**
|
|
584
|
+
* When true, `renderLeaf` output is spliced verbatim — the renderer
|
|
585
|
+
* supplies its own delimiters (e.g. the flatMap descriptor form
|
|
586
|
+
* `({ k, h })`). Default wraps each leaf in a template literal.
|
|
587
|
+
*/
|
|
588
|
+
rawLeaf?: boolean
|
|
578
589
|
},
|
|
579
590
|
): string {
|
|
580
591
|
let out = ''
|
|
@@ -582,6 +593,8 @@ export function renderPreamble(
|
|
|
582
593
|
if (seg.kind === 'js') {
|
|
583
594
|
const text = opts.textVariant === 'template' ? (seg.templateText ?? seg.text) : seg.text
|
|
584
595
|
out += opts.transformJs ? opts.transformJs(text) : text
|
|
596
|
+
} else if (opts.rawLeaf) {
|
|
597
|
+
out += opts.renderLeaf(escapeLeafTextExpressions(seg.ir))
|
|
585
598
|
} else {
|
|
586
599
|
out += '`' + opts.renderLeaf(escapeLeafTextExpressions(seg.ir)) + '`'
|
|
587
600
|
}
|
|
@@ -589,6 +602,96 @@ export function renderPreamble(
|
|
|
589
602
|
return out
|
|
590
603
|
}
|
|
591
604
|
|
|
605
|
+
/**
|
|
606
|
+
* Project a flatMap segment leaf's `key={...}` attribute into a runtime key
|
|
607
|
+
* expression, or `null` when the leaf declares none. Template-literal keys
|
|
608
|
+
* (`key={\`${it.id}:${tag}\`}`) are first-class here — unlike the loop-level
|
|
609
|
+
* `extractLoopKey`, the expression is evaluated inside the flatMap body where
|
|
610
|
+
* the callback params are in scope, so any expression form works.
|
|
611
|
+
*/
|
|
612
|
+
export function flatMapLeafKeyExpr(ir: IRNode): string | null {
|
|
613
|
+
if (ir.type !== 'element') return null
|
|
614
|
+
const keyAttr = ir.attrs.find((a) => a.name === 'key')
|
|
615
|
+
if (!keyAttr) return null
|
|
616
|
+
switch (keyAttr.value.kind) {
|
|
617
|
+
case 'expression':
|
|
618
|
+
return `(${keyAttr.value.expr})`
|
|
619
|
+
case 'literal':
|
|
620
|
+
return JSON.stringify(keyAttr.value.value)
|
|
621
|
+
case 'template':
|
|
622
|
+
return attrValueToString(keyAttr.value)
|
|
623
|
+
default:
|
|
624
|
+
return null
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Copy of `ir` with the loop `key` attribute removed (element leaves only). */
|
|
629
|
+
function stripLeafKeyAttr(ir: IRNode): IRNode {
|
|
630
|
+
if (ir.type !== 'element') return ir
|
|
631
|
+
return { ...ir, attrs: ir.attrs.filter((a) => a.name !== 'key') }
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Render a `FlatMapCallback` as the CLIENT-SIDE descriptor body for
|
|
636
|
+
* `mapArray` (the reconciliation twin of the string-template rendering in
|
|
637
|
+
* `irToHtmlTemplate`'s `'loop'` case). Each JSX leaf becomes
|
|
638
|
+
* `({ k: <keyExpr>, h: \`<html>\` })` — the flatMap flattens descriptors,
|
|
639
|
+
* `mapArray` keys on `d.k` (index fallback), and the emitted renderItem
|
|
640
|
+
* builds/patches the leaf element from `d.h`.
|
|
641
|
+
*
|
|
642
|
+
* Runs in the init scope where the loop SOURCE items are plain values (the
|
|
643
|
+
* flatMap executes inside the `mapArray` accessor, BEFORE per-item signals
|
|
644
|
+
* exist), so neither js segments nor leaf HTML get the accessor wrap — refs
|
|
645
|
+
* stay `t.title`, not `t().title`. `data-key` is deliberately NOT emitted in
|
|
646
|
+
* the leaf HTML: reconciliation identity is stamped by `mapArray` via
|
|
647
|
+
* `setAttribute`, matching the SSR side (which never emits it for flatMap
|
|
648
|
+
* leaves).
|
|
649
|
+
*/
|
|
650
|
+
export function renderFlatMapClientBody(
|
|
651
|
+
cb: Pick<FlatMapCallback, 'segments'>,
|
|
652
|
+
restSpreadNames?: Set<string>,
|
|
653
|
+
): string {
|
|
654
|
+
return renderPreamble(cb, {
|
|
655
|
+
textVariant: 'client',
|
|
656
|
+
rawLeaf: true,
|
|
657
|
+
renderLeaf: (ir) => {
|
|
658
|
+
const key = flatMapLeafKeyExpr(ir)
|
|
659
|
+
const html = irToHtmlTemplate(stripLeafKeyAttr(ir), restSpreadNames, 1, undefined, undefined, true)
|
|
660
|
+
return `({ k: ${key ?? 'undefined'}, h: \`${html}\` })`
|
|
661
|
+
},
|
|
662
|
+
})
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** True when any segment leaf declares a `key` — drives the mapArray keyFn. */
|
|
666
|
+
export function flatMapCallbackHasKeyedLeaf(cb: Pick<FlatMapCallback, 'segments'>): boolean {
|
|
667
|
+
return cb.segments.some((s) => s.kind === 'jsx' && flatMapLeafKeyExpr(s.ir) !== null)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Synthesize the client descriptor body for a flatMap PROJECTION loop —
|
|
672
|
+
* one whose only child is a nested `IRLoop` lowered from
|
|
673
|
+
* `flatMap(it => it.tags.map(tag => <li/>))`. The neutral IR is the single
|
|
674
|
+
* carrier: SSR adapters templatize the nested loop natively, and this
|
|
675
|
+
* derives the `mapArray` accessor's flatten projection from the SAME inner
|
|
676
|
+
* loop — `<chained-inner>.map((tag, i) => ({ k: <inner.key>, h: `<leaf>` }))`.
|
|
677
|
+
* Runs in the accessor context (plain source items, no per-item signals),
|
|
678
|
+
* so leaf refs stay unwrapped. Leaf `key` attrs were already stripped at IR
|
|
679
|
+
* build (`stripLoopLeafKeyAttrs`); the inner loop's `key` FIELD supplies
|
|
680
|
+
* `k`.
|
|
681
|
+
*/
|
|
682
|
+
export function renderFlatMapProjectionClientBody(
|
|
683
|
+
inner: Extract<IRNode, { type: 'loop' }>,
|
|
684
|
+
restSpreadNames?: Set<string>,
|
|
685
|
+
): string {
|
|
686
|
+
const chained = applyLoopChain(inner)
|
|
687
|
+
const params = inner.index ? `(${inner.param}, ${inner.index})` : `(${inner.param})`
|
|
688
|
+
const key = inner.key ? `(${inner.key})` : 'undefined'
|
|
689
|
+
const html = inner.children
|
|
690
|
+
.map((c) => irToHtmlTemplate(escapeLeafTextExpressions(c), restSpreadNames, 1, undefined, undefined, true))
|
|
691
|
+
.join('')
|
|
692
|
+
return `${chained}.map(${params} => ({ k: ${key}, h: \`${html}\` }))`
|
|
693
|
+
}
|
|
694
|
+
|
|
592
695
|
/**
|
|
593
696
|
* SSR/CSR escaping parity for preamble leaves, decided once at the door: a
|
|
594
697
|
* JSX-runtime SSR adapter renders the leaf's raw JSX and auto-escapes text
|
|
@@ -816,8 +919,12 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
|
|
|
816
919
|
if (node.flatMapCallback) {
|
|
817
920
|
// Complex flatMap: the body is structured segments, rendered through
|
|
818
921
|
// the same single door as map preambles.
|
|
922
|
+
// Leaf `key` is stripped from the string form: SSR (Hono rawBody)
|
|
923
|
+
// never emits data-key for flatMap leaves, and reconciliation
|
|
924
|
+
// identity is stamped by mapArray via setAttribute — emitting it
|
|
925
|
+
// here was the CSR/SSR data-key asymmetry (unescaped, client-only).
|
|
819
926
|
const body = renderPreamble(node.flatMapCallback, {
|
|
820
|
-
renderLeaf: (ir) => irToHtmlTemplate(ir, restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar, insideLoop),
|
|
927
|
+
renderLeaf: (ir) => irToHtmlTemplate(stripLeafKeyAttr(ir), restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar, insideLoop),
|
|
821
928
|
})
|
|
822
929
|
mapExpr = `\${${wrappedArray}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
|
|
823
930
|
} else if (node.preamble) {
|
|
@@ -1263,7 +1370,8 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
|
|
|
1263
1370
|
let mapExpr: string
|
|
1264
1371
|
if (node.flatMapCallback) {
|
|
1265
1372
|
const body = renderPreamble(node.flatMapCallback, {
|
|
1266
|
-
|
|
1373
|
+
// Leaf `key` stripped — see the irToHtmlTemplate site above.
|
|
1374
|
+
renderLeaf: (ir) => irToPlaceholderTemplate(stripLeafKeyAttr(ir), restSpreadNames, loopDepth + 1, loopParams),
|
|
1267
1375
|
})
|
|
1268
1376
|
mapExpr = `\${${wrappedArray}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
|
|
1269
1377
|
} else if (node.preamble) {
|
|
@@ -2386,7 +2494,8 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
|
|
|
2386
2494
|
const body = renderPreamble(node.flatMapCallback, {
|
|
2387
2495
|
textVariant: 'template',
|
|
2388
2496
|
transformJs: (t) => applyPropsRewrite(t, propsObjectName ?? null),
|
|
2389
|
-
|
|
2497
|
+
// Leaf `key` stripped — see the irToHtmlTemplate site above.
|
|
2498
|
+
renderLeaf: (ir) => recurseInLoopBody(stripLeafKeyAttr(ir)),
|
|
2390
2499
|
})
|
|
2391
2500
|
mapExpr = `\${${iterArrayExpr}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
|
|
2392
2501
|
} else if (node.preamble) {
|
|
@@ -8,7 +8,7 @@ import { isClientBuiltinName } from '../builtins.ts'
|
|
|
8
8
|
// All exports from @barefootjs/client/runtime that may be used in generated code
|
|
9
9
|
export const RUNTIME_IMPORT_CANDIDATES = [
|
|
10
10
|
'createSignal', 'createMemo', 'createEffect', 'onCleanup', 'onMount',
|
|
11
|
-
'hydrate', 'insert', 'reconcileElements', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'mapArrayAnchored', 'createDisposableEffect',
|
|
11
|
+
'hydrate', 'insert', 'reconcileElements', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'mapArrayAnchored', 'patchLeaf', 'patchSlotRange', 'createDisposableEffect',
|
|
12
12
|
'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild', 'updateClientMarker',
|
|
13
13
|
'createPortal',
|
|
14
14
|
'provideContext', 'createContext', 'useContext',
|
|
@@ -570,6 +570,12 @@ export function collectLoopChildReactiveTexts(
|
|
|
570
570
|
...stopAt<boolean>('loop', 'async', 'ifStatement'),
|
|
571
571
|
expression: ({ node: n, scope: insideConditional }) => {
|
|
572
572
|
if (!n.slotId) return
|
|
573
|
+
// #2389 — a preamble-patched region (see `IRLoop.preambleRegions`) has
|
|
574
|
+
// its own `patchSlotRange`-based effect (`preambleRegions` in the
|
|
575
|
+
// client-JS loop plan); it must never ALSO become a `reactiveTexts`
|
|
576
|
+
// entry, which would patch it via `.textContent` and corrupt markup
|
|
577
|
+
// (a `joinArrayChild` region's value is raw HTML, not text).
|
|
578
|
+
if (n.preambleRegion) return
|
|
573
579
|
const originFreeIds = freeIdsFromRefs(n.origin?.freeRefs)
|
|
574
580
|
const expanded = expandConstantForReactivity(n.expr, ctx, originFreeIds)
|
|
575
581
|
// Include if expression reads signals OR references the loop parameter
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
TypeInfo,
|
|
24
24
|
TypeDefinition,
|
|
25
25
|
MapCallbackPreamble,
|
|
26
|
+
PreambleRegionSource,
|
|
26
27
|
} from '../types.ts'
|
|
27
28
|
import type { CsrInlinabilityMap } from './csr-substitute.ts'
|
|
28
29
|
import type { SkeletonSlotPaths } from './html-template.ts'
|
|
@@ -310,6 +311,29 @@ export interface LoopCore {
|
|
|
310
311
|
* `IRLoop.objectIteration`.
|
|
311
312
|
*/
|
|
312
313
|
objectIteration?: 'entries' | 'keys' | 'values'
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Client-side descriptor body for a flatMap loop carried as structured
|
|
317
|
+
* segments (`IRLoop.flatMapCallback`). `body` is the callback body
|
|
318
|
+
* rendered so each JSX leaf becomes a `({ k, h })` descriptor
|
|
319
|
+
* (`renderFlatMapClientBody`); the plan builder wires
|
|
320
|
+
* `mapArray(() => <chained>.flatMap(<params> => <body>), …)` so the
|
|
321
|
+
* runtime reconciles the FLATTENED leaves — never the un-flattened
|
|
322
|
+
* source items (which loses leaves at hydration and crashes on adds
|
|
323
|
+
* against an empty item template). `keyed` is true when any leaf
|
|
324
|
+
* declares a `key`, driving the `d.k`-based keyFn.
|
|
325
|
+
*/
|
|
326
|
+
flatMapClient?: { params: string; body: string; keyed: boolean }
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Loop-body expression children classified as preamble-patched regions
|
|
330
|
+
* (#2389), threaded from `IRLoop.preambleRegions`. Consumed only by the
|
|
331
|
+
* top-level and branch **plain** loop-plan builders (`build-loop.ts`,
|
|
332
|
+
* `build-branch-loop.ts`) — the composite / component / static / anchored
|
|
333
|
+
* shapes leave this unconsumed (stale — same pre-existing freeze, not a
|
|
334
|
+
* new regression) pending a follow-up.
|
|
335
|
+
*/
|
|
336
|
+
preambleRegions?: readonly PreambleRegionSource[]
|
|
313
337
|
}
|
|
314
338
|
|
|
315
339
|
/**
|