@barefootjs/client 0.26.4 → 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/reactive.d.ts.map +1 -1
- package/dist/runtime/claim-slots.d.ts +222 -0
- package/dist/runtime/claim-slots.d.ts.map +1 -0
- package/dist/runtime/component.d.ts +40 -18
- package/dist/runtime/component.d.ts.map +1 -1
- package/dist/runtime/dynamic-text.d.ts +24 -1
- package/dist/runtime/dynamic-text.d.ts.map +1 -1
- package/dist/runtime/index.d.ts +4 -5
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +467 -259
- package/dist/runtime/loop-markers.d.ts +26 -0
- package/dist/runtime/loop-markers.d.ts.map +1 -0
- package/dist/runtime/map-array-lazy.d.ts +164 -0
- package/dist/runtime/map-array-lazy.d.ts.map +1 -0
- package/dist/runtime/map-array.d.ts +35 -0
- package/dist/runtime/map-array.d.ts.map +1 -1
- package/dist/runtime/qsa-item.d.ts +7 -0
- package/dist/runtime/qsa-item.d.ts.map +1 -1
- package/dist/runtime/registry.d.ts.map +1 -1
- package/dist/runtime/standalone.js +455 -248
- package/package.json +2 -2
- package/src/reactive.ts +2 -1
- package/src/runtime/claim-slots.ts +647 -0
- package/src/runtime/component.ts +153 -70
- package/src/runtime/dynamic-text.ts +24 -1
- package/src/runtime/index.ts +20 -7
- package/src/runtime/insert.ts +1 -1
- package/src/runtime/loop-markers.ts +100 -0
- package/src/runtime/map-array-lazy.ts +470 -0
- package/src/runtime/map-array.ts +68 -11
- package/src/runtime/qsa-item.ts +9 -3
- package/src/runtime/registry.ts +5 -3
- package/dist/runtime/client-marker.d.ts +0 -21
- package/dist/runtime/client-marker.d.ts.map +0 -1
- package/dist/runtime/list.d.ts +0 -21
- package/dist/runtime/list.d.ts.map +0 -1
- package/dist/runtime/patch-slot-range.d.ts +0 -47
- package/dist/runtime/patch-slot-range.d.ts.map +0 -1
- package/dist/runtime/reconcile-elements.d.ts +0 -44
- package/dist/runtime/reconcile-elements.d.ts.map +0 -1
- package/src/runtime/client-marker.ts +0 -46
- package/src/runtime/list.ts +0 -47
- package/src/runtime/patch-slot-range.ts +0 -105
- package/src/runtime/reconcile-elements.ts +0 -391
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@barefootjs/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "BarefootJS client package: reactive primitives (SSR-safe) plus browser runtime under the `/runtime` subpath (compiler target)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"directory": "packages/client"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@barefootjs/shared": "0.
|
|
58
|
+
"@barefootjs/shared": "0.28.0"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"@barefootjs/jsx": ">=0.2.0"
|
package/src/reactive.ts
CHANGED
|
@@ -319,7 +319,8 @@ export function createSignal<T>(initialValue: T, __bfId?: string): Signal<T> {
|
|
|
319
319
|
export function createEffect(fn: EffectFn, __bfId?: string, __bfKind: SubscriberKind = 'effect'): void {
|
|
320
320
|
// Note: Nested effects are now allowed. runEffect() properly saves/restores
|
|
321
321
|
// prevEffect, so nested effects correctly track their own dependencies.
|
|
322
|
-
// This enables synchronous component initialization
|
|
322
|
+
// This enables synchronous component initialization inside loop reconcilers
|
|
323
|
+
// (mapArray/mapArrayAnchored).
|
|
323
324
|
|
|
324
325
|
const effect: EffectContext = {
|
|
325
326
|
fn,
|
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claim-plan interpreter and claimed-slot primitives (slot unification
|
|
3
|
+
* Steps A2/A3, `spec/slot-unification.md` §4/§5).
|
|
4
|
+
*
|
|
5
|
+
* This module is the ONE claim mechanism the spec's §4 target architecture
|
|
6
|
+
* describes: a compile-time `ClaimPlan` (per-slot child-index paths from a
|
|
7
|
+
* claim root down to the slot's anchor comment) is resolved ONCE — either
|
|
8
|
+
* eagerly (`claimSlots`) or lazily on first write (`lazySlots`) — and every
|
|
9
|
+
* later write goes through the held reference, never re-scanning the DOM.
|
|
10
|
+
* As of A3 the compiler emits claim plans for every content slot; the
|
|
11
|
+
* `patchSlotRange` and `updateClientMarker` mechanisms it superseded are
|
|
12
|
+
* deleted. `$t`/text-effect writes and `__bfText` are ALSO superseded for
|
|
13
|
+
* every emission site but one — see `dynamic-text.ts`'s docstring for the
|
|
14
|
+
* one deliberately-deferred case (`emitDynamicTextUpdates`'s
|
|
15
|
+
* `conditionalElems` path).
|
|
16
|
+
*
|
|
17
|
+
* Anchors are still the existing `<!--bf:sN-->…<!--/-->` marker pairs — SSR
|
|
18
|
+
* bytes are unchanged in Step A (§5, §6) so the new client claims against
|
|
19
|
+
* known-good SSR output. A path is only required to be valid AT THE MOMENT
|
|
20
|
+
* OF CLAIM (§2): once a 'text' Text node or a 'markup' boundary pair is
|
|
21
|
+
* held, subsequent writes never consult the path or the marker again, so a
|
|
22
|
+
* sibling slot's later variable-length change cannot invalidate anything
|
|
23
|
+
* already claimed.
|
|
24
|
+
*
|
|
25
|
+
* Kind contracts (mirroring the mechanisms being superseded — this is the
|
|
26
|
+
* "one slot concept with an identity contract" of §4):
|
|
27
|
+
* - 'text': held ref is the Text node immediately after the anchor
|
|
28
|
+
* comment. The CLAIM is non-mutating: it adopts that node when SSR
|
|
29
|
+
* rendered the slot non-empty and otherwise holds the ANCHOR COMMENT
|
|
30
|
+
* itself as a stand-in for the not-yet-created node, deferring
|
|
31
|
+
* `document.createTextNode` to the first write that actually needs it
|
|
32
|
+
* (`materializeText` reads the insertion point off that comment). That
|
|
33
|
+
* one-field representation is deliberate — see `ClaimedTextSlot`. What
|
|
34
|
+
* lets
|
|
35
|
+
* `read` exist — a seed that only compares must be able to inspect a
|
|
36
|
+
* slot without leaving an empty Text node behind on every row
|
|
37
|
+
* (§9.3(1)). Writes are a `nodeValue` assignment, and once materialized
|
|
38
|
+
* the node's identity never changes — the guarantee effect closures and
|
|
39
|
+
* `mapArray`'s same-key path rely on.
|
|
40
|
+
* - 'markup': held ref is BOTH boundary comments (start = anchor, end =
|
|
41
|
+
* the matching `<!--/-->` found by a nesting-depth walk (any further
|
|
42
|
+
* `bf:`-prefixed comment along the way opens a nested region). A string
|
|
43
|
+
* write clears everything strictly between the boundaries and inserts
|
|
44
|
+
* freshly `<template>`-parsed HTML before the end comment; a `Node`
|
|
45
|
+
* write clears the range and splices the node in by identity (the
|
|
46
|
+
* `__bfText` live-Node case). The boundaries themselves are never
|
|
47
|
+
* removed, so the range stays writeable on every later write. Because
|
|
48
|
+
* the end ref is already held, a write never needs to re-walk for
|
|
49
|
+
* nesting depth — only the CLAIM does.
|
|
50
|
+
*
|
|
51
|
+
* Warn-don't-guess (§4, "one ownership rule"): a path that fails to resolve
|
|
52
|
+
* to its slot's own `bf:sN` comment — out of range, or shape drift where
|
|
53
|
+
* the path now lands on some other node — falls back to a marker scan
|
|
54
|
+
* within the claim root, using the same ownership rule as `query.ts`'s `$t`:
|
|
55
|
+
* a `bf:sN` comment owned by a nested `bf-s` scope (a child component's own
|
|
56
|
+
* same-numbered slot — ids are
|
|
57
|
+
* assigned per component, so collisions are expected) is never a candidate
|
|
58
|
+
* — UNLESS the id is `^`-prefixed (`BF_PARENT_OWNED_PREFIX`), meaning the
|
|
59
|
+
* marker is content the claiming component itself authored and merely
|
|
60
|
+
* forwarded through one or more descendants' `children` (see
|
|
61
|
+
* `findOwnedMarker`'s docstring); for those the ownership walk is skipped
|
|
62
|
+
* outright, matching `query.ts`'s `$()`. If neither the path nor the
|
|
63
|
+
* fallback scan finds an owned marker, that one slot is dropped with a
|
|
64
|
+
* `console.warn` and the rest of the plan still claims — never guess a
|
|
65
|
+
* boundary, and never let one bad slot sink its siblings. An EMPTY path
|
|
66
|
+
* (`[]`) skips straight to the scan without that first warning — the
|
|
67
|
+
* compiler emits `path: []` deliberately when a slot's position can't be
|
|
68
|
+
* statically pathed (slot unification A3), so a miss there is expected, not
|
|
69
|
+
* drift; only a non-empty path that fails to resolve signals a real shape
|
|
70
|
+
* mismatch worth warning about.
|
|
71
|
+
*
|
|
72
|
+
* Row-pristine lazy claim (§3(a)): `lazySlots` touches NOTHING until the
|
|
73
|
+
* first write, and that first write claims the WHOLE plan at once (not just
|
|
74
|
+
* the slot being written) — so no earlier write into one slot can shift a
|
|
75
|
+
* sibling slot's still-unclaimed path out from under it. A row that never
|
|
76
|
+
* updates never pays for a claim at all. `claimSlots` is the eager escape
|
|
77
|
+
* hatch for callers that cannot honor that invariant (streaming/portal
|
|
78
|
+
* paths that mutate row content before any write would occur, per §6's
|
|
79
|
+
* risk note) — it claims every slot in the plan immediately.
|
|
80
|
+
*
|
|
81
|
+
* Dedup, no trust-first-run (slot unification A3 follow-up): a 'markup'
|
|
82
|
+
* slot's write door holds a `last` value alongside its boundary refs. Every
|
|
83
|
+
* write — INCLUDING THE FIRST — clears-and-inserts unless the new string
|
|
84
|
+
* equals `last`, in which case the DOM touch is skipped. `last` starts
|
|
85
|
+
* `undefined`, which never equals a `String(...)`-coerced value, so the
|
|
86
|
+
* first write can never dedup away; it always patches. A `Node` write is
|
|
87
|
+
* deduped by identity (mirrors `__bfText`'s `value === current` check) and,
|
|
88
|
+
* like the string case, is never skipped on the first write — a freshly
|
|
89
|
+
* `createComponent`-built element is a distinct object from whatever the
|
|
90
|
+
* SSR markup rendered, so it always splices. 'text' writes stay a plain
|
|
91
|
+
* `nodeValue` assignment (already idempotent, per §5's design note) — no
|
|
92
|
+
* dedup state needed.
|
|
93
|
+
*
|
|
94
|
+
* The first write is never skipped on the assumption that the claimed
|
|
95
|
+
* range already matches SSR/CSR content ("trust-first-run") — that
|
|
96
|
+
* assumption only holds for a preamble-region row whose SSR content and
|
|
97
|
+
* the effect's mount-time recomputation are both derived from the exact
|
|
98
|
+
* same source data, so they cannot disagree. It is false in general: any
|
|
99
|
+
* markup slot whose value comes from client-only state that the server
|
|
100
|
+
* cannot see — `createSignal(readFromLocalStorage())`, a client-side region
|
|
101
|
+
* swap adopting HTML the server rendered from a different default — can
|
|
102
|
+
* genuinely differ from the SSR/CSR content on the very first write, and
|
|
103
|
+
* skipping that first write would silently leave the stale SSR default on
|
|
104
|
+
* screen until the NEXT change (regression pin: site/ui's
|
|
105
|
+
* `admin-gallery.spec.ts` cross-page time-range persistence test). So every
|
|
106
|
+
* write unconditionally applies unless deduped by value/identity — never
|
|
107
|
+
* because it happens to be the first one — for every 'markup' caller,
|
|
108
|
+
* including the preamble-region case (which only loses a same-value
|
|
109
|
+
* redundant-patch skip on mount, not correctness).
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
import { BF_SCOPE, BF_PARENT_OWNED_PREFIX } from '@barefootjs/shared'
|
|
113
|
+
import { commentsInScope } from './query.ts'
|
|
114
|
+
import { commentScopeRegistry } from './scope.ts'
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A slot's compile-time descriptor. `path` is the list of child indices
|
|
118
|
+
* from the claim root to the slot's ANCHOR NODE — in Step A that anchor is
|
|
119
|
+
* always the existing `<!--bf:sN-->` start comment (markers are still
|
|
120
|
+
* emitted; Step B may point paths at other node kinds), so resolution
|
|
121
|
+
* walks `childNodes` by index with no assumption about the target's
|
|
122
|
+
* `nodeType` until the kind-specific claim inspects it. `id` is kept for
|
|
123
|
+
* diagnostics and as the marker-scan fallback's search key.
|
|
124
|
+
*/
|
|
125
|
+
export interface SlotSpec {
|
|
126
|
+
id: string
|
|
127
|
+
kind: 'text' | 'markup'
|
|
128
|
+
path: readonly number[]
|
|
129
|
+
/**
|
|
130
|
+
* Slot unification Step B (`spec/slot-unification.md` §3(b), §5 Step B):
|
|
131
|
+
* true when NO `<!--bf:id-->…<!--/-->` marker was emitted for this slot at
|
|
132
|
+
* all — `path` is then a path to the slot's POSITION itself (the LAST
|
|
133
|
+
* index is this slot's own index within its parent's `childNodes`, not an
|
|
134
|
+
* anchor comment to search from). Only ever set for `kind: 'text'` — a
|
|
135
|
+
* `'markup'` slot always keeps its markers (an empty-able range needs a
|
|
136
|
+
* physical anchor to splice into; see `spec/slot-unification.md` §3(b)
|
|
137
|
+
* case (ii)). Resolution CREATES a Text node at that position if SSR
|
|
138
|
+
* rendered the slot empty (nothing to adopt there yet) — see `claimOne`.
|
|
139
|
+
* The compiler emits this only when it has already proven the position
|
|
140
|
+
* safe (`client-only-elision.ts`); the runtime never re-derives it.
|
|
141
|
+
*/
|
|
142
|
+
markerless?: boolean
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type ClaimPlan = readonly SlotSpec[]
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A claimed 'text' slot, represented by ONE field. `ref` is the live Text
|
|
149
|
+
* node once the slot is materialized, and until then — only possible for a
|
|
150
|
+
* MARKED slot SSR rendered empty — the slot's anchor Comment, which doubles
|
|
151
|
+
* as the record of where the Text node must be created. `nodeType`
|
|
152
|
+
* discriminates the two.
|
|
153
|
+
*
|
|
154
|
+
* Claiming a marked text slot therefore never mutates the DOM, which is
|
|
155
|
+
* what the read door depends on: a seed that only compares must not leave a
|
|
156
|
+
* trail of empty Text nodes across every row (`spec/slot-unification.md`
|
|
157
|
+
* §9.3(1)). Markerless slots keep the original eager creation and so always
|
|
158
|
+
* arrive here already materialized — see `claimMarkerlessText` for why
|
|
159
|
+
* deferring them would cost more than it saves.
|
|
160
|
+
*
|
|
161
|
+
* One field rather than a node-plus-site pair because this object is
|
|
162
|
+
* allocated once per slot per row: on a 1k-row list every extra field is
|
|
163
|
+
* paid a thousand times over (measured: +77KB/1k rows for a
|
|
164
|
+
* node+after+parent+index shape). `materializeText` re-reads the insertion
|
|
165
|
+
* point off the anchor at creation time rather than capturing it at claim
|
|
166
|
+
* time, so a sibling slot's write in between cannot stale it.
|
|
167
|
+
*
|
|
168
|
+
* Once materialized the node is held by identity forever, exactly as
|
|
169
|
+
* before: effect closures and `mapArray`'s same-key path rely on that.
|
|
170
|
+
*/
|
|
171
|
+
interface ClaimedTextSlot {
|
|
172
|
+
readonly kind: 'text'
|
|
173
|
+
/** Text node once materialized; the anchor Comment until then. */
|
|
174
|
+
ref: Text | Comment
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A claimed 'markup' slot: both boundary comments, held by identity.
|
|
179
|
+
* Content lives strictly between `start` and `end`; both survive every
|
|
180
|
+
* write. `last` is the trust-first-run + dedup state (see module docstring)
|
|
181
|
+
* — `undefined` until the first write, a `string` once a string has been
|
|
182
|
+
* recorded/patched, or the live `Node` once one has been spliced in.
|
|
183
|
+
*/
|
|
184
|
+
interface ClaimedMarkupSlot {
|
|
185
|
+
readonly kind: 'markup'
|
|
186
|
+
readonly start: Comment
|
|
187
|
+
readonly end: Comment
|
|
188
|
+
last: string | Node | undefined
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
type ClaimedSlotRef = ClaimedTextSlot | ClaimedMarkupSlot
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The result of claiming a plan: a write door keyed by slot id. Writing an
|
|
195
|
+
* id that failed to claim (or was never in the plan) warns and no-ops —
|
|
196
|
+
* one bad/missing slot never breaks any other slot's writes.
|
|
197
|
+
*/
|
|
198
|
+
export interface ClaimedSlots {
|
|
199
|
+
write(id: string, value: unknown): void
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* A claimed plan that can be read as well as written. Separate from
|
|
204
|
+
* {@link ClaimedSlots} because a door is allocated PER ROW: giving every
|
|
205
|
+
* claim a reader costs an extra closure on every row of a list, read or not
|
|
206
|
+
* (measured: ~40KB/1k rows). Only the loops that need read-compare-write
|
|
207
|
+
* seeding (`spec/slot-unification.md` §9.3(1)) ask for this shape. Both
|
|
208
|
+
* shapes sit on the SAME claim (`claimRefs`) — this is a second accessor
|
|
209
|
+
* bundle, never a second way to resolve a position (§2's claim-once rule).
|
|
210
|
+
*/
|
|
211
|
+
export interface ClaimedSlotsRW extends ClaimedSlots {
|
|
212
|
+
/**
|
|
213
|
+
* Current DOM text of a 'text' slot. `''` when the slot rendered empty,
|
|
214
|
+
* `null` when the slot cannot answer (not a 'text' slot, or it failed to
|
|
215
|
+
* claim); `null` MUST be treated by the caller as "differs, write it".
|
|
216
|
+
*/
|
|
217
|
+
read(id: string): string | null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** `lazySlots`'s per-write function — the same shape `ClaimedSlots.write` has. */
|
|
221
|
+
export type SlotWriter = (id: string, value: unknown) => void
|
|
222
|
+
|
|
223
|
+
// --- path resolution ---
|
|
224
|
+
|
|
225
|
+
/** Walk `childNodes` by index from `root`. No node-kind assumption — the
|
|
226
|
+
* caller checks whether the result is actually the expected comment. */
|
|
227
|
+
function resolvePath(root: Node, path: readonly number[]): Node | null {
|
|
228
|
+
let node: Node = root
|
|
229
|
+
for (const index of path) {
|
|
230
|
+
const child: Node | undefined = node.childNodes[index]
|
|
231
|
+
if (!child) return null
|
|
232
|
+
node = child
|
|
233
|
+
}
|
|
234
|
+
return node
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isSlotComment(node: Node | null, id: string): node is Comment {
|
|
238
|
+
return node != null && node.nodeType === Node.COMMENT_NODE && (node as Comment).nodeValue === `bf:${id}`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Fallback marker scan, used only when a slot's compile-time path fails to
|
|
243
|
+
* resolve to its own `bf:sN` comment (shape drift, or a plan built against
|
|
244
|
+
* a differently-shaped claim root). The ownership rule: a same-id marker
|
|
245
|
+
* owned by a nested `bf-s` scope (a child component's own slot — ids
|
|
246
|
+
* collide across components by design) is skipped so the fallback can
|
|
247
|
+
* never claim into a child's content.
|
|
248
|
+
*
|
|
249
|
+
* `commentsInScope` (not a bare `document.createTreeWalker(root, …)`) so a
|
|
250
|
+
* whole-item loop conditional's claim root (`insert.ts`'s detached
|
|
251
|
+
* `commentScopeRegistry` proxy for a `<!--bf-loop-i:key-->` anchor, #1665)
|
|
252
|
+
* resolves correctly: the proxy has no DOM children of its own — the row's
|
|
253
|
+
* real content lives as SIBLINGS of the registered comment — and
|
|
254
|
+
* `commentsInScope` already knows to walk that sibling range instead of
|
|
255
|
+
* `root`'s (empty) descendants. The ownership boundary adapts to match:
|
|
256
|
+
* every node in a comment-scope's range shares the registered comment's
|
|
257
|
+
* OWN parent element, so that (not the unreachable proxy `root`) is where
|
|
258
|
+
* the ancestor walk must stop.
|
|
259
|
+
*
|
|
260
|
+
* Parent-owned slots (`^`-prefixed id, `BF_PARENT_OWNED_PREFIX`) skip the
|
|
261
|
+
* ownership walk entirely — same carve-out as `query.ts`'s `$()` and its
|
|
262
|
+
* `findText` marker map. A `^sN` id is JSX children the CLAIMING component
|
|
263
|
+
* itself authored (e.g. `<Button><span>{displayText()}</span></Button>`)
|
|
264
|
+
* that only physically lands inside descendant components' DOM because it
|
|
265
|
+
* was forwarded through their `children` prop — every one of those
|
|
266
|
+
* descendants (Button, its own children, …) legitimately carries its own
|
|
267
|
+
* `bf-s` scope attribute, but that scope boundary says nothing about who
|
|
268
|
+
* authored THIS content. Without the carve-out, any slot forwarded more
|
|
269
|
+
* than zero levels deep is unfindable — every ordinary ancestor bf-s
|
|
270
|
+
* attribute trips the "nested scope" rejection meant for a same-numbered
|
|
271
|
+
* marker some unrelated component happens to render for itself.
|
|
272
|
+
*/
|
|
273
|
+
function findOwnedMarker(root: Element, id: string): Comment | null {
|
|
274
|
+
const marker = `bf:${id}`
|
|
275
|
+
const parentOwned = id.startsWith(BF_PARENT_OWNED_PREFIX)
|
|
276
|
+
const registryInfo = commentScopeRegistry.get(root)
|
|
277
|
+
const boundary = registryInfo ? registryInfo.commentNode.parentElement : root
|
|
278
|
+
for (const comment of commentsInScope(root)) {
|
|
279
|
+
if (comment.nodeValue !== marker) continue
|
|
280
|
+
if (parentOwned) return comment
|
|
281
|
+
let owned = true
|
|
282
|
+
for (let el = comment.parentElement; el && el !== boundary; el = el.parentElement) {
|
|
283
|
+
if (el.hasAttribute(BF_SCOPE)) {
|
|
284
|
+
owned = false
|
|
285
|
+
break
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (owned) return comment
|
|
289
|
+
}
|
|
290
|
+
return null
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Find the matching `<!--/-->` end comment for a 'markup' slot's start
|
|
295
|
+
* comment: any further `bf:`-prefixed comment along the way opens a nested
|
|
296
|
+
* region (a leaf rendered inside this one can carry its own ordinary slot
|
|
297
|
+
* markers) and increments a depth counter so that region's own `/` doesn't
|
|
298
|
+
* prematurely close this outer range. Runs once, at claim time — writes
|
|
299
|
+
* never need this since the end ref is held afterward.
|
|
300
|
+
*/
|
|
301
|
+
function findMarkupEnd(start: Comment): Comment | null {
|
|
302
|
+
let depth = 0
|
|
303
|
+
let node: Node | null = start.nextSibling
|
|
304
|
+
while (node) {
|
|
305
|
+
if (node.nodeType === Node.COMMENT_NODE) {
|
|
306
|
+
const value = (node as Comment).nodeValue ?? ''
|
|
307
|
+
if (value.startsWith('bf:')) {
|
|
308
|
+
depth++
|
|
309
|
+
} else if (value === '/') {
|
|
310
|
+
if (depth === 0) return node as Comment
|
|
311
|
+
depth--
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
node = node.nextSibling
|
|
315
|
+
}
|
|
316
|
+
return null
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Resolve one slot's anchor comment: try the compile-time path first, fall
|
|
321
|
+
* back to an owned marker scan on any miss (path resolves to nothing, or to
|
|
322
|
+
* a node that isn't this slot's own comment — shape drift), and warn on
|
|
323
|
+
* either the fallback-needed or the total-miss case — EXCEPT when the plan
|
|
324
|
+
* shipped an empty path (`spec.path.length === 0`, slot unification A3's
|
|
325
|
+
* "cannot be statically pathed" case, `spec/slot-unification.md` §5-A3):
|
|
326
|
+
* an empty path is a deliberate "no compile-time path available" marker,
|
|
327
|
+
* not a claim that index `0` addresses this slot, so going straight to the
|
|
328
|
+
* scan is the plan's INTENDED behavior, not a drift to warn about. Never
|
|
329
|
+
* throws — a bad slot returns `null` and the caller drops it from the
|
|
330
|
+
* claimed set.
|
|
331
|
+
*/
|
|
332
|
+
function resolveAnchor(root: Element, spec: SlotSpec): Comment | null {
|
|
333
|
+
if (spec.path.length > 0) {
|
|
334
|
+
const resolved = resolvePath(root, spec.path)
|
|
335
|
+
if (isSlotComment(resolved, spec.id)) return resolved
|
|
336
|
+
console.warn(
|
|
337
|
+
`[barefootjs] claim path for slot ${spec.id} did not resolve to its bf:${spec.id} marker; falling back to a scan`,
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const found = findOwnedMarker(root, spec.id)
|
|
342
|
+
if (!found) {
|
|
343
|
+
console.warn(`[barefootjs] slot ${spec.id} marker not found; skipping`)
|
|
344
|
+
}
|
|
345
|
+
return found
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Resolve a `markerless` 'text' slot (slot unification Step B): `path`'s
|
|
350
|
+
* LAST index is the slot's own position within its parent's `childNodes` —
|
|
351
|
+
* there is no anchor comment to walk from or scan for, since the compiler
|
|
352
|
+
* only ever sets `markerless` when it has already proven no marker is
|
|
353
|
+
* needed (`client-only-elision.ts`). If SSR/CSR rendered the slot non-empty,
|
|
354
|
+
* a Text node already sits at that position — adopt it. If SSR rendered it
|
|
355
|
+
* empty (the only case Step B currently elides — `/* @client *\/`
|
|
356
|
+
* expressions, always empty at claim time), nothing sits there yet — create
|
|
357
|
+
* one and insert it before whatever currently occupies that index (or at
|
|
358
|
+
* the end, if the index is past the end of `childNodes`). Never falls back
|
|
359
|
+
* to a marker scan — there is no marker to find — so a path miss here is a
|
|
360
|
+
* genuine, loud failure, not the "cannot be statically pathed" case
|
|
361
|
+
* `resolveAnchor`'s empty-path allowance covers.
|
|
362
|
+
*/
|
|
363
|
+
function claimMarkerlessText(root: Element, spec: SlotSpec): ClaimedTextSlot | null {
|
|
364
|
+
if (spec.path.length === 0) {
|
|
365
|
+
console.warn(`[barefootjs] markerless slot ${spec.id} has an empty path; skipping`)
|
|
366
|
+
return null
|
|
367
|
+
}
|
|
368
|
+
const parentPath = spec.path.slice(0, -1)
|
|
369
|
+
const idx = spec.path[spec.path.length - 1]
|
|
370
|
+
const parent = resolvePath(root, parentPath)
|
|
371
|
+
if (!parent) {
|
|
372
|
+
console.warn(`[barefootjs] markerless claim path for slot ${spec.id} did not resolve to a parent node; skipping`)
|
|
373
|
+
return null
|
|
374
|
+
}
|
|
375
|
+
const existing = parent.childNodes[idx] as Node | undefined
|
|
376
|
+
if (existing && existing.nodeType === Node.TEXT_NODE) {
|
|
377
|
+
return { kind: 'text', ref: existing as Text }
|
|
378
|
+
}
|
|
379
|
+
// Markerless slots keep the original eager creation. They exist only for
|
|
380
|
+
// Step B's `/* @client */` elision, which never applies inside a loop, so
|
|
381
|
+
// no markerless slot is ever a lazy-row seed target — deferring here would
|
|
382
|
+
// buy nothing and would need the parent+index pair the shape above avoids.
|
|
383
|
+
const node = document.createTextNode('')
|
|
384
|
+
parent.insertBefore(node, existing ?? null)
|
|
385
|
+
return { kind: 'text', ref: node }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Claim one slot per its kind's contract. `null` on any failure (already warned). */
|
|
389
|
+
function claimOne(root: Element, spec: SlotSpec): ClaimedSlotRef | null {
|
|
390
|
+
if (spec.kind === 'text' && spec.markerless) {
|
|
391
|
+
return claimMarkerlessText(root, spec)
|
|
392
|
+
}
|
|
393
|
+
const anchor = resolveAnchor(root, spec)
|
|
394
|
+
if (!anchor) return null
|
|
395
|
+
|
|
396
|
+
if (spec.kind === 'text') {
|
|
397
|
+
const next = anchor.nextSibling
|
|
398
|
+
return { kind: 'text', ref: next?.nodeType === Node.TEXT_NODE ? (next as Text) : anchor }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const end = findMarkupEnd(anchor)
|
|
402
|
+
if (!end) {
|
|
403
|
+
console.warn(`[barefootjs] slot ${spec.id} has no end marker; skipping`)
|
|
404
|
+
return null
|
|
405
|
+
}
|
|
406
|
+
return { kind: 'markup', start: anchor, end, last: undefined }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// --- writes ---
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Create the Text node a claim deliberately did not create, at the position
|
|
413
|
+
* the claim recorded. Reached only from a write against a slot SSR rendered
|
|
414
|
+
* empty — the one case where the DOM genuinely has nothing to write into.
|
|
415
|
+
*/
|
|
416
|
+
function materializeText(slot: ClaimedTextSlot): Text | null {
|
|
417
|
+
const anchor = slot.ref as Comment
|
|
418
|
+
const parent = anchor.parentNode
|
|
419
|
+
if (!parent) return null
|
|
420
|
+
const node = document.createTextNode('')
|
|
421
|
+
parent.insertBefore(node, anchor.nextSibling)
|
|
422
|
+
slot.ref = node
|
|
423
|
+
return node
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Current DOM text of a claimed 'text' slot; `''` while unmaterialized. */
|
|
427
|
+
function readText(slot: ClaimedTextSlot): string {
|
|
428
|
+
return slot.ref.nodeType === Node.TEXT_NODE ? ((slot.ref as Text).nodeValue ?? '') : ''
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function writeText(slot: ClaimedTextSlot, value: unknown): void {
|
|
432
|
+
const node = slot.ref.nodeType === Node.TEXT_NODE ? (slot.ref as Text) : materializeText(slot)
|
|
433
|
+
if (!node) return
|
|
434
|
+
node.nodeValue = String(value ?? '')
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Pass a live Node through untouched; coerce anything else with `String`.
|
|
439
|
+
*
|
|
440
|
+
* The 'text' door's counterpart to {@link escapeTextOrNode}. A 'text' slot
|
|
441
|
+
* writes through `nodeValue`, which needs no escaping — routing it through
|
|
442
|
+
* `escapeText` would double-escape — but it DOES need the Node case
|
|
443
|
+
* separated out, because a Text node cannot host an element and
|
|
444
|
+
* `String(node)` destroys it: `[object HTMLDivElement]` in a browser, the
|
|
445
|
+
* serialized markup rendered as visible text under some DOM shims. Wrong
|
|
446
|
+
* either way, and silently so, which is why the split lives here rather
|
|
447
|
+
* than at each call site.
|
|
448
|
+
*
|
|
449
|
+
* A Node reaches a content slot whenever a child-position interpolation
|
|
450
|
+
* calls something that builds one — `props.renderRow(item)` handed an
|
|
451
|
+
* inline-JSX arrow, which the compiler lifts into a component whose call
|
|
452
|
+
* returns a real element. Whether such a call returns a string or a Node is
|
|
453
|
+
* not decidable from the expression's syntax (both are `CallExpression`), so
|
|
454
|
+
* the decision belongs at runtime, on the value.
|
|
455
|
+
*
|
|
456
|
+
* `String(value)`, not `String(value ?? '')`: a non-Node value must coerce
|
|
457
|
+
* exactly as the previous inline `String(...)` emission did. The nullish
|
|
458
|
+
* collapse stays where it already was, in {@link writeText}.
|
|
459
|
+
*/
|
|
460
|
+
export function textOrNode(value: unknown): string | Node {
|
|
461
|
+
if (typeof Node !== 'undefined' && value instanceof Node) return value
|
|
462
|
+
return String(value)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* A Node landed on a slot claimed as 'text'. Promote the claim to the
|
|
467
|
+
* 'markup' contract in place — the anchor comment becomes `start`, its
|
|
468
|
+
* matching `<!--/-->` becomes `end` — so this and every later write on the
|
|
469
|
+
* id goes through {@link writeMarkup}, which already splices Nodes by
|
|
470
|
+
* identity.
|
|
471
|
+
*
|
|
472
|
+
* This is a promotion, not a re-claim: the anchor is the SAME comment the
|
|
473
|
+
* original claim resolved (§2's claim-once rule holds — no second position
|
|
474
|
+
* resolution). The Text node the claim adopted or created, if any, sits
|
|
475
|
+
* inside the new range and `clearMarkupRange` removes it on the write.
|
|
476
|
+
*
|
|
477
|
+
* `null` when the slot cannot host a Node — a markerless slot (Step B
|
|
478
|
+
* elision: no anchor to promote from) or a marked slot whose end comment is
|
|
479
|
+
* missing. Both warn: refusing loudly beats stringifying an element into
|
|
480
|
+
* visible `[object HTMLDivElement]`.
|
|
481
|
+
*/
|
|
482
|
+
function promoteTextToMarkup(slot: ClaimedTextSlot, id: string): ClaimedMarkupSlot | null {
|
|
483
|
+
const anchor = slot.ref.nodeType === Node.COMMENT_NODE
|
|
484
|
+
? (slot.ref as Comment)
|
|
485
|
+
// Materialized: `claimOne` adopts/creates the Text node immediately after
|
|
486
|
+
// the anchor, so the anchor is its previous sibling — unless the slot is
|
|
487
|
+
// markerless, where there is no anchor at all and `isSlotComment` says so.
|
|
488
|
+
: isSlotComment(slot.ref.previousSibling, id) ? slot.ref.previousSibling : null
|
|
489
|
+
if (!anchor) {
|
|
490
|
+
console.warn(
|
|
491
|
+
`[barefootjs] slot ${id} was claimed as text and received a Node, but has no anchor marker to promote from; write ignored`,
|
|
492
|
+
)
|
|
493
|
+
return null
|
|
494
|
+
}
|
|
495
|
+
const end = findMarkupEnd(anchor)
|
|
496
|
+
if (!end) {
|
|
497
|
+
console.warn(
|
|
498
|
+
`[barefootjs] slot ${id} was claimed as text and received a Node, but has no end marker; write ignored`,
|
|
499
|
+
)
|
|
500
|
+
return null
|
|
501
|
+
}
|
|
502
|
+
return { kind: 'markup', start: anchor, end, last: undefined }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Remove every node strictly between `start` and `end` (both survive). */
|
|
506
|
+
function clearMarkupRange(start: Comment, end: Comment): void {
|
|
507
|
+
const parent = end.parentNode
|
|
508
|
+
if (!parent) return
|
|
509
|
+
let node: Node | null = start.nextSibling
|
|
510
|
+
while (node && node !== end) {
|
|
511
|
+
const next = node.nextSibling
|
|
512
|
+
parent.removeChild(node)
|
|
513
|
+
node = next
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function writeMarkup(ref: ClaimedMarkupSlot, value: unknown): void {
|
|
518
|
+
const { start, end } = ref
|
|
519
|
+
const parent = end.parentNode
|
|
520
|
+
if (!parent) return
|
|
521
|
+
|
|
522
|
+
// Slot markers (`__slot()`, `@barefootjs/client/slot.ts`): a caller-passed
|
|
523
|
+
// JSX prop that itself contains a component. Leave the server-rendered DOM
|
|
524
|
+
// untouched entirely — no write, no `last` update either, so a later real
|
|
525
|
+
// value still gets a correct dedup read. Mirrors `__bfText`'s identical
|
|
526
|
+
// guard (#1663).
|
|
527
|
+
if (value != null && (value as { __isSlot?: boolean }).__isSlot) return
|
|
528
|
+
|
|
529
|
+
if (typeof Node !== 'undefined' && value instanceof Node) {
|
|
530
|
+
// Identity dedup, mirrors `__bfText`'s `value === current` check — the
|
|
531
|
+
// same live node handed back again is a no-op. `ref.last` starts
|
|
532
|
+
// `undefined`, which no real Node is ever `===` to, so the first Node
|
|
533
|
+
// write always splices — a freshly rendered Node is never the
|
|
534
|
+
// SSR-rendered markup by identity.
|
|
535
|
+
if (value === ref.last) return
|
|
536
|
+
clearMarkupRange(start, end)
|
|
537
|
+
parent.insertBefore(value, end)
|
|
538
|
+
ref.last = value
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const text = String(value ?? '')
|
|
543
|
+
if (text === ref.last) return // dedup: identical string, skip the DOM touch
|
|
544
|
+
clearMarkupRange(start, end)
|
|
545
|
+
const tpl = document.createElement('template')
|
|
546
|
+
tpl.innerHTML = text
|
|
547
|
+
parent.insertBefore(tpl.content, end)
|
|
548
|
+
ref.last = text
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function writeSlot(refs: Map<string, ClaimedSlotRef>, id: string, value: unknown): void {
|
|
552
|
+
const ref = refs.get(id)
|
|
553
|
+
if (!ref) {
|
|
554
|
+
console.warn(`[barefootjs] no claimed slot for id ${id}; write ignored`)
|
|
555
|
+
return
|
|
556
|
+
}
|
|
557
|
+
if (ref.kind === 'text') {
|
|
558
|
+
// A 'text' slot cannot represent an element. Promote once, then fall
|
|
559
|
+
// through to the markup writer — which is also what makes the read door
|
|
560
|
+
// answer `null` for this id afterwards, i.e. "cannot answer, write it",
|
|
561
|
+
// the conservative direction.
|
|
562
|
+
if (typeof Node !== 'undefined' && value instanceof Node) {
|
|
563
|
+
const promoted = promoteTextToMarkup(ref, id)
|
|
564
|
+
if (!promoted) return
|
|
565
|
+
refs.set(id, promoted)
|
|
566
|
+
writeMarkup(promoted, value)
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
writeText(ref, value)
|
|
570
|
+
} else {
|
|
571
|
+
writeMarkup(ref, value)
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Read half of the door. `null` means "cannot answer" — the slot is not a
|
|
577
|
+
* 'text' slot, or it never claimed — and every caller must treat that as
|
|
578
|
+
* "differs" and write. Conservative, never unsound. Reads are silent: a
|
|
579
|
+
* slot that failed to claim already warned when it did so.
|
|
580
|
+
*/
|
|
581
|
+
function readSlot(refs: ReadonlyMap<string, ClaimedSlotRef>, id: string): string | null {
|
|
582
|
+
const ref = refs.get(id)
|
|
583
|
+
if (!ref || ref.kind !== 'text') return null
|
|
584
|
+
return readText(ref)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// --- public API ---
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Claim every slot in `plan` against `root` NOW. Escape hatch for callers
|
|
591
|
+
* that cannot honor the row-pristine invariant `lazySlots` relies on
|
|
592
|
+
* (streaming/portal paths that may mutate row content before any write
|
|
593
|
+
* would naturally occur, per §6) — claim eagerly there instead.
|
|
594
|
+
*/
|
|
595
|
+
export function claimSlots(root: Element, plan: ClaimPlan): ClaimedSlots {
|
|
596
|
+
const refs = claimRefs(root, plan)
|
|
597
|
+
return { write: (id, value) => writeSlot(refs, id, value) }
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Resolve every slot in `plan`; slots that fail to claim are simply absent. */
|
|
601
|
+
function claimRefs(root: Element, plan: ClaimPlan): Map<string, ClaimedSlotRef> {
|
|
602
|
+
const refs = new Map<string, ClaimedSlotRef>()
|
|
603
|
+
for (const spec of plan) {
|
|
604
|
+
const ref = claimOne(root, spec)
|
|
605
|
+
if (ref) refs.set(spec.id, ref)
|
|
606
|
+
}
|
|
607
|
+
return refs
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Lazy wrapper honoring the row-pristine invariant (§3(a)): nothing touches
|
|
612
|
+
* `root`'s DOM until the first write, and that first write claims the
|
|
613
|
+
* WHOLE plan at once — so no earlier write into a sibling slot can shift
|
|
614
|
+
* this row's still-unclaimed paths first. A row that never updates never
|
|
615
|
+
* pays for a claim at all.
|
|
616
|
+
*/
|
|
617
|
+
export function lazySlots(root: Element, plan: ClaimPlan): SlotWriter {
|
|
618
|
+
let claimed: ClaimedSlots | null = null
|
|
619
|
+
return (id: string, value: unknown) => {
|
|
620
|
+
if (!claimed) claimed = claimSlots(root, plan)
|
|
621
|
+
claimed.write(id, value)
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* The read-capable twin of `lazySlots`: the same deferred claim, exposed as
|
|
627
|
+
* the full `{ write, read }` door instead of a bare write function.
|
|
628
|
+
*
|
|
629
|
+
* Two entry points rather than one door with a `.read` property because the
|
|
630
|
+
* door is allocated PER ROW: attaching a reader to every writer costs the
|
|
631
|
+
* extra closures on every row in the list, whether or not it ever reads
|
|
632
|
+
* (measured: +84KB/1k rows). Loops that need read-compare-write seeding
|
|
633
|
+
* (`spec/slot-unification.md` §9.3(1)) pay for the reader; every other loop
|
|
634
|
+
* keeps the single-closure writer. There is still exactly ONE claim
|
|
635
|
+
* mechanism underneath — every entry point resolves through `claimRefs`,
|
|
636
|
+
* and the first access of either kind resolves the whole plan while the row
|
|
637
|
+
* is pristine (§2's claim-once rule; reads never open a second resolution
|
|
638
|
+
* path).
|
|
639
|
+
*/
|
|
640
|
+
export function lazyClaimSlots(root: Element, plan: ClaimPlan): ClaimedSlotsRW {
|
|
641
|
+
let refs: Map<string, ClaimedSlotRef> | null = null
|
|
642
|
+
const ensure = (): Map<string, ClaimedSlotRef> => (refs ??= claimRefs(root, plan))
|
|
643
|
+
return {
|
|
644
|
+
write: (id, value) => writeSlot(ensure(), id, value),
|
|
645
|
+
read: (id) => readSlot(ensure(), id),
|
|
646
|
+
}
|
|
647
|
+
}
|