@barefootjs/jsx 0.27.0 → 0.28.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/index.js +620 -26
- package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +6 -0
- 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 +2 -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-insert.d.ts +7 -0
- package/dist/ir-to-client-js/control-flow/plan/build-insert.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +107 -0
- package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -0
- package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts +9 -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/lazy-row-eligibility.d.ts +203 -0
- package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -0
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +10 -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/branch-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts +98 -0
- package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -0
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +5 -0
- 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.map +1 -1
- package/dist/ir-to-client-js/control-flow.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/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +162 -36
- package/src/__tests__/client-js-generation.test.ts +4 -1
- package/src/__tests__/composite-branch-loop.test.ts +12 -3
- package/src/__tests__/conditional-mapArray-key.test.ts +7 -1
- package/src/__tests__/create-selector.test.ts +21 -7
- package/src/__tests__/lazy-row-eligibility.test.ts +692 -0
- package/src/__tests__/loop-branch-bare-expression-reactive-text.test.ts +98 -0
- package/src/__tests__/loop-fallback-wrap.test.ts +31 -12
- package/src/__tests__/loop-hoisted-template.test.ts +10 -6
- package/src/__tests__/static-loop-csr-materialize.test.ts +6 -1
- package/src/ir-to-client-js/collect-elements.ts +48 -13
- package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +6 -0
- package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +35 -12
- package/src/ir-to-client-js/control-flow/plan/build-insert.ts +9 -2
- package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +305 -0
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +44 -12
- package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +440 -0
- package/src/ir-to-client-js/control-flow/plan/loop.ts +10 -0
- package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +22 -0
- package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +478 -0
- package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +6 -1
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +23 -0
- package/src/ir-to-client-js/control-flow.ts +7 -2
- package/src/ir-to-client-js/imports.ts +8 -2
- package/src/jsx-to-ir.ts +18 -1
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emit a lazy row graph loop — `mapArrayLazy(...)` + a compiler-built
|
|
3
|
+
* `LazyRowPlan` object literal (`spec/slot-unification.md` §9, L3).
|
|
4
|
+
*
|
|
5
|
+
* This is the alternative to `stringifyPlainLoop`'s `mapArray` + renderItem
|
|
6
|
+
* emission, taken only when `lazyRowEligibility` said yes (see
|
|
7
|
+
* `plan/lazy-row-eligibility.ts`). Every ineligible loop keeps today's
|
|
8
|
+
* emission byte-for-byte — sound-or-loud, no silent third path.
|
|
9
|
+
*
|
|
10
|
+
* Output shape:
|
|
11
|
+
*
|
|
12
|
+
* const __tpl_<mid> = document.createElement('template') // hoisted skeleton, when usable
|
|
13
|
+
* __tpl_<mid>.innerHTML = `…`
|
|
14
|
+
* const __lzp_<mid> = [...] // hoisted fresh-clone text paths, when usable
|
|
15
|
+
* const __lzs_<mid> = [{ id, kind: 'text', path: [] }, …] // hoisted claim plan, ADOPTED-row form
|
|
16
|
+
* const __lzsc_<mid> = [{ id, kind: 'text', path: __lzp_<mid>[i] }, …] // FRESH-CLONE form, only when it differs
|
|
17
|
+
* const __lzc_<mid> = (__e) => { … } // lazy ref claim for ADOPTED rows (door slot empty)
|
|
18
|
+
* mapArrayLazy(() => <arr>, <container>, <keyFn>, {
|
|
19
|
+
* createRow: (__e, <idx>) => { … writes every binding, seeds refs+last … },
|
|
20
|
+
* applyItem: (__e) => { … item-driven bindings, dedup against __e.last … },
|
|
21
|
+
* applyOuter: (__es, __seed) => { … one loop-level effect body … },
|
|
22
|
+
* }, '<markerId>')
|
|
23
|
+
*
|
|
24
|
+
* Three shapes carry the plan's obligations (runtime docstring,
|
|
25
|
+
* `packages/client/src/runtime/map-array-lazy.ts`):
|
|
26
|
+
*
|
|
27
|
+
* **`createRow`** — CSR creation. Clones the row, resolves refs from KNOWN
|
|
28
|
+
* clone paths (the hoisted skeleton's `__p`-style child-index chains, reused
|
|
29
|
+
* verbatim from #2143 via `pathExpr`) with no scan, writes ALL bindings
|
|
30
|
+
* (item-driven and outer-involving alike) and seeds `entry.last` so the
|
|
31
|
+
* loop-level outer effect's dedup is correct from the row's first tick.
|
|
32
|
+
*
|
|
33
|
+
* **`applyItem`** — called by the reconciler after `entry.item` changed.
|
|
34
|
+
* Claims refs lazily through `__lzc_<mid>` (a `qsa` scan inside that ONE row)
|
|
35
|
+
* when `entry.refs` is null, materializes the content door on demand
|
|
36
|
+
* (`doorAccess`), then writes each item-driven binding behind a per-binding
|
|
37
|
+
* dedup on `entry.last`.
|
|
38
|
+
*
|
|
39
|
+
* **`applyOuter`** — the ONE loop-level effect body, emitted only when some
|
|
40
|
+
* binding is outer-involving. Two details matter:
|
|
41
|
+
*
|
|
42
|
+
* - **Prime reads first.** The effect subscribes to whatever its body reads.
|
|
43
|
+
* With an empty entry list the per-row loop reads nothing, so the effect
|
|
44
|
+
* would never subscribe and the loop would go permanently dead. The plan
|
|
45
|
+
* therefore emits a bare `getter()` statement per reactive outer name
|
|
46
|
+
* BEFORE the row loop. The eligibility gate guarantees every reactive
|
|
47
|
+
* outer name is a primable zero-arg signal/memo getter.
|
|
48
|
+
* - **Read-compare-write seeding (§9.3(1)).** On the first run (`__seed`)
|
|
49
|
+
* each binding computes its value, READS the current DOM, and writes only
|
|
50
|
+
* on difference — sound even when the outer state is client-only and
|
|
51
|
+
* diverges from SSR, with no writes on the consistent path. The DOM read
|
|
52
|
+
* is per attribute KIND and pairs with `emitAttrUpdate`'s dispatch
|
|
53
|
+
* (`emit-reactive.ts`); any kind this module does not recognise falls back
|
|
54
|
+
* to `true` (always write on seed), which is conservative, never wrong.
|
|
55
|
+
* CONTENT slots seed the same way through the claim's `read(id)` door
|
|
56
|
+
* (§9.5, lifted) — see `refParts` for the per-loop door choice.
|
|
57
|
+
*
|
|
58
|
+
* A loop with NO outer binding emits no `applyOuter` at all — its rows do
|
|
59
|
+
* literally nothing at hydration.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { isBooleanAttr } from '../../../html-constants.ts'
|
|
63
|
+
import { emitAttrUpdate } from '../../emit-reactive.ts'
|
|
64
|
+
import { toHtmlAttrName } from '../../utils.ts'
|
|
65
|
+
import type { SkeletonSlotPaths } from '../../html-template.ts'
|
|
66
|
+
import { claimPlanLiteral, type ClaimSlotSpec } from './claim-plan.ts'
|
|
67
|
+
import { pathExpr } from './skeleton-paths.ts'
|
|
68
|
+
import {
|
|
69
|
+
emitHoistedTemplateDecl,
|
|
70
|
+
emitTemplateCloneInline,
|
|
71
|
+
hoistedCloneExpr,
|
|
72
|
+
} from './template-parse.ts'
|
|
73
|
+
import type { LazyRowAttrBinding, LazyRowPlanData, LazyRowTextBinding } from '../plan/build-lazy-row.ts'
|
|
74
|
+
|
|
75
|
+
export interface StringifyLazyRowOptions {
|
|
76
|
+
/** Indent of the `mapArrayLazy(` line itself. */
|
|
77
|
+
indent: string
|
|
78
|
+
containerVar: string
|
|
79
|
+
/** Wrap the call in `if (<containerVar>) …` — branch-scoped loops do. */
|
|
80
|
+
guardContainer: boolean
|
|
81
|
+
markerId: string
|
|
82
|
+
arrayExpr: string
|
|
83
|
+
keyFn: string
|
|
84
|
+
paramHead: string
|
|
85
|
+
indexParam: string
|
|
86
|
+
/** Per-row interpolated template (the non-hoisted clone source). */
|
|
87
|
+
template: string
|
|
88
|
+
skeletonTemplate?: string
|
|
89
|
+
skeletonPaths?: SkeletonSlotPaths
|
|
90
|
+
lazyRow: LazyRowPlanData
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function stringifyLazyRowLoop(lines: string[], o: StringifyLazyRowOptions): void {
|
|
94
|
+
const { indent, lazyRow, paramHead } = o
|
|
95
|
+
const mid = o.markerId.replace(/[^A-Za-z0-9_$]/g, '_')
|
|
96
|
+
const tplVar = `__tpl_${mid}`
|
|
97
|
+
const claimVar = `__lzc_${mid}`
|
|
98
|
+
const hasRefs = lazyRow.attrSlotIds.length > 0 || lazyRow.texts.length > 0
|
|
99
|
+
const hasBindings = lazyRow.attrs.length > 0 || lazyRow.texts.length > 0
|
|
100
|
+
// ONE per-loop door decision (see `refParts`): the read-capable claim, or
|
|
101
|
+
// today's write-only writer. Never decided per binding — the door is
|
|
102
|
+
// allocated once per ROW.
|
|
103
|
+
const rwDoor = lazyRow.textNeedsRead
|
|
104
|
+
|
|
105
|
+
// Hoisted once-per-loop skeleton (perf, #2143): clone an already-parsed
|
|
106
|
+
// node per row instead of re-running an `innerHTML` parse. The skeleton
|
|
107
|
+
// omits dynamic ATTRIBUTE VALUES and empties text markers but keeps every
|
|
108
|
+
// `bf="sN"` attribute and `<!--bf:sN-->` marker, so a slot with no
|
|
109
|
+
// compile-time path still resolves by scan against the clone — same
|
|
110
|
+
// per-slot fallback the eager `__p` path takes (`buildSkeletonPathPlan`
|
|
111
|
+
// simply omits pathless slots).
|
|
112
|
+
const paths = o.skeletonPaths
|
|
113
|
+
const useHoisted = Boolean(o.skeletonTemplate)
|
|
114
|
+
|
|
115
|
+
if (useHoisted) emitHoistedTemplateDecl(lines, indent, tplVar, o.skeletonTemplate!)
|
|
116
|
+
|
|
117
|
+
// Hoisted fresh-clone claim paths for this loop's text slots, REFERENCED
|
|
118
|
+
// (not inlined) by `createRow`'s claim plan. Two reasons, both load-bearing:
|
|
119
|
+
//
|
|
120
|
+
// 1. One array per loop instead of one per row — `createRow` runs per CSR
|
|
121
|
+
// row and would otherwise re-allocate every path literal.
|
|
122
|
+
// 2. These paths describe the SKELETON CLONE, not the server-rendered
|
|
123
|
+
// tree, and they are ROW-relative — resolving them from the component
|
|
124
|
+
// scope root (which is what an SSR-shape checker can see) is
|
|
125
|
+
// meaningless. That is the same situation `ClaimSlotSpec.pathExpr`
|
|
126
|
+
// exists for on the eager path (`__existing ? [] : […]`), and the
|
|
127
|
+
// claim-plan conformance harness (`adapter-tests/src/
|
|
128
|
+
// claim-plan-conformance.ts`) correctly verifies only literal
|
|
129
|
+
// `number[]` paths. Adopted (SSR) rows never use these — `__lzc_<mid>`
|
|
130
|
+
// claims with `path: []`, the sanctioned marker-scan case.
|
|
131
|
+
const textPathVar = useHoisted && paths && lazyRow.texts.length > 0 ? `__lzp_${mid}` : null
|
|
132
|
+
if (textPathVar) {
|
|
133
|
+
const arrays = lazyRow.texts.map(t => `[${(paths!.textMarkerPaths.get(t.slotId) ?? []).join(', ')}]`)
|
|
134
|
+
lines.push(`${indent}const ${textPathVar} = [${arrays.join(', ')}]`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Hoisted claim-plan literal(s) for this loop's text slots (perf): a
|
|
138
|
+
// `ClaimPlan` is `readonly SlotSpec[]` (`claim-slots.ts:145`) and
|
|
139
|
+
// `claimRefs` (line 601) only ever READS it — there is no `spec.<field> = `
|
|
140
|
+
// assignment anywhere in that module, and `claimSlots`/`claimRefs` build a
|
|
141
|
+
// fresh `Map` per call — so one shared plan object is safe across every
|
|
142
|
+
// row of the loop instead of a fresh `{ id, kind, path }` object (plus,
|
|
143
|
+
// when `textPathVar` is null, a fresh inner `path: []` array) per row.
|
|
144
|
+
// `__lzs_<mid>` is the ADOPTED-row form (`path: []`, resolved by A2's
|
|
145
|
+
// marker scan); `__lzsc_<mid>` is the FRESH-CLONE form (each slot's path
|
|
146
|
+
// is a `__lzp_<mid>` lookup) and is only emitted when it differs from the
|
|
147
|
+
// adopted form — i.e. when `textPathVar` is non-null.
|
|
148
|
+
let adoptedPlanVar: string | null = null
|
|
149
|
+
let freshPlanVar: string | null = null
|
|
150
|
+
if (lazyRow.texts.length > 0) {
|
|
151
|
+
adoptedPlanVar = `__lzs_${mid}`
|
|
152
|
+
const adoptedSlots: ClaimSlotSpec[] = lazyRow.texts.map(t => ({ id: t.slotId, kind: 'text', path: [] }))
|
|
153
|
+
lines.push(`${indent}const ${adoptedPlanVar} = ${claimPlanLiteral(adoptedSlots)}`)
|
|
154
|
+
if (textPathVar) {
|
|
155
|
+
freshPlanVar = `__lzsc_${mid}`
|
|
156
|
+
const freshSlots: ClaimSlotSpec[] = lazyRow.texts.map((t, i) => ({
|
|
157
|
+
id: t.slotId,
|
|
158
|
+
kind: 'text',
|
|
159
|
+
path: [],
|
|
160
|
+
pathExpr: `${textPathVar}[${i}]`,
|
|
161
|
+
}))
|
|
162
|
+
lines.push(`${indent}const ${freshPlanVar} = ${claimPlanLiteral(freshSlots)}`)
|
|
163
|
+
} else {
|
|
164
|
+
freshPlanVar = adoptedPlanVar
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Lazy ref claim for ADOPTED (SSR) rows: one scan inside that row, cached
|
|
169
|
+
// on `entry.refs`. Shared by applyItem and applyOuter so a row claims once.
|
|
170
|
+
//
|
|
171
|
+
// The text door slot is left EMPTY here and filled by the first content
|
|
172
|
+
// write (`doorAccess`). An adopted row is claimed by whichever of
|
|
173
|
+
// applyItem/applyOuter touches it first, and an `applyOuter` that only
|
|
174
|
+
// drives ATTRIBUTES never reads or writes a content slot — so building the
|
|
175
|
+
// door in this function spends one closure per row of the list on something
|
|
176
|
+
// that stays unused until (and unless) that row's item changes. Deferring
|
|
177
|
+
// is confined to the adopted path on purpose: `createRow` writes every text
|
|
178
|
+
// immediately, so its door is used on the same tick it is built and there
|
|
179
|
+
// is nothing to defer (see the `deferDoor` note on `refParts`).
|
|
180
|
+
if (hasRefs) {
|
|
181
|
+
// With the door deferred, the ELEMENT refs are the only parts that read the
|
|
182
|
+
// row root — so a text-only row (no reactive-attr slot) claims to a bare
|
|
183
|
+
// `[null]` and the `__el` binding would be dead. `parts` decides, rather
|
|
184
|
+
// than a second predicate that could drift from `refParts`.
|
|
185
|
+
const parts = refParts(lazyRow, '__el', null, adoptedPlanVar, true)
|
|
186
|
+
lines.push(`${indent}const ${claimVar} = (__e) => {`)
|
|
187
|
+
if (parts.some(p => p.includes('__el'))) lines.push(`${indent} const __el = __e.primaryEl`)
|
|
188
|
+
lines.push(`${indent} return [${parts.join(', ')}]`)
|
|
189
|
+
lines.push(`${indent}}`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const call = `mapArrayLazy(() => ${o.arrayExpr}, ${o.containerVar}, ${o.keyFn}, {`
|
|
193
|
+
lines.push(`${indent}${o.guardContainer ? `if (${o.containerVar}) ` : ''}${call}`)
|
|
194
|
+
|
|
195
|
+
// --- createRow ---------------------------------------------------------
|
|
196
|
+
const b1 = `${indent} `
|
|
197
|
+
const b2 = `${indent} `
|
|
198
|
+
lines.push(`${b1}createRow: (__e, ${o.indexParam}) => {`)
|
|
199
|
+
// Always bound, even with no reactive bindings: the non-hoisted clone
|
|
200
|
+
// interpolates the per-row template, which reads the param for at least
|
|
201
|
+
// the `data-key` attribute.
|
|
202
|
+
lines.push(`${b2}const ${paramHead} = () => __e.item`)
|
|
203
|
+
const cloneExpr = useHoisted
|
|
204
|
+
? hoistedCloneExpr(tplVar, o.skeletonTemplate!)
|
|
205
|
+
: `(() => { ${emitTemplateCloneInline(o.template)} })()`
|
|
206
|
+
lines.push(`${b2}const __el = ${cloneExpr}`)
|
|
207
|
+
if (hasRefs) {
|
|
208
|
+
lines.push(`${b2}const __r = __e.refs = [${refParts(lazyRow, '__el', useHoisted ? (paths ?? null) : null, freshPlanVar).join(', ')}]`)
|
|
209
|
+
}
|
|
210
|
+
if (hasBindings) {
|
|
211
|
+
lines.push(`${b2}const __l = __e.last = []`)
|
|
212
|
+
for (const a of lazyRow.attrs) emitAttrBinding(lines, b2, a, 'create')
|
|
213
|
+
const createDoor = `__r[${lazyRow.writerIndex}]`
|
|
214
|
+
for (const t of lazyRow.texts) emitTextBinding(lines, b2, t, createDoor, 'create', rwDoor)
|
|
215
|
+
}
|
|
216
|
+
lines.push(`${b2}return __el`)
|
|
217
|
+
lines.push(`${b1}},`)
|
|
218
|
+
|
|
219
|
+
// --- applyItem ---------------------------------------------------------
|
|
220
|
+
const itemAttrs = lazyRow.attrs.filter(a => a.readsItem)
|
|
221
|
+
const itemTexts = lazyRow.texts.filter(t => t.readsItem)
|
|
222
|
+
if (itemAttrs.length === 0 && itemTexts.length === 0) {
|
|
223
|
+
lines.push(`${b1}applyItem: () => {},`)
|
|
224
|
+
} else {
|
|
225
|
+
lines.push(`${b1}applyItem: (__e) => {`)
|
|
226
|
+
lines.push(`${b2}const ${paramHead} = () => __e.item`)
|
|
227
|
+
lines.push(`${b2}const __r = __e.refs ?? (__e.refs = ${claimVar}(__e))`)
|
|
228
|
+
lines.push(`${b2}const __l = __e.last ?? (__e.last = [])`)
|
|
229
|
+
for (const a of itemAttrs) emitAttrBinding(lines, b2, a, 'item')
|
|
230
|
+
if (itemTexts.length > 0) {
|
|
231
|
+
lines.push(`${b2}const __d = ${doorAccess(lazyRow, lazyRow.writerIndex, adoptedPlanVar)}`)
|
|
232
|
+
for (const t of itemTexts) emitTextBinding(lines, b2, t, '__d', 'item', rwDoor)
|
|
233
|
+
}
|
|
234
|
+
lines.push(`${b1}},`)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- applyOuter (only when some binding is outer-involving) -------------
|
|
238
|
+
const outerAttrs = lazyRow.attrs.filter(a => a.readsOuter)
|
|
239
|
+
const outerTexts = lazyRow.texts.filter(t => t.readsOuter)
|
|
240
|
+
if (outerAttrs.length > 0 || outerTexts.length > 0) {
|
|
241
|
+
const b3 = `${indent} `
|
|
242
|
+
lines.push(`${b1}applyOuter: (__es, __seed) => {`)
|
|
243
|
+
// Prime the outer reads so this ONE loop-level effect subscribes even
|
|
244
|
+
// when the entry list is momentarily empty (see module docstring).
|
|
245
|
+
for (const g of lazyRow.outerPrimeGetters) lines.push(`${b2}${g}()`)
|
|
246
|
+
lines.push(`${b2}for (const __e of __es) {`)
|
|
247
|
+
lines.push(`${b3}const ${paramHead} = () => __e.item`)
|
|
248
|
+
lines.push(`${b3}const __r = __e.refs ?? (__e.refs = ${claimVar}(__e))`)
|
|
249
|
+
lines.push(`${b3}const __l = __e.last ?? (__e.last = [])`)
|
|
250
|
+
for (const a of outerAttrs) emitAttrBinding(lines, b3, a, 'outer')
|
|
251
|
+
if (outerTexts.length > 0) {
|
|
252
|
+
lines.push(`${b3}const __d = ${doorAccess(lazyRow, lazyRow.writerIndex, adoptedPlanVar)}`)
|
|
253
|
+
for (const t of outerTexts) emitTextBinding(lines, b3, t, '__d', 'outer', rwDoor)
|
|
254
|
+
}
|
|
255
|
+
lines.push(`${b2}}`)
|
|
256
|
+
lines.push(`${b1}},`)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
lines.push(`${indent}}, '${o.markerId}')`)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The `entry.refs` array contents: one element ref per reactive-attr slot
|
|
264
|
+
* (in `attrSlotIds` order), then — when the row has text slots — the
|
|
265
|
+
* claimed-slot door at `writerIndex`.
|
|
266
|
+
*
|
|
267
|
+
* **Which door (per LOOP, never per binding).** `lazySlots` returns a bare
|
|
268
|
+
* write function; `lazyClaimSlots` returns the `{ write, read }` pair over
|
|
269
|
+
* the SAME claim, at the cost of an extra closure on EVERY row of the list
|
|
270
|
+
* (`claim-slots.ts` measured ~40-84KB/1k rows). So the RW door is taken only
|
|
271
|
+
* when this loop actually has an outer-involving text binding to seed by
|
|
272
|
+
* read-compare-write (`plan.textNeedsRead`, decided once in
|
|
273
|
+
* `build-lazy-row.ts`); every other loop keeps today's writer byte-for-byte.
|
|
274
|
+
*
|
|
275
|
+
* **`deferDoor`** (adopted-row claim only). Even the cheap write-only door is
|
|
276
|
+
* a closure per row, and an `applyOuter` that drives ATTRIBUTES only claims
|
|
277
|
+
* every row at seed without ever touching a content slot — so the door was
|
|
278
|
+
* being built 1,000 times for a list of 1,000 rows and used zero times until
|
|
279
|
+
* some row's item changed. With `deferDoor` the slot holds `null` and the
|
|
280
|
+
* first content write fills it (`doorAccess`). Measured on the SSR bench's
|
|
281
|
+
* 1,000-row table (item texts + one outer-signal class, the shape this
|
|
282
|
+
* describes): post-hydration heap 1630.6KB -> 1573.2KB, -57.4KB, reproduced
|
|
283
|
+
* twice, against a per-run stdev of 0.1-0.6KB and with react/solid unmoved.
|
|
284
|
+
*
|
|
285
|
+
* `createRow` does NOT defer: it writes every text on the tick it builds the
|
|
286
|
+
* row, so its door is used immediately and a `??=` would only add a branch.
|
|
287
|
+
*
|
|
288
|
+
* **Honest cost note.** Reading a text slot CLAIMS that row's whole plan
|
|
289
|
+
* (§2's claim-once rule — `read` and `write` share `claimRefs`), so a loop
|
|
290
|
+
* with an outer-involving TEXT still pays one claim per row at hydration
|
|
291
|
+
* instead of the row-pristine zero. That is inherent to read-compare-write
|
|
292
|
+
* for content: you cannot compare what you have not resolved. It is still
|
|
293
|
+
* far cheaper than the eager path this replaces, which pays a root + a
|
|
294
|
+
* signal + an effect per row. A loop whose outer bindings are all attributes
|
|
295
|
+
* now pays neither the claim nor the door.
|
|
296
|
+
*
|
|
297
|
+
* `skeletonPaths` non-null = fresh-clone context (`createRow`): resolve via
|
|
298
|
+
* compile-time child-index chains, no scan. Null = adopted-row context
|
|
299
|
+
* (`__lzc_<mid>`): `qsa` + an empty claim path, which A2's marker scan
|
|
300
|
+
* resolves — the sanctioned "cannot be statically pathed" case for a
|
|
301
|
+
* server-rendered tree the skeleton does not describe (§5-A3).
|
|
302
|
+
*
|
|
303
|
+
* `planVar` is the hoisted claim-plan variable for THIS context (`__lzs_<mid>`
|
|
304
|
+
* for the adopted-row call site, `__lzsc_<mid>` — or the same `__lzs_<mid>`
|
|
305
|
+
* when the two forms coincide — for the fresh-clone one), built once by
|
|
306
|
+
* `stringifyLazyRowLoop` and referenced here rather than rebuilt per row.
|
|
307
|
+
* `null` iff `lazyRow.texts` is empty, the only case that skips the text part
|
|
308
|
+
* below.
|
|
309
|
+
*/
|
|
310
|
+
function refParts(
|
|
311
|
+
lazyRow: LazyRowPlanData,
|
|
312
|
+
elVar: string,
|
|
313
|
+
skeletonPaths: SkeletonSlotPaths | null,
|
|
314
|
+
planVar: string | null,
|
|
315
|
+
deferDoor = false,
|
|
316
|
+
): string[] {
|
|
317
|
+
const parts: string[] = []
|
|
318
|
+
for (const slotId of lazyRow.attrSlotIds) {
|
|
319
|
+
const path = skeletonPaths?.elementPaths.get(slotId)
|
|
320
|
+
parts.push(path ? pathExpr(elVar, path) : `qsa(${elVar}, '[bf="${slotId}"]')`)
|
|
321
|
+
}
|
|
322
|
+
if (lazyRow.texts.length > 0) {
|
|
323
|
+
parts.push(deferDoor ? 'null' : `${doorCtor(lazyRow)}(${elVar}, ${planVar})`)
|
|
324
|
+
}
|
|
325
|
+
return parts
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The door constructor for this loop — see `refParts`'s "which door" note. */
|
|
329
|
+
function doorCtor(lazyRow: LazyRowPlanData): string {
|
|
330
|
+
return lazyRow.textNeedsRead ? 'lazyClaimSlots' : 'lazySlots'
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* How a content write reaches this row's door.
|
|
335
|
+
*
|
|
336
|
+
* `createRow` seeds the door itself (fresh-clone plan, used on the same
|
|
337
|
+
* tick), so there it is a plain slot read. An ADOPTED row's slot is `null`
|
|
338
|
+
* until the first content write, so applyItem/applyOuter materialize it
|
|
339
|
+
* on demand against the ADOPTED plan — the only plan an adopted row can
|
|
340
|
+
* use, and reachable here because a row whose refs `createRow` seeded
|
|
341
|
+
* always finds a door already in the slot and never evaluates the `??`
|
|
342
|
+
* right-hand side.
|
|
343
|
+
*
|
|
344
|
+
* Emitted ONCE per apply body rather than per binding, and only when that
|
|
345
|
+
* body actually has content bindings: an `applyOuter` driving attributes
|
|
346
|
+
* only never mentions the door, which is the whole point of deferring it.
|
|
347
|
+
*/
|
|
348
|
+
function doorAccess(
|
|
349
|
+
lazyRow: LazyRowPlanData,
|
|
350
|
+
writerIndex: number,
|
|
351
|
+
adoptedPlanVar: string | null,
|
|
352
|
+
): string {
|
|
353
|
+
const slot = `__r[${writerIndex}]`
|
|
354
|
+
return `${slot} ?? (${slot} = ${doorCtor(lazyRow)}(__e.primaryEl, ${adoptedPlanVar}))`
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** `entry.last`-backed dedup test. `in` (not a truthiness check) so a
|
|
358
|
+
* legitimately `undefined` value still records and still writes once. */
|
|
359
|
+
function dedupGuard(ordinal: number): string {
|
|
360
|
+
return `!(${ordinal} in __l) || !Object.is(__l[${ordinal}], __x)`
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function emitAttrBinding(
|
|
364
|
+
lines: string[],
|
|
365
|
+
ind: string,
|
|
366
|
+
a: LazyRowAttrBinding,
|
|
367
|
+
mode: 'create' | 'item' | 'outer',
|
|
368
|
+
): void {
|
|
369
|
+
lines.push(`${ind}{ const __t = __r[${a.refIndex}]`)
|
|
370
|
+
lines.push(`${ind}if (__t) {`)
|
|
371
|
+
lines.push(`${ind} const __x = ${a.wrappedExpression}`)
|
|
372
|
+
const guard = mode === 'create'
|
|
373
|
+
? null
|
|
374
|
+
: mode === 'item'
|
|
375
|
+
? dedupGuard(a.ordinal)
|
|
376
|
+
: `__seed ? (${seedDiffersExpr('__t', a)}) : (${dedupGuard(a.ordinal)})`
|
|
377
|
+
const writeIndent = guard ? `${ind} ` : `${ind} `
|
|
378
|
+
if (guard) lines.push(`${ind} if (${guard}) {`)
|
|
379
|
+
for (const stmt of emitAttrUpdate('__t', a.attrName, '__x', a.meta)) {
|
|
380
|
+
lines.push(`${writeIndent}${stmt}`)
|
|
381
|
+
}
|
|
382
|
+
if (guard) lines.push(`${ind} }`)
|
|
383
|
+
lines.push(`${ind} __l[${a.ordinal}] = __x`)
|
|
384
|
+
lines.push(`${ind}} }`)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* A content-slot write, in the same three modes as {@link emitAttrBinding}.
|
|
389
|
+
*
|
|
390
|
+
* `'outer'` seeds by read-compare-write (§9.3(1)) through the RW door's
|
|
391
|
+
* `read(id)`. `read` returns `null` when it cannot answer (slot never
|
|
392
|
+
* claimed, or not a 'text' slot) and `null !== <the string>` is always true,
|
|
393
|
+
* so the comparison already fails safe into "write it" — no explicit null
|
|
394
|
+
* handling is needed or wanted here.
|
|
395
|
+
*
|
|
396
|
+
* The `'outer'` mode emits a real `if`/`else if` rather than one ternary
|
|
397
|
+
* guard so the seed branch can bind `textOrNode(__x)` ONCE and use it for
|
|
398
|
+
* both the comparison and the write. A ternary would coerce twice on the
|
|
399
|
+
* seed path, which double-invokes a user value's `toString` — observable
|
|
400
|
+
* when it has side effects, wasted work when it doesn't. Hoisting it above
|
|
401
|
+
* the branch instead would fix that but would also coerce on the non-seed
|
|
402
|
+
* path even when dedup skips the write, i.e. on every later tick; the branch
|
|
403
|
+
* keeps exactly one coercion per path taken.
|
|
404
|
+
*
|
|
405
|
+
* `textOrNode` rather than a bare `String(...)` because a child-position
|
|
406
|
+
* interpolation can evaluate to a live Node (`props.renderRow(item)` handed
|
|
407
|
+
* an inline-JSX arrow), and `String(node)` destroys it. The helper passes a
|
|
408
|
+
* Node through so the claim door can promote the slot to 'markup' and splice
|
|
409
|
+
* it; see `claim-slots.ts`. Whether such a call yields a string or a Node is
|
|
410
|
+
* not decidable from the expression's syntax — both are `CallExpression` —
|
|
411
|
+
* so this stays a runtime decision on the value, not a compile-time
|
|
412
|
+
* classification.
|
|
413
|
+
*
|
|
414
|
+
* A Node also makes the seed comparison fail safe on its own: `read(id)`
|
|
415
|
+
* answers with a string or `null`, neither of which is ever `===` a Node, so
|
|
416
|
+
* the seed always writes. That is the correct direction — a Node is freshly
|
|
417
|
+
* built on this run and is never the SSR-rendered content by identity.
|
|
418
|
+
*/
|
|
419
|
+
function emitTextBinding(
|
|
420
|
+
lines: string[],
|
|
421
|
+
ind: string,
|
|
422
|
+
t: LazyRowTextBinding,
|
|
423
|
+
doorExpr: string,
|
|
424
|
+
mode: 'create' | 'item' | 'outer',
|
|
425
|
+
rwDoor: boolean,
|
|
426
|
+
): void {
|
|
427
|
+
lines.push(`${ind}{ const __x = ${t.wrappedExpression}`)
|
|
428
|
+
const writeOf = (valueExpr: string): string =>
|
|
429
|
+
rwDoor
|
|
430
|
+
? `${doorExpr}.write('${t.slotId}', ${valueExpr})`
|
|
431
|
+
: `${doorExpr}('${t.slotId}', ${valueExpr})`
|
|
432
|
+
|
|
433
|
+
if (mode === 'outer') {
|
|
434
|
+
lines.push(`${ind}if (__seed) {`)
|
|
435
|
+
lines.push(`${ind} const __s = textOrNode(__x)`)
|
|
436
|
+
lines.push(`${ind} if (${doorExpr}.read('${t.slotId}') !== __s) ${writeOf('__s')}`)
|
|
437
|
+
lines.push(`${ind}} else if (${dedupGuard(t.ordinal)}) ${writeOf('textOrNode(__x)')}`)
|
|
438
|
+
} else if (mode === 'item') {
|
|
439
|
+
lines.push(`${ind}if (${dedupGuard(t.ordinal)}) ${writeOf('textOrNode(__x)')}`)
|
|
440
|
+
} else {
|
|
441
|
+
lines.push(`${ind}${writeOf('textOrNode(__x)')}`)
|
|
442
|
+
}
|
|
443
|
+
lines.push(`${ind}__l[${t.ordinal}] = __x }`)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Read-compare-write seeding predicate (§9.3(1)): does the CURRENT DOM
|
|
448
|
+
* differ from the value `__x` this binding would write? Mirrors
|
|
449
|
+
* `emitAttrUpdate`'s per-kind dispatch (`emit-reactive.ts`) — the two must
|
|
450
|
+
* be read together. An unrecognised kind returns `true`, i.e. write on the
|
|
451
|
+
* seed run unconditionally: conservative, never unsound.
|
|
452
|
+
*/
|
|
453
|
+
function seedDiffersExpr(target: string, a: LazyRowAttrBinding): string {
|
|
454
|
+
const html = toHtmlAttrName(a.attrName)
|
|
455
|
+
if (a.attrName === 'dangerouslySetInnerHTML' || html === 'dangerouslySetInnerHTML') return 'true'
|
|
456
|
+
if (html === 'style') return `${target}.getAttribute('style') !== styleToCss(__x)`
|
|
457
|
+
if (html === 'class') return `${target}.getAttribute('class') !== (__x != null ? String(__x) : null)`
|
|
458
|
+
if (html === 'value') return `${target}.value !== String(__x)`
|
|
459
|
+
if (isBooleanAttr(html)) return `${target}.${html} !== !!(__x)`
|
|
460
|
+
if (a.meta.presenceOrUndefined) {
|
|
461
|
+
// Compare the VALUE the writer would produce, not just presence.
|
|
462
|
+
// `emitAttrUpdate` writes `'true'` for `aria-*` (WAI-ARIA requires an
|
|
463
|
+
// explicit value) and `''` for everything else, while SSR renders these
|
|
464
|
+
// as a BARE attribute name (`templateAttrExpr` in `html-template.ts`),
|
|
465
|
+
// which parses to the empty string. So for `aria-*` the SSR value and
|
|
466
|
+
// the client's value legitimately differ while presence agrees — a
|
|
467
|
+
// presence-only check would skip the seed write and leave
|
|
468
|
+
// `aria-pressed=""` where the eager path produces `aria-pressed="true"`.
|
|
469
|
+
// `getAttribute` returns null when absent, which is exactly the falsy
|
|
470
|
+
// expectation, so one comparison covers both directions.
|
|
471
|
+
const written = html.startsWith('aria-') ? 'true' : ''
|
|
472
|
+
return `${target}.getAttribute('${html}') !== (__x ? '${written}' : null)`
|
|
473
|
+
}
|
|
474
|
+
return `${target}.getAttribute('${html}') !== (__x != null ? String(__x) : null)`
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Exported for the emission-shape unit tests. */
|
|
478
|
+
export const __lazyRowInternals = { seedDiffersExpr, dedupGuard }
|
|
@@ -97,6 +97,11 @@ export function stringifyBranchEventBindings(
|
|
|
97
97
|
* SSR side: element has `bf-s` → qsa() finds it, initChild wires events.
|
|
98
98
|
* CSR side: element is a `data-bf-ph` placeholder → createComponent
|
|
99
99
|
* replaces it, then initChild runs against the new element.
|
|
100
|
+
*
|
|
101
|
+
* The placeholder is passed to `createComponent` as its `mountAt`
|
|
102
|
+
* argument so the component's own init runs connected — context resolves
|
|
103
|
+
* by DOM position, and a detached init falls back to the global,
|
|
104
|
+
* last-writer-wins context store.
|
|
100
105
|
*/
|
|
101
106
|
export function stringifyBranchChildComponentInits(
|
|
102
107
|
lines: string[],
|
|
@@ -104,7 +109,7 @@ export function stringifyBranchChildComponentInits(
|
|
|
104
109
|
indent: string,
|
|
105
110
|
): void {
|
|
106
111
|
for (const init of plan) {
|
|
107
|
-
lines.push(`${indent}{ let __c = qsa(__branchScope, ${init.selector}); if (!__c) { const __ph = __branchScope.querySelector('[${DATA_BF_PH}="${init.placeholderId}"]'); if (__ph) { __c = createComponent('${nameForRegistryRef(init.name)}', ${init.propsExpr}
|
|
112
|
+
lines.push(`${indent}{ let __c = qsa(__branchScope, ${init.selector}); if (!__c) { const __ph = __branchScope.querySelector('[${DATA_BF_PH}="${init.placeholderId}"]'); if (__ph) { __c = createComponent('${nameForRegistryRef(init.name)}', ${init.propsExpr}, undefined, undefined, __ph) } } if (__c) initChild('${nameForRegistryRef(init.name)}', __c, ${init.propsExpr}) }`)
|
|
108
113
|
}
|
|
109
114
|
}
|
|
110
115
|
|
|
@@ -37,6 +37,7 @@ import { buildSkeletonPathPlan, type SkeletonPathPlan } from './skeleton-paths.t
|
|
|
37
37
|
import { stringifyComponentLoop } from './component-loop.ts'
|
|
38
38
|
import { stringifyCompositeLoop } from './composite-loop.ts'
|
|
39
39
|
import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from './claim-plan.ts'
|
|
40
|
+
import { stringifyLazyRowLoop } from './lazy-row.ts'
|
|
40
41
|
import type { LoopChildRefBinding, LoopPlan, PlainLoopPlan, StaticLoopPlan } from '../plan/types.ts'
|
|
41
42
|
|
|
42
43
|
/**
|
|
@@ -182,6 +183,28 @@ export function stringifyPlainLoop(
|
|
|
182
183
|
return
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
// Lazy row graph (spec/slot-unification.md §9, L3): rows carry NO per-row
|
|
187
|
+
// reactive resources. `plan.lazyRow` is set only when
|
|
188
|
+
// `lazyRowEligibility` accepted this loop; every other loop falls through
|
|
189
|
+
// to the eager emission below, byte-for-byte unchanged.
|
|
190
|
+
if (plan.lazyRow) {
|
|
191
|
+
stringifyLazyRowLoop(lines, {
|
|
192
|
+
indent: topIndent,
|
|
193
|
+
containerVar,
|
|
194
|
+
guardContainer: false,
|
|
195
|
+
markerId,
|
|
196
|
+
arrayExpr,
|
|
197
|
+
keyFn,
|
|
198
|
+
paramHead,
|
|
199
|
+
indexParam,
|
|
200
|
+
template,
|
|
201
|
+
skeletonTemplate,
|
|
202
|
+
skeletonPaths: plan.skeletonPaths,
|
|
203
|
+
lazyRow: plan.lazyRow,
|
|
204
|
+
})
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
185
208
|
// Hoisted shared-template fast path (perf, see `buildLoopSkeletonTemplate`):
|
|
186
209
|
// declare the once-per-loop template BEFORE the `mapArray` call so every
|
|
187
210
|
// row clones from an already-parsed node instead of re-running
|
|
@@ -25,6 +25,7 @@ import { internalInvariant } from '../errors.ts'
|
|
|
25
25
|
import { buildInsertPlan } from './control-flow/plan/build-insert.ts'
|
|
26
26
|
import { stringifyInsert } from './control-flow/stringify/insert.ts'
|
|
27
27
|
import { buildLoopPlan } from './control-flow/plan/build-loop.ts'
|
|
28
|
+
import { buildLazyRowScopeInfo } from './control-flow/plan/build-lazy-row.ts'
|
|
28
29
|
import { stringifyLoop } from './control-flow/stringify/loop.ts'
|
|
29
30
|
import {
|
|
30
31
|
buildDynamicLoopDelegationPlan,
|
|
@@ -36,7 +37,7 @@ import { stringifyEventDelegation } from './control-flow/stringify/event-delegat
|
|
|
36
37
|
export function emitConditionalUpdates(lines: string[], ctx: ClientJsContext): void {
|
|
37
38
|
const profileComponentName = ctx.profile ? ctx.componentName : undefined
|
|
38
39
|
for (const elem of ctx.conditionalElements) {
|
|
39
|
-
const plan = buildInsertPlan(elem, { scope: { kind: 'top' }, eventNameMode: 'dom', profileComponentName })
|
|
40
|
+
const plan = buildInsertPlan(elem, { scope: { kind: 'top' }, eventNameMode: 'dom', profileComponentName, lazyScope: buildLazyRowScopeInfo(ctx) })
|
|
40
41
|
stringifyInsert(lines, plan, { leadingIndent: ' ', bodyIndent: ' ' })
|
|
41
42
|
lines.push('')
|
|
42
43
|
}
|
|
@@ -46,7 +47,7 @@ export function emitConditionalUpdates(lines: string[], ctx: ClientJsContext): v
|
|
|
46
47
|
export function emitClientOnlyConditionals(lines: string[], ctx: ClientJsContext): void {
|
|
47
48
|
const profileComponentName = ctx.profile ? ctx.componentName : undefined
|
|
48
49
|
for (const elem of ctx.clientOnlyConditionals) {
|
|
49
|
-
const plan = buildInsertPlan(elem, { scope: { kind: 'top' }, eventNameMode: 'raw', profileComponentName })
|
|
50
|
+
const plan = buildInsertPlan(elem, { scope: { kind: 'top' }, eventNameMode: 'raw', profileComponentName, lazyScope: buildLazyRowScopeInfo(ctx) })
|
|
50
51
|
lines.push(` // @client conditional: ${elem.slotId}`)
|
|
51
52
|
stringifyInsert(lines, plan, { leadingIndent: ' ', bodyIndent: ' ' })
|
|
52
53
|
lines.push('')
|
|
@@ -66,10 +67,14 @@ export function emitClientOnlyConditionals(lines: string[], ctx: ClientJsContext
|
|
|
66
67
|
* event surface, no delegation pass needed
|
|
67
68
|
*/
|
|
68
69
|
export function emitLoopUpdates(lines: string[], ctx: ClientJsContext, unsafeLocalNames: Set<string>): void {
|
|
70
|
+
// Lazy row graph (§9.4) name facts — built once per component, consulted
|
|
71
|
+
// by every plain loop's eligibility gate.
|
|
72
|
+
const lazyScope = buildLazyRowScopeInfo(ctx)
|
|
69
73
|
for (const elem of ctx.loopElements) {
|
|
70
74
|
const plan = buildLoopPlan(elem, {
|
|
71
75
|
unsafeLocalNames,
|
|
72
76
|
profileComponentName: ctx.profile ? ctx.componentName : undefined,
|
|
77
|
+
lazyScope,
|
|
73
78
|
})
|
|
74
79
|
// Stage 3 root cure — a JSX-bearing preamble can only be spliced into a
|
|
75
80
|
// string-templated row (renderPreamble). Every shape that reaches a
|
|
@@ -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', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'mapArrayAnchored', 'patchLeaf', 'createDisposableEffect',
|
|
11
|
+
'hydrate', 'insert', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'mapArrayAnchored', 'mapArrayLazy', 'patchLeaf', 'createDisposableEffect',
|
|
12
12
|
'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild',
|
|
13
13
|
'createPortal',
|
|
14
14
|
'provideContext', 'createContext', 'useContext',
|
|
@@ -17,7 +17,13 @@ export const RUNTIME_IMPORT_CANDIDATES = [
|
|
|
17
17
|
// Claim-plan interpreter (slot unification A2/A3, spec/slot-unification.md)
|
|
18
18
|
// — the "one claim mechanism" that replaced `patchSlotRange` and
|
|
19
19
|
// `updateClientMarker` (both deleted) as the content-slot update door.
|
|
20
|
-
|
|
20
|
+
// `lazyClaimSlots` is the read-capable twin of `lazySlots` over the same
|
|
21
|
+
// claim — emitted only by lazy loops that seed an outer-involving TEXT
|
|
22
|
+
// binding by read-compare-write (§9.3(1)).
|
|
23
|
+
// `textOrNode` is the 'text' door's Node guard: a child-position value that
|
|
24
|
+
// turns out to be a live Node must reach the writer as a Node so the claim
|
|
25
|
+
// can promote to 'markup', never as `String(node)`.
|
|
26
|
+
'claimSlots', 'lazySlots', 'lazyClaimSlots', 'textOrNode',
|
|
21
27
|
// Profile mode (#1690, SR3) — turn-boundary markers around event handlers.
|
|
22
28
|
'beginTurn', 'endTurn',
|
|
23
29
|
// Catalogued `Date` lowering (#2274/#2292) — the client counterpart to
|
package/src/jsx-to-ir.ts
CHANGED
|
@@ -2707,7 +2707,24 @@ function transformConditionalBranch(
|
|
|
2707
2707
|
const callsReactive = exprCallsReactiveGetters(node, ctx)
|
|
2708
2708
|
const hasCalls = exprHasFunctionCalls(node)
|
|
2709
2709
|
const reactive = isReactiveExpression(exprText, ctx, node) || isReactiveOrigin(branchOrigin)
|
|
2710
|
-
|
|
2710
|
+
// A branch whose entire value is a bare loop-item read (`row.label`) sets
|
|
2711
|
+
// neither `reactive` nor `callsReactive`: `render-item` is deliberately
|
|
2712
|
+
// excluded from `REACTIVE_BINDING_KINDS` (types.ts) because per-item
|
|
2713
|
+
// reactivity flows through the loop's own per-item signal accessor, not
|
|
2714
|
+
// this origin-based classification, and there is no call to trip
|
|
2715
|
+
// `callsReactive`/`hasCalls` either. Without a slotId here, a keyed loop
|
|
2716
|
+
// row that changes value without its condition flipping has nothing for
|
|
2717
|
+
// `collectLoopChildReactiveTexts` (ir-to-client-js/reactivity.ts) to
|
|
2718
|
+
// attach an update effect to, and the branch is frozen at its
|
|
2719
|
+
// mount-time value forever (the loop-branch-stale-text defect). Read the
|
|
2720
|
+
// freeRefs this function already computed above — no new parse, and no
|
|
2721
|
+
// regex re-scan of `exprText` (contrast the legacy `referencesLoopParam`
|
|
2722
|
+
// used by the sibling `transformConditional`/`transformLogicalAnd`
|
|
2723
|
+
// condition-side decisions, which token-matches the loop param name
|
|
2724
|
+
// against expression TEXT and can false-match inside an unrelated string
|
|
2725
|
+
// literal branch like `"this row is empty"`).
|
|
2726
|
+
const refsLoopParam = branchOrigin.freeRefs?.some(r => r.kind === 'render-item') ?? false
|
|
2727
|
+
const needsSlot = reactive || callsReactive || refsLoopParam
|
|
2711
2728
|
const slotId = needsSlot ? generateSlotId(ctx) : null
|
|
2712
2729
|
return {
|
|
2713
2730
|
type: 'expression',
|