@barefootjs/jsx 0.26.3 → 0.27.0
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/compiler.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +735 -140
- package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
- package/dist/ir-to-client-js/client-only-elision.d.ts +103 -0
- package/dist/ir-to-client-js/client-only-elision.d.ts.map +1 -0
- 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 +13 -2
- package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/claim-plan.d.ts +54 -0
- package/dist/ir-to-client-js/control-flow/stringify/claim-plan.d.ts.map +1 -0
- 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/inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/insert.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +19 -3
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +63 -4
- package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow.d.ts +1 -1
- package/dist/ir-to-client-js/element-refs.d.ts +8 -3
- package/dist/ir-to-client-js/element-refs.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts +8 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +73 -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 +35 -2
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/types.d.ts +70 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +555 -678
- package/src/__tests__/child-components-in-map.test.ts +4 -4
- package/src/__tests__/client-js-generation.test.ts +26 -12
- package/src/__tests__/compiler-runtime-contract.test.ts +4 -4
- package/src/__tests__/composite-branch-loop.test.ts +2 -2
- package/src/__tests__/cross-file-client-signal.test.ts +5 -1
- package/src/__tests__/delegated-handler-preamble.test.ts +153 -0
- package/src/__tests__/early-return-scope-var-ref.test.ts +10 -11
- package/src/__tests__/flatmap-segments.test.ts +182 -0
- package/src/__tests__/inline-jsx-callback.test.ts +6 -4
- package/src/__tests__/ir-jsx-props.test.ts +6 -5
- package/src/__tests__/loop-fallback-wrap.test.ts +3 -4
- package/src/__tests__/map-body-no-silent-divergence.test.ts +45 -0
- package/src/__tests__/module-level-signal.audit.test.ts +7 -2
- package/src/__tests__/nested-loop-conditional.test.ts +9 -7
- package/src/__tests__/nested-loop-reactive-attrs.test.ts +2 -2
- package/src/__tests__/preamble-region-patch.test.ts +156 -0
- package/src/__tests__/profile-cond-binding-ids.test.ts +3 -2
- package/src/__tests__/profile-loop-binding-ids.test.ts +4 -3
- package/src/__tests__/profile-nested-binding-ids.test.ts +11 -6
- package/src/__tests__/reactive-attrs-in-map.test.ts +6 -4
- package/src/__tests__/static-loop-csr-materialize.test.ts +3 -2
- package/src/__tests__/template-closure.test.ts +1 -1
- package/src/compiler.ts +11 -0
- package/src/index.ts +1 -0
- package/src/ir-to-client-js/build-references.ts +16 -0
- package/src/ir-to-client-js/client-only-elision.ts +273 -0
- package/src/ir-to-client-js/collect-elements.ts +74 -14
- 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 +27 -2
- package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +34 -4
- package/src/ir-to-client-js/control-flow/stringify/claim-plan.ts +68 -0
- package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +67 -19
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +18 -9
- package/src/ir-to-client-js/control-flow/stringify/insert.ts +17 -11
- package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +35 -16
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +80 -14
- package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +241 -31
- package/src/ir-to-client-js/control-flow.ts +1 -1
- package/src/ir-to-client-js/element-refs.ts +9 -11
- package/src/ir-to-client-js/emit-reactive.ts +50 -14
- package/src/ir-to-client-js/html-template.ts +144 -15
- package/src/ir-to-client-js/imports.ts +7 -4
- package/src/ir-to-client-js/reactivity.ts +7 -0
- package/src/ir-to-client-js/types.ts +34 -2
- package/src/ir-to-client-js/utils.ts +1 -1
- package/src/jsx-to-ir.ts +382 -15
- package/src/types.ts +72 -0
|
@@ -9,6 +9,7 @@ import type { AttrMeta, IRMetadata } from '../types.ts'
|
|
|
9
9
|
import { isBooleanAttr } from '../html-constants.ts'
|
|
10
10
|
import type { ClientJsContext } from './types.ts'
|
|
11
11
|
import { toHtmlAttrName, varSlotId, PROPS_PARAM } from './utils.ts'
|
|
12
|
+
import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from './control-flow/stringify/claim-plan.ts'
|
|
12
13
|
import { createTemplateAwareStringProtector } from './html-template.ts'
|
|
13
14
|
import { datePlugin, DATE_METHODS } from '../date-lowering.ts'
|
|
14
15
|
import { toLocaleDatePlugin, foldedArgToClientJs } from '../to-locale-date-lowering.ts'
|
|
@@ -303,24 +304,44 @@ export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): v
|
|
|
303
304
|
const normalElems = elems.filter(e => !e.insideConditional)
|
|
304
305
|
|
|
305
306
|
if (normalElems.length > 0 || conditionalElems.length > 0) {
|
|
306
|
-
// Persistent slot trackers for non-conditional elements. `__bfText`
|
|
307
|
-
// returns the node now occupying the slot; a JSX-valued expression
|
|
308
|
-
// (`{themeLogo(id)}`) replaces the text node with a live element, so
|
|
309
|
-
// the next reactive run must operate on that element, not the stale
|
|
310
|
-
// text node (#1663). Primitive values keep the same text node.
|
|
311
|
-
for (const elem of normalElems) {
|
|
312
|
-
const v = varSlotId(elem.slotId)
|
|
313
|
-
lines.push(` let __anchor_${v} = _${v}`)
|
|
314
|
-
}
|
|
315
307
|
const __textSlot = (normalElems[0] ?? conditionalElems[0])?.slotId
|
|
308
|
+
|
|
309
|
+
// Non-conditional elements: ONE claimed 'markup' writer covering every
|
|
310
|
+
// slot this expression feeds (slot unification A3, row-level claim at
|
|
311
|
+
// `__scope` — spec/slot-unification.md §3(a)/§5-A3). 'markup' (not
|
|
312
|
+
// 'text') because the value may be a live Node (e.g. a JSX-returning
|
|
313
|
+
// call like `{themeLogo(id)}`) — the writer splices a Node in by
|
|
314
|
+
// identity, exactly the contract `__bfText` used to provide (#1663).
|
|
315
|
+
let writer = ''
|
|
316
|
+
if (normalElems.length > 0) {
|
|
317
|
+
const slots: ClaimSlotSpec[] = normalElems.map(elem => ({ id: elem.slotId, kind: 'markup', path: [] }))
|
|
318
|
+
writer = claimWriterVarName(slots, varSlotId)
|
|
319
|
+
lines.push(` const ${writer} = lazySlots(__scope, ${claimPlanLiteral(slots)})`)
|
|
320
|
+
}
|
|
321
|
+
|
|
316
322
|
lines.push(` createEffect(() => {`)
|
|
317
323
|
if (normalElems.length > 0) {
|
|
318
324
|
// Expression is always evaluated for non-conditional elements
|
|
319
325
|
lines.push(` const __val = ${expr}`)
|
|
326
|
+
// `escapeTextOrNode`: `writeMarkup` inserts a string via
|
|
327
|
+
// `innerHTML =`, which — unlike the plain `Text.nodeValue =`
|
|
328
|
+
// assignment the initial SSR/CSR TEMPLATE's `escapeText(${expr})`
|
|
329
|
+
// was standing in for — DOES interpret HTML, so a raw un-escaped
|
|
330
|
+
// string here is an injection/byte-parity gap; a live Node (this
|
|
331
|
+
// expression may return one) must pass through untouched instead.
|
|
320
332
|
for (const elem of normalElems) {
|
|
321
|
-
|
|
322
|
-
lines.push(` __anchor_${v} = __bfText(__anchor_${v}, __val)`)
|
|
333
|
+
lines.push(` ${writer}('${elem.slotId}', escapeTextOrNode(__val))`)
|
|
323
334
|
}
|
|
335
|
+
// Conditional elements: deferred from slot unification A3 — see
|
|
336
|
+
// `__bfText`'s docstring for why. `insert()` may swap the branch's
|
|
337
|
+
// DOM independently of this effect's own reruns, and a cached claim
|
|
338
|
+
// door's dedup state would go stale across that swap (unlike the
|
|
339
|
+
// 'text'-kind conditional cases elsewhere in the compiler, this
|
|
340
|
+
// slot's value may be a live Node, so it can't reuse the "just
|
|
341
|
+
// re-claim fresh every run" trick those use — a fresh 'markup'
|
|
342
|
+
// claim's dedup state always starts empty, so re-claiming per-run
|
|
343
|
+
// would throw away the unchanged-value skip on every single run).
|
|
344
|
+
// Keeps `$t` + `__bfText`.
|
|
324
345
|
for (const elem of conditionalElems) {
|
|
325
346
|
const v = varSlotId(elem.slotId)
|
|
326
347
|
lines.push(` const [__el_${v}] = $t(__scope, '${elem.slotId}')`)
|
|
@@ -348,13 +369,28 @@ export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): v
|
|
|
348
369
|
}
|
|
349
370
|
}
|
|
350
371
|
|
|
351
|
-
/**
|
|
372
|
+
/**
|
|
373
|
+
* Emit createEffect blocks for `@client` expressions. Slot unification A3:
|
|
374
|
+
* these are ordinary claimed 'text' slots now (never Node-valued — `@client`
|
|
375
|
+
* only ever evaluates a plain expression), one writer per slot at `__scope`,
|
|
376
|
+
* replacing `updateClientMarker`'s full-tree rescan + zero-width-space
|
|
377
|
+
* managed-node marker (no longer needed: a claimed Text node's identity is
|
|
378
|
+
* already known, so there's nothing to disambiguate from other content).
|
|
379
|
+
*/
|
|
352
380
|
export function emitClientOnlyExpressions(lines: string[], ctx: ClientJsContext): void {
|
|
353
381
|
for (const elem of ctx.clientOnlyElements) {
|
|
382
|
+
// Slot unification Step B: `elem.elidedPath`, when present, was proven
|
|
383
|
+
// safe by `client-only-elision.ts` before this pass ran — use the real
|
|
384
|
+
// position-based path and skip the marker-scan fallback entirely.
|
|
385
|
+
const slots: ClaimSlotSpec[] = elem.elidedPath
|
|
386
|
+
? [{ id: elem.slotId, kind: 'text', path: elem.elidedPath, markerless: true }]
|
|
387
|
+
: [{ id: elem.slotId, kind: 'text', path: [] }]
|
|
388
|
+
const writer = claimWriterVarName(slots, varSlotId)
|
|
354
389
|
lines.push(` // @client: ${elem.slotId}`)
|
|
390
|
+
lines.push(` { const ${writer} = lazySlots(__scope, ${claimPlanLiteral(slots)})`)
|
|
355
391
|
lines.push(` createEffect(() => {`)
|
|
356
|
-
lines.push(`
|
|
357
|
-
lines.push(` }${bindingIdArg(ctx, elem.slotId)})`)
|
|
392
|
+
lines.push(` ${writer}('${elem.slotId}', ${elem.expression})`)
|
|
393
|
+
lines.push(` }${bindingIdArg(ctx, elem.slotId)}) }`)
|
|
358
394
|
lines.push('')
|
|
359
395
|
}
|
|
360
396
|
}
|
|
@@ -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'
|
|
@@ -126,7 +126,11 @@ export function createTemplateAwareStringProtector(): {
|
|
|
126
126
|
return { protect, restore, replaceProtectedCall }
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
// Exported (not just module-local) so `client-only-elision.ts` (slot
|
|
130
|
+
// unification Step B) can reuse the exact same void-element list when it
|
|
131
|
+
// walks the IR tree computing markerless-eligible child-index paths —
|
|
132
|
+
// duplicating this list would risk the two walks silently drifting apart.
|
|
133
|
+
export const VOID_ELEMENTS = new Set([
|
|
130
134
|
'area', 'base', 'br', 'col', 'embed', 'hr', 'img',
|
|
131
135
|
'input', 'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
132
136
|
])
|
|
@@ -276,9 +280,14 @@ function templateAttrExpr(attrName: string, valExpr: string, presenceOrUndefined
|
|
|
276
280
|
// `data-key` / `data-key-N` is a reconciliation contract — every loop item
|
|
277
281
|
// must carry one. Emit unconditionally; if the user passes `key={undefined}`
|
|
278
282
|
// 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"
|
|
283
|
+
// assertion in mapArray) rather than silently fall back to "no key" —
|
|
284
|
+
// `escapeAttr(undefined)` stringifies to exactly that. The value is escaped
|
|
285
|
+
// like every other dynamic attribute: SSR adapters escape it (their
|
|
286
|
+
// template engines do), so an unescaped `"` in a key — surfaced by the
|
|
287
|
+
// flatmap-expression-body fixture's adversarial keys — corrupted the
|
|
288
|
+
// client-assembled HTML and diverged from the SSR bytes.
|
|
280
289
|
if (attrName === 'data-key' || attrName.startsWith('data-key-')) {
|
|
281
|
-
return `${attrName}="\${${valExpr}}"`
|
|
290
|
+
return `${attrName}="\${${escapeAttrValueExpr(valExpr)}}"`
|
|
282
291
|
}
|
|
283
292
|
return `\${(${valExpr}) != null ? '${attrName}="' + ${escapeAttrValueExpr(valExpr)} + '"' : ''}`
|
|
284
293
|
}
|
|
@@ -575,6 +584,12 @@ export function renderPreamble(
|
|
|
575
584
|
transformJs?: (text: string) => string
|
|
576
585
|
/** Context-appropriate leaf renderer (an irToHtmlTemplate variant). */
|
|
577
586
|
renderLeaf: (ir: IRNode) => string
|
|
587
|
+
/**
|
|
588
|
+
* When true, `renderLeaf` output is spliced verbatim — the renderer
|
|
589
|
+
* supplies its own delimiters (e.g. the flatMap descriptor form
|
|
590
|
+
* `({ k, h })`). Default wraps each leaf in a template literal.
|
|
591
|
+
*/
|
|
592
|
+
rawLeaf?: boolean
|
|
578
593
|
},
|
|
579
594
|
): string {
|
|
580
595
|
let out = ''
|
|
@@ -582,6 +597,8 @@ export function renderPreamble(
|
|
|
582
597
|
if (seg.kind === 'js') {
|
|
583
598
|
const text = opts.textVariant === 'template' ? (seg.templateText ?? seg.text) : seg.text
|
|
584
599
|
out += opts.transformJs ? opts.transformJs(text) : text
|
|
600
|
+
} else if (opts.rawLeaf) {
|
|
601
|
+
out += opts.renderLeaf(escapeLeafTextExpressions(seg.ir))
|
|
585
602
|
} else {
|
|
586
603
|
out += '`' + opts.renderLeaf(escapeLeafTextExpressions(seg.ir)) + '`'
|
|
587
604
|
}
|
|
@@ -589,6 +606,96 @@ export function renderPreamble(
|
|
|
589
606
|
return out
|
|
590
607
|
}
|
|
591
608
|
|
|
609
|
+
/**
|
|
610
|
+
* Project a flatMap segment leaf's `key={...}` attribute into a runtime key
|
|
611
|
+
* expression, or `null` when the leaf declares none. Template-literal keys
|
|
612
|
+
* (`key={\`${it.id}:${tag}\`}`) are first-class here — unlike the loop-level
|
|
613
|
+
* `extractLoopKey`, the expression is evaluated inside the flatMap body where
|
|
614
|
+
* the callback params are in scope, so any expression form works.
|
|
615
|
+
*/
|
|
616
|
+
export function flatMapLeafKeyExpr(ir: IRNode): string | null {
|
|
617
|
+
if (ir.type !== 'element') return null
|
|
618
|
+
const keyAttr = ir.attrs.find((a) => a.name === 'key')
|
|
619
|
+
if (!keyAttr) return null
|
|
620
|
+
switch (keyAttr.value.kind) {
|
|
621
|
+
case 'expression':
|
|
622
|
+
return `(${keyAttr.value.expr})`
|
|
623
|
+
case 'literal':
|
|
624
|
+
return JSON.stringify(keyAttr.value.value)
|
|
625
|
+
case 'template':
|
|
626
|
+
return attrValueToString(keyAttr.value)
|
|
627
|
+
default:
|
|
628
|
+
return null
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Copy of `ir` with the loop `key` attribute removed (element leaves only). */
|
|
633
|
+
function stripLeafKeyAttr(ir: IRNode): IRNode {
|
|
634
|
+
if (ir.type !== 'element') return ir
|
|
635
|
+
return { ...ir, attrs: ir.attrs.filter((a) => a.name !== 'key') }
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Render a `FlatMapCallback` as the CLIENT-SIDE descriptor body for
|
|
640
|
+
* `mapArray` (the reconciliation twin of the string-template rendering in
|
|
641
|
+
* `irToHtmlTemplate`'s `'loop'` case). Each JSX leaf becomes
|
|
642
|
+
* `({ k: <keyExpr>, h: \`<html>\` })` — the flatMap flattens descriptors,
|
|
643
|
+
* `mapArray` keys on `d.k` (index fallback), and the emitted renderItem
|
|
644
|
+
* builds/patches the leaf element from `d.h`.
|
|
645
|
+
*
|
|
646
|
+
* Runs in the init scope where the loop SOURCE items are plain values (the
|
|
647
|
+
* flatMap executes inside the `mapArray` accessor, BEFORE per-item signals
|
|
648
|
+
* exist), so neither js segments nor leaf HTML get the accessor wrap — refs
|
|
649
|
+
* stay `t.title`, not `t().title`. `data-key` is deliberately NOT emitted in
|
|
650
|
+
* the leaf HTML: reconciliation identity is stamped by `mapArray` via
|
|
651
|
+
* `setAttribute`, matching the SSR side (which never emits it for flatMap
|
|
652
|
+
* leaves).
|
|
653
|
+
*/
|
|
654
|
+
export function renderFlatMapClientBody(
|
|
655
|
+
cb: Pick<FlatMapCallback, 'segments'>,
|
|
656
|
+
restSpreadNames?: Set<string>,
|
|
657
|
+
): string {
|
|
658
|
+
return renderPreamble(cb, {
|
|
659
|
+
textVariant: 'client',
|
|
660
|
+
rawLeaf: true,
|
|
661
|
+
renderLeaf: (ir) => {
|
|
662
|
+
const key = flatMapLeafKeyExpr(ir)
|
|
663
|
+
const html = irToHtmlTemplate(stripLeafKeyAttr(ir), restSpreadNames, 1, undefined, undefined, true)
|
|
664
|
+
return `({ k: ${key ?? 'undefined'}, h: \`${html}\` })`
|
|
665
|
+
},
|
|
666
|
+
})
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** True when any segment leaf declares a `key` — drives the mapArray keyFn. */
|
|
670
|
+
export function flatMapCallbackHasKeyedLeaf(cb: Pick<FlatMapCallback, 'segments'>): boolean {
|
|
671
|
+
return cb.segments.some((s) => s.kind === 'jsx' && flatMapLeafKeyExpr(s.ir) !== null)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Synthesize the client descriptor body for a flatMap PROJECTION loop —
|
|
676
|
+
* one whose only child is a nested `IRLoop` lowered from
|
|
677
|
+
* `flatMap(it => it.tags.map(tag => <li/>))`. The neutral IR is the single
|
|
678
|
+
* carrier: SSR adapters templatize the nested loop natively, and this
|
|
679
|
+
* derives the `mapArray` accessor's flatten projection from the SAME inner
|
|
680
|
+
* loop — `<chained-inner>.map((tag, i) => ({ k: <inner.key>, h: `<leaf>` }))`.
|
|
681
|
+
* Runs in the accessor context (plain source items, no per-item signals),
|
|
682
|
+
* so leaf refs stay unwrapped. Leaf `key` attrs were already stripped at IR
|
|
683
|
+
* build (`stripLoopLeafKeyAttrs`); the inner loop's `key` FIELD supplies
|
|
684
|
+
* `k`.
|
|
685
|
+
*/
|
|
686
|
+
export function renderFlatMapProjectionClientBody(
|
|
687
|
+
inner: Extract<IRNode, { type: 'loop' }>,
|
|
688
|
+
restSpreadNames?: Set<string>,
|
|
689
|
+
): string {
|
|
690
|
+
const chained = applyLoopChain(inner)
|
|
691
|
+
const params = inner.index ? `(${inner.param}, ${inner.index})` : `(${inner.param})`
|
|
692
|
+
const key = inner.key ? `(${inner.key})` : 'undefined'
|
|
693
|
+
const html = inner.children
|
|
694
|
+
.map((c) => irToHtmlTemplate(escapeLeafTextExpressions(c), restSpreadNames, 1, undefined, undefined, true))
|
|
695
|
+
.join('')
|
|
696
|
+
return `${chained}.map(${params} => ({ k: ${key}, h: \`${html}\` }))`
|
|
697
|
+
}
|
|
698
|
+
|
|
592
699
|
/**
|
|
593
700
|
* SSR/CSR escaping parity for preamble leaves, decided once at the door: a
|
|
594
701
|
* JSX-runtime SSR adapter renders the leaf's raw JSX and auto-escapes text
|
|
@@ -691,6 +798,16 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
|
|
|
691
798
|
|
|
692
799
|
case 'expression': {
|
|
693
800
|
if (node.expr === 'null' || node.expr === 'undefined') return ''
|
|
801
|
+
// Slot unification Step B (`markerless`, decided once by
|
|
802
|
+
// `client-only-elision.ts` before this CSR pass runs): drop the
|
|
803
|
+
// marker pair and fall through to the same bare `${...}` shape the
|
|
804
|
+
// no-slotId branch below already uses — `elidedPath` (not this
|
|
805
|
+
// string) is what the claim plan resolves against, so no marker
|
|
806
|
+
// anchor is needed here at all.
|
|
807
|
+
if (node.markerless) {
|
|
808
|
+
const bare = wrapInterpolation(wrapExpr(node.expr))
|
|
809
|
+
return `\${${bare}}`
|
|
810
|
+
}
|
|
694
811
|
const inner = wrapInterpolation(wrapExpr(node.expr))
|
|
695
812
|
// Stage 3 / D4 — an element-array child ({out}) built by an arbitrary
|
|
696
813
|
// .map() preamble is an array of HTML strings; join it rather than let
|
|
@@ -805,8 +922,8 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
|
|
|
805
922
|
// (Go's `bf_sort`, etc.) applied the sort separately and
|
|
806
923
|
// hydration only needed to match, but broken on Hono / CSR
|
|
807
924
|
// where the template is the only source of truth. The chain
|
|
808
|
-
// mirrors `buildChainedArrayExpr` so
|
|
809
|
-
// same array shape this template emits.
|
|
925
|
+
// mirrors `buildChainedArrayExpr` so mapArray/mapArrayAnchored sees
|
|
926
|
+
// the same array shape this template emits.
|
|
810
927
|
const rawChainedArray = applyLoopChain(node)
|
|
811
928
|
const { array: iterArray, callbackParam } = applyIterationShape(node, rawChainedArray, indexParam)
|
|
812
929
|
const wrappedArray = wrapExpr(iterArray)
|
|
@@ -816,8 +933,12 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
|
|
|
816
933
|
if (node.flatMapCallback) {
|
|
817
934
|
// Complex flatMap: the body is structured segments, rendered through
|
|
818
935
|
// the same single door as map preambles.
|
|
936
|
+
// Leaf `key` is stripped from the string form: SSR (Hono rawBody)
|
|
937
|
+
// never emits data-key for flatMap leaves, and reconciliation
|
|
938
|
+
// identity is stamped by mapArray via setAttribute — emitting it
|
|
939
|
+
// here was the CSR/SSR data-key asymmetry (unescaped, client-only).
|
|
819
940
|
const body = renderPreamble(node.flatMapCallback, {
|
|
820
|
-
renderLeaf: (ir) => irToHtmlTemplate(ir, restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar, insideLoop),
|
|
941
|
+
renderLeaf: (ir) => irToHtmlTemplate(stripLeafKeyAttr(ir), restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar, insideLoop),
|
|
821
942
|
})
|
|
822
943
|
mapExpr = `\${${wrappedArray}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
|
|
823
944
|
} else if (node.preamble) {
|
|
@@ -1013,7 +1134,9 @@ export interface SkeletonSlotPaths {
|
|
|
1013
1134
|
* the loop keeps its hoisted-clone fast path, but every slot lookup falls
|
|
1014
1135
|
* back to `qsa`/`$t`.
|
|
1015
1136
|
*/
|
|
1016
|
-
|
|
1137
|
+
// Exported for reuse by `client-only-elision.ts` (Step B) — same hazard
|
|
1138
|
+
// class, same reasoning, must never drift out of sync with a second copy.
|
|
1139
|
+
export const SKELETON_PATH_HAZARD_TAGS = new Set([
|
|
1017
1140
|
'table', 'thead', 'tbody', 'tfoot', 'caption', 'colgroup', 'col',
|
|
1018
1141
|
'select', 'optgroup',
|
|
1019
1142
|
'p',
|
|
@@ -1029,7 +1152,7 @@ const SKELETON_PATH_HAZARD_TAGS = new Set([
|
|
|
1029
1152
|
* `<dt>` closes the `<dt>`). A skeleton hitting this bails on path
|
|
1030
1153
|
* computation entirely (see `SKELETON_PATH_HAZARD_TAGS` doc).
|
|
1031
1154
|
*/
|
|
1032
|
-
const SKELETON_PATH_FORCE_CLOSE_GROUPS: ReadonlyArray<ReadonlySet<string>> = [
|
|
1155
|
+
export const SKELETON_PATH_FORCE_CLOSE_GROUPS: ReadonlyArray<ReadonlySet<string>> = [
|
|
1033
1156
|
new Set(['a']),
|
|
1034
1157
|
new Set(['button']),
|
|
1035
1158
|
new Set(['form']),
|
|
@@ -1039,7 +1162,7 @@ const SKELETON_PATH_FORCE_CLOSE_GROUPS: ReadonlyArray<ReadonlySet<string>> = [
|
|
|
1039
1162
|
new Set(['li']),
|
|
1040
1163
|
]
|
|
1041
1164
|
|
|
1042
|
-
function skeletonForceCloseGroup(tag: string): number {
|
|
1165
|
+
export function skeletonForceCloseGroup(tag: string): number {
|
|
1043
1166
|
return SKELETON_PATH_FORCE_CLOSE_GROUPS.findIndex(group => group.has(tag))
|
|
1044
1167
|
}
|
|
1045
1168
|
|
|
@@ -1097,7 +1220,7 @@ function walkSkeletonPathNode(
|
|
|
1097
1220
|
}
|
|
1098
1221
|
|
|
1099
1222
|
/** True if `children` (already flattened) contains anything the HTML parser would foster-parent out of a `<tr>`. */
|
|
1100
|
-
function hasForeignTableRowContent(children: readonly IRNode[]): boolean {
|
|
1223
|
+
export function hasForeignTableRowContent(children: readonly IRNode[]): boolean {
|
|
1101
1224
|
for (const child of children) {
|
|
1102
1225
|
if (child.type === 'text') {
|
|
1103
1226
|
if (child.value.trim() !== '') return true
|
|
@@ -1111,7 +1234,7 @@ function hasForeignTableRowContent(children: readonly IRNode[]): boolean {
|
|
|
1111
1234
|
}
|
|
1112
1235
|
|
|
1113
1236
|
/** Splice fragment children inline — a fragment contributes no DOM node of its own. */
|
|
1114
|
-
function flattenSkeletonChildren(children: readonly IRNode[]): IRNode[] {
|
|
1237
|
+
export function flattenSkeletonChildren(children: readonly IRNode[]): IRNode[] {
|
|
1115
1238
|
const out: IRNode[] = []
|
|
1116
1239
|
for (const child of children) {
|
|
1117
1240
|
if (child.type === 'fragment') {
|
|
@@ -1263,7 +1386,8 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
|
|
|
1263
1386
|
let mapExpr: string
|
|
1264
1387
|
if (node.flatMapCallback) {
|
|
1265
1388
|
const body = renderPreamble(node.flatMapCallback, {
|
|
1266
|
-
|
|
1389
|
+
// Leaf `key` stripped — see the irToHtmlTemplate site above.
|
|
1390
|
+
renderLeaf: (ir) => irToPlaceholderTemplate(stripLeafKeyAttr(ir), restSpreadNames, loopDepth + 1, loopParams),
|
|
1267
1391
|
})
|
|
1268
1392
|
mapExpr = `\${${wrappedArray}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
|
|
1269
1393
|
} else if (node.preamble) {
|
|
@@ -2214,7 +2338,11 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
|
|
|
2214
2338
|
case 'expression':
|
|
2215
2339
|
if (node.expr === 'null' || node.expr === 'undefined') return ''
|
|
2216
2340
|
if (node.clientOnly && node.slotId) {
|
|
2217
|
-
|
|
2341
|
+
// Ordinary claimed 'text' slot pair (slot unification A3) — matches
|
|
2342
|
+
// the SSR adapters' `renderExpression` byte-for-byte (byte parity):
|
|
2343
|
+
// empty at CSR mount too, since the expression is evaluated only
|
|
2344
|
+
// once init's createEffect runs, same as the SSR case.
|
|
2345
|
+
return `<!--bf:${node.slotId}--><!--/-->`
|
|
2218
2346
|
}
|
|
2219
2347
|
{
|
|
2220
2348
|
const transformed = transformExpr(node.expr, node.templateExpr)
|
|
@@ -2386,7 +2514,8 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
|
|
|
2386
2514
|
const body = renderPreamble(node.flatMapCallback, {
|
|
2387
2515
|
textVariant: 'template',
|
|
2388
2516
|
transformJs: (t) => applyPropsRewrite(t, propsObjectName ?? null),
|
|
2389
|
-
|
|
2517
|
+
// Leaf `key` stripped — see the irToHtmlTemplate site above.
|
|
2518
|
+
renderLeaf: (ir) => recurseInLoopBody(stripLeafKeyAttr(ir)),
|
|
2390
2519
|
})
|
|
2391
2520
|
mapExpr = `\${${iterArrayExpr}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
|
|
2392
2521
|
} else if (node.preamble) {
|
|
@@ -8,13 +8,16 @@ 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', '
|
|
12
|
-
'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild',
|
|
11
|
+
'hydrate', 'insert', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'mapArrayAnchored', 'patchLeaf', 'createDisposableEffect',
|
|
12
|
+
'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild',
|
|
13
13
|
'createPortal',
|
|
14
14
|
'provideContext', 'createContext', 'useContext',
|
|
15
|
-
'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss', 'escapeAttr', 'escapeText',
|
|
15
|
+
'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss', 'escapeAttr', 'escapeText', 'escapeTextOrNode',
|
|
16
16
|
'qsa', 'qsaItem', 'qsaChildScope', 'qsaChildScopes', 'upsertChildItem', '__slot', '__bfSlot', '__bfText',
|
|
17
|
-
|
|
17
|
+
// Claim-plan interpreter (slot unification A2/A3, spec/slot-unification.md)
|
|
18
|
+
// — the "one claim mechanism" that replaced `patchSlotRange` and
|
|
19
|
+
// `updateClientMarker` (both deleted) as the content-slot update door.
|
|
20
|
+
'claimSlots', 'lazySlots',
|
|
18
21
|
// Profile mode (#1690, SR3) — turn-boundary markers around event handlers.
|
|
19
22
|
'beginTurn', 'endTurn',
|
|
20
23
|
// Catalogued `Date` lowering (#2274/#2292) — the client counterpart to
|
|
@@ -570,6 +570,13 @@ 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 claimed 'markup'-slot effect (`preambleRegions` in the
|
|
575
|
+
// client-JS loop plan; slot unification A3, `@barefootjs/client/
|
|
576
|
+
// runtime/claim-slots.ts`); it must never ALSO become a `reactiveTexts`
|
|
577
|
+
// entry, which would patch it via `.textContent` and corrupt markup
|
|
578
|
+
// (a `joinArrayChild` region's value is raw HTML, not text).
|
|
579
|
+
if (n.preambleRegion) return
|
|
573
580
|
const originFreeIds = freeIdsFromRefs(n.origin?.freeRefs)
|
|
574
581
|
const expanded = expandConstantForReactivity(n.expr, ctx, originFreeIds)
|
|
575
582
|
// 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'
|
|
@@ -254,7 +255,7 @@ export interface LoopCore {
|
|
|
254
255
|
/**
|
|
255
256
|
* Unique id for this loop's `<!--bf-loop:<id>--> ... <!--bf-/loop:<id>-->`
|
|
256
257
|
* marker pair, threaded from `IRLoop.markerId`. Each `mapArray()` /
|
|
257
|
-
* `
|
|
258
|
+
* `mapArrayAnchored()` call passes this id so sibling loops under the
|
|
258
259
|
* same parent reconcile their own range (#1087).
|
|
259
260
|
*/
|
|
260
261
|
markerId: string
|
|
@@ -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
|
/**
|
|
@@ -585,7 +609,7 @@ export interface TopLevelLoop extends LoopCore {
|
|
|
585
609
|
// Per-item bindings (events / reactiveAttrs / reactiveTexts / refs / conditionals)
|
|
586
610
|
// now live on `LoopCore.bindings` — see issue #1244 §B.
|
|
587
611
|
isStaticArray: boolean // True if array is a static prop (not a signal)
|
|
588
|
-
useElementReconciliation?: boolean // True:
|
|
612
|
+
useElementReconciliation?: boolean // True: mapArray/mapArrayAnchored + composite rendering (native root with child components)
|
|
589
613
|
/** Inner loop metadata for composite element reconciliation (array, param, key, container) */
|
|
590
614
|
innerLoops?: NestedLoop[]
|
|
591
615
|
/** Offset of this loop's items past its preceding container siblings (#1693). */
|
|
@@ -630,6 +654,14 @@ export interface ReactiveAttribute extends AttrMeta {
|
|
|
630
654
|
export interface ClientOnlyElement {
|
|
631
655
|
slotId: string
|
|
632
656
|
expression: string
|
|
657
|
+
/**
|
|
658
|
+
* Slot unification Step B (`spec/slot-unification.md` §3(b), §5 Step B):
|
|
659
|
+
* copied from `IRExpression.elidedPath` (`client-only-elision.ts`) when
|
|
660
|
+
* the compiler proved this slot's marker pair can be elided. Absent
|
|
661
|
+
* (`undefined`) is always safe — the claim plan falls back to `path: []`
|
|
662
|
+
* / marker-based resolution exactly as before Step B.
|
|
663
|
+
*/
|
|
664
|
+
elidedPath?: readonly number[]
|
|
633
665
|
}
|
|
634
666
|
|
|
635
667
|
|
|
@@ -180,7 +180,7 @@ export function applyObjectIterationWrap(
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
/**
|
|
183
|
-
* Build the chained array expression for
|
|
183
|
+
* Build the chained array expression for mapArray/mapArrayAnchored. Thin
|
|
184
184
|
* adapter over `buildLoopChainExpr` that unpacks the collected
|
|
185
185
|
* `TopLevelLoop` / `BranchLoop` shape into the primitive inputs.
|
|
186
186
|
* Branch loops carry the same `filterPredicate` / `sortComparator`
|