@barefootjs/client 0.1.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/build.d.ts +56 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +76 -0
- package/dist/context.d.ts +25 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/csr-adapter.d.ts +26 -0
- package/dist/csr-adapter.d.ts.map +1 -0
- package/dist/forward-props.d.ts +17 -0
- package/dist/forward-props.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +154 -0
- package/dist/reactive.d.ts +150 -0
- package/dist/reactive.d.ts.map +1 -0
- package/dist/reactive.js +215 -0
- package/dist/runtime/apply-rest-attrs.d.ts +16 -0
- package/dist/runtime/apply-rest-attrs.d.ts.map +1 -0
- package/dist/runtime/branch-slot.d.ts +22 -0
- package/dist/runtime/branch-slot.d.ts.map +1 -0
- package/dist/runtime/client-marker.d.ts +21 -0
- package/dist/runtime/client-marker.d.ts.map +1 -0
- package/dist/runtime/component.d.ts +99 -0
- package/dist/runtime/component.d.ts.map +1 -0
- package/dist/runtime/context.d.ts +40 -0
- package/dist/runtime/context.d.ts.map +1 -0
- package/dist/runtime/hydrate.d.ts +100 -0
- package/dist/runtime/hydrate.d.ts.map +1 -0
- package/dist/runtime/hydration-state.d.ts +13 -0
- package/dist/runtime/hydration-state.d.ts.map +1 -0
- package/dist/runtime/index.d.ts +27 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +2093 -0
- package/dist/runtime/insert.d.ts +75 -0
- package/dist/runtime/insert.d.ts.map +1 -0
- package/dist/runtime/list.d.ts +21 -0
- package/dist/runtime/list.d.ts.map +1 -0
- package/dist/runtime/map-array.d.ts +32 -0
- package/dist/runtime/map-array.d.ts.map +1 -0
- package/dist/runtime/portal.d.ts +96 -0
- package/dist/runtime/portal.d.ts.map +1 -0
- package/dist/runtime/qsa-item.d.ts +52 -0
- package/dist/runtime/qsa-item.d.ts.map +1 -0
- package/dist/runtime/query.d.ts +86 -0
- package/dist/runtime/query.d.ts.map +1 -0
- package/dist/runtime/reconcile-elements.d.ts +44 -0
- package/dist/runtime/reconcile-elements.d.ts.map +1 -0
- package/dist/runtime/registry.d.ts +53 -0
- package/dist/runtime/registry.d.ts.map +1 -0
- package/dist/runtime/render.d.ts +35 -0
- package/dist/runtime/render.d.ts.map +1 -0
- package/dist/runtime/scope.d.ts +28 -0
- package/dist/runtime/scope.d.ts.map +1 -0
- package/dist/runtime/slot-resolver.d.ts +36 -0
- package/dist/runtime/slot-resolver.d.ts.map +1 -0
- package/dist/runtime/spread-attrs.d.ts +19 -0
- package/dist/runtime/spread-attrs.d.ts.map +1 -0
- package/dist/runtime/standalone.js +2278 -0
- package/dist/runtime/streaming.d.ts +36 -0
- package/dist/runtime/streaming.d.ts.map +1 -0
- package/dist/runtime/style.d.ts +17 -0
- package/dist/runtime/style.d.ts.map +1 -0
- package/dist/runtime/template.d.ts +39 -0
- package/dist/runtime/template.d.ts.map +1 -0
- package/dist/runtime/types.d.ts +26 -0
- package/dist/runtime/types.d.ts.map +1 -0
- package/dist/shims.d.ts +21 -0
- package/dist/shims.d.ts.map +1 -0
- package/dist/slot.d.ts +14 -0
- package/dist/slot.d.ts.map +1 -0
- package/dist/split-props.d.ts +26 -0
- package/dist/split-props.d.ts.map +1 -0
- package/dist/unwrap.d.ts +16 -0
- package/dist/unwrap.d.ts.map +1 -0
- package/package.json +71 -0
- package/src/build.ts +92 -0
- package/src/context.ts +33 -0
- package/src/csr-adapter.ts +134 -0
- package/src/forward-props.ts +43 -0
- package/src/index.ts +42 -0
- package/src/reactive.ts +411 -0
- package/src/runtime/apply-rest-attrs.ts +109 -0
- package/src/runtime/branch-slot.ts +32 -0
- package/src/runtime/client-marker.ts +46 -0
- package/src/runtime/component.ts +501 -0
- package/src/runtime/context.ts +111 -0
- package/src/runtime/hydrate.ts +311 -0
- package/src/runtime/hydration-state.ts +13 -0
- package/src/runtime/index.ts +96 -0
- package/src/runtime/insert.ts +407 -0
- package/src/runtime/list.ts +47 -0
- package/src/runtime/map-array.ts +381 -0
- package/src/runtime/portal.ts +174 -0
- package/src/runtime/qsa-item.ts +128 -0
- package/src/runtime/query.ts +632 -0
- package/src/runtime/reconcile-elements.ts +391 -0
- package/src/runtime/registry.ts +160 -0
- package/src/runtime/render.ts +105 -0
- package/src/runtime/scope.ts +46 -0
- package/src/runtime/slot-resolver.ts +66 -0
- package/src/runtime/spread-attrs.ts +88 -0
- package/src/runtime/streaming.ts +65 -0
- package/src/runtime/style.ts +27 -0
- package/src/runtime/template.ts +53 -0
- package/src/runtime/types.ts +27 -0
- package/src/shims.ts +54 -0
- package/src/slot.ts +23 -0
- package/src/split-props.ts +86 -0
- package/src/unwrap.ts +18 -0
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BarefootJS - DOM Query Helpers
|
|
3
|
+
*
|
|
4
|
+
* Scope-aware DOM query utilities for compiler-generated ClientJS.
|
|
5
|
+
* These helpers find elements within component scopes, respecting
|
|
6
|
+
* nested scope boundaries and comment-based scopes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { commentScopeRegistry, getCommentScopeBoundary } from './scope'
|
|
10
|
+
import { hydratedScopes } from './hydration-state'
|
|
11
|
+
import { BF_SCOPE, BF_SLOT, BF_PORTAL_OWNER, BF_PARENT_OWNED_PREFIX, BF_SCOPE_COMMENT_PREFIX } from '@barefootjs/shared'
|
|
12
|
+
|
|
13
|
+
/** CSS attribute-value escape with a fallback for environments lacking CSS.escape. */
|
|
14
|
+
export const cssEscape: (s: string) => string =
|
|
15
|
+
typeof CSS !== 'undefined' && (CSS as { escape?: (s: string) => string }).escape
|
|
16
|
+
? (s) => CSS.escape(s)
|
|
17
|
+
: (s) => s.replace(/"/g, '\\"')
|
|
18
|
+
|
|
19
|
+
// --- helpers ---
|
|
20
|
+
|
|
21
|
+
/** Read bf-s attribute. Returns null when absent.
|
|
22
|
+
* Per #1249, bf-s is the bare addressable id — no stripping needed. */
|
|
23
|
+
function getScopeId(el: Element | null): string | null {
|
|
24
|
+
return el?.getAttribute(BF_SCOPE) ?? null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Comments already processed by findScopeByComment. */
|
|
28
|
+
const initializedComments = new WeakSet<Comment>()
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse scope ID from a comment value like "bf-scope:Name_xxx|propsJson".
|
|
32
|
+
* Strips the prefix and props JSON suffix.
|
|
33
|
+
*/
|
|
34
|
+
function parseCommentScopeId(value: string, prefix: string): string | null {
|
|
35
|
+
if (!value.startsWith(prefix)) return null
|
|
36
|
+
let id = value.slice(prefix.length)
|
|
37
|
+
const pipeIdx = id.indexOf('|')
|
|
38
|
+
if (pipeIdx >= 0) id = id.slice(0, pipeIdx)
|
|
39
|
+
return id
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Find the first Element sibling after a node. */
|
|
43
|
+
function nextElementSibling(node: Node): Element | null {
|
|
44
|
+
let sibling: Node | null = node.nextSibling
|
|
45
|
+
while (sibling) {
|
|
46
|
+
if (sibling.nodeType === Node.ELEMENT_NODE) return sibling as Element
|
|
47
|
+
sibling = sibling.nextSibling
|
|
48
|
+
}
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- findScope ---
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Find component scope element for hydration.
|
|
56
|
+
* Supports unique instance IDs (e.g., ComponentName_abc123).
|
|
57
|
+
*
|
|
58
|
+
* @param name - Component name prefix to search for
|
|
59
|
+
* @param idx - Instance index (for multiple instances)
|
|
60
|
+
* @param parent - Parent element or scope element to search within
|
|
61
|
+
* @param comment - When true, fall back to comment-based scope search (fragment roots only)
|
|
62
|
+
* @returns The scope element or null if not found
|
|
63
|
+
*/
|
|
64
|
+
export function findScope(
|
|
65
|
+
name: string,
|
|
66
|
+
idx: number,
|
|
67
|
+
parent: Element | Document | null,
|
|
68
|
+
comment?: boolean
|
|
69
|
+
): Element | null {
|
|
70
|
+
const parentEl = parent as HTMLElement
|
|
71
|
+
|
|
72
|
+
// Check comment scope registry first.
|
|
73
|
+
// For fragment root components, the scope is identified by a comment marker,
|
|
74
|
+
// not by the bf-s attribute on the proxy element.
|
|
75
|
+
// This must be checked before the bf-s check to prevent the proxy element
|
|
76
|
+
// from being incorrectly accepted and marked as hydrated,
|
|
77
|
+
// which would block child component initialization via initChild.
|
|
78
|
+
if (parentEl) {
|
|
79
|
+
const commentInfo = commentScopeRegistry.get(parentEl)
|
|
80
|
+
if (commentInfo && commentInfo.scopeId.startsWith(`${name}_`)) {
|
|
81
|
+
return parentEl
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Check if parent is the scope element itself.
|
|
86
|
+
// Two cases:
|
|
87
|
+
// 1. Scope ID starts with component name (e.g., "AddTodoForm_abc123")
|
|
88
|
+
// 2. Scope ID is from parent component via initChild (e.g., "TodoApp_xyz_s5")
|
|
89
|
+
// — initChild already found the correct element, so trust it
|
|
90
|
+
const scopeId = getScopeId(parentEl)
|
|
91
|
+
if (scopeId) {
|
|
92
|
+
if (
|
|
93
|
+
scopeId.startsWith(`${name}_`) ||
|
|
94
|
+
(/_s\d/.test(scopeId) && parent !== document)
|
|
95
|
+
) {
|
|
96
|
+
hydratedScopes.add(parentEl)
|
|
97
|
+
return parent as Element
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Search for scope elements with prefix matching
|
|
102
|
+
const searchRoot = parent || document
|
|
103
|
+
const allScopes = Array.from(
|
|
104
|
+
searchRoot.querySelectorAll(`[${BF_SCOPE}^="${name}_"]`)
|
|
105
|
+
)
|
|
106
|
+
const uninitializedScopes = allScopes.filter(
|
|
107
|
+
s => !hydratedScopes.has(s)
|
|
108
|
+
)
|
|
109
|
+
const scope = uninitializedScopes[idx] || null
|
|
110
|
+
|
|
111
|
+
if (scope) {
|
|
112
|
+
hydratedScopes.add(scope)
|
|
113
|
+
return scope
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Only fall back to comment-based search when explicitly flagged (fragment roots)
|
|
117
|
+
if (comment) {
|
|
118
|
+
return findScopeByComment(name, idx, searchRoot)
|
|
119
|
+
}
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Find a scope element by walking comment nodes for bf-scope: markers.
|
|
125
|
+
* Returns the first element sibling after the comment (or parent element).
|
|
126
|
+
*/
|
|
127
|
+
function findScopeByComment(
|
|
128
|
+
name: string,
|
|
129
|
+
idx: number,
|
|
130
|
+
searchRoot: Element | Document
|
|
131
|
+
): Element | null {
|
|
132
|
+
const prefix = BF_SCOPE_COMMENT_PREFIX
|
|
133
|
+
const walker = document.createTreeWalker(
|
|
134
|
+
searchRoot,
|
|
135
|
+
NodeFilter.SHOW_COMMENT
|
|
136
|
+
)
|
|
137
|
+
let matchIdx = 0
|
|
138
|
+
|
|
139
|
+
while (walker.nextNode()) {
|
|
140
|
+
const comment = walker.currentNode as Comment
|
|
141
|
+
const value = comment.nodeValue
|
|
142
|
+
if (!value?.startsWith(prefix)) continue
|
|
143
|
+
|
|
144
|
+
const scopeId = parseCommentScopeId(value, prefix)
|
|
145
|
+
if (!scopeId?.startsWith(`${name}_`)) continue
|
|
146
|
+
if (initializedComments.has(comment)) continue
|
|
147
|
+
|
|
148
|
+
if (matchIdx === idx) {
|
|
149
|
+
initializedComments.add(comment)
|
|
150
|
+
|
|
151
|
+
// Proxy element: first element sibling after the comment, or parent
|
|
152
|
+
const proxyEl = nextElementSibling(comment) ?? comment.parentElement
|
|
153
|
+
if (proxyEl) {
|
|
154
|
+
commentScopeRegistry.set(proxyEl, { commentNode: comment, scopeId })
|
|
155
|
+
}
|
|
156
|
+
return proxyEl
|
|
157
|
+
}
|
|
158
|
+
matchIdx++
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- candidate enumeration ---
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Lazily enumerate DOM elements matching `selector` within a scope's DOM range.
|
|
168
|
+
* Covers comment-range siblings (for fragment roots), regular descendants,
|
|
169
|
+
* and fragment siblings. Portals are searched separately via findInPortals.
|
|
170
|
+
*
|
|
171
|
+
* This generator separates "where to search" from "how to filter",
|
|
172
|
+
* allowing find() and findDirectChild() to share enumeration logic
|
|
173
|
+
* while applying different acceptance criteria.
|
|
174
|
+
*/
|
|
175
|
+
function* candidatesInScope(scope: Element, selector: string): Generator<Element> {
|
|
176
|
+
const commentInfo = commentScopeRegistry.get(scope)
|
|
177
|
+
|
|
178
|
+
if (commentInfo) {
|
|
179
|
+
// Comment-based scope: walk siblings in the comment range
|
|
180
|
+
const boundary = getCommentScopeBoundary(commentInfo.commentNode)
|
|
181
|
+
let node: Node | null = commentInfo.commentNode.nextSibling
|
|
182
|
+
while (node && node !== boundary) {
|
|
183
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
184
|
+
const el = node as Element
|
|
185
|
+
if (el.matches?.(selector)) yield el
|
|
186
|
+
yield* el.querySelectorAll(selector)
|
|
187
|
+
}
|
|
188
|
+
node = node.nextSibling
|
|
189
|
+
}
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Regular scope: descendants then fragment siblings
|
|
194
|
+
yield* scope.querySelectorAll(selector)
|
|
195
|
+
|
|
196
|
+
const scopeId = scope.getAttribute(BF_SCOPE)
|
|
197
|
+
if (!scopeId) return
|
|
198
|
+
const parent = scope.parentElement
|
|
199
|
+
if (!parent) return
|
|
200
|
+
const siblings = parent.querySelectorAll(`[${BF_SCOPE}="${scopeId}"]`)
|
|
201
|
+
for (const sibling of siblings) {
|
|
202
|
+
if (sibling === scope) continue
|
|
203
|
+
if (sibling.matches?.(selector)) yield sibling
|
|
204
|
+
yield* sibling.querySelectorAll(selector)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// --- scope membership ---
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Check if a slot element belongs directly to a scope (not in a nested scope).
|
|
212
|
+
* Returns true only if the element's nearest scope is exactly the given scope.
|
|
213
|
+
* Elements inside nested child scopes (which have their own bf-s) return false.
|
|
214
|
+
*
|
|
215
|
+
* Only used for slot element searches (bf="sN" selectors) in regular scopes.
|
|
216
|
+
* Child scope searches ($c) use findChildScope() which bypasses this check.
|
|
217
|
+
* Comment scope filtering is handled inline in find().
|
|
218
|
+
*/
|
|
219
|
+
function belongsToScope(element: Element, scope: Element): boolean {
|
|
220
|
+
// Elements with their own scope are component roots — never a slot match
|
|
221
|
+
if (element.getAttribute(BF_SCOPE)) return false
|
|
222
|
+
|
|
223
|
+
// Check if nearest scope matches
|
|
224
|
+
const nearestScope = element.closest(`[${BF_SCOPE}]`)
|
|
225
|
+
return nearestScope === scope
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Check if an element is within the range of a comment-based scope.
|
|
230
|
+
* The range is from the comment node to the next bf-scope: comment (or end of parent).
|
|
231
|
+
*/
|
|
232
|
+
function isInCommentScopeRange(element: Element, commentNode: Comment): boolean {
|
|
233
|
+
const boundary = getCommentScopeBoundary(commentNode)
|
|
234
|
+
let node: Node | null = commentNode.nextSibling
|
|
235
|
+
while (node && node !== boundary) {
|
|
236
|
+
if (node === element || (node.nodeType === Node.ELEMENT_NODE && (node as Element).contains(element))) {
|
|
237
|
+
return true
|
|
238
|
+
}
|
|
239
|
+
node = node.nextSibling
|
|
240
|
+
}
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --- find ---
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Find an element within a scope.
|
|
248
|
+
* Enumerates candidates via candidatesInScope generator, then applies
|
|
249
|
+
* context-specific filtering (scope-aware, ignoreScope, or comment-scope).
|
|
250
|
+
* Portals are searched as a final fallback via findInPortals.
|
|
251
|
+
*
|
|
252
|
+
* @param scope - The scope element to search within
|
|
253
|
+
* @param selector - CSS selector to match
|
|
254
|
+
* @param ignoreScope - Skip scope boundary checks (for parent-owned ^-prefixed slots)
|
|
255
|
+
* @returns The matching element or null
|
|
256
|
+
*/
|
|
257
|
+
export function find(
|
|
258
|
+
scope: Element | null,
|
|
259
|
+
selector: string,
|
|
260
|
+
ignoreScope?: boolean
|
|
261
|
+
): Element | null {
|
|
262
|
+
if (!scope) return null
|
|
263
|
+
|
|
264
|
+
const commentInfo = commentScopeRegistry.get(scope)
|
|
265
|
+
|
|
266
|
+
// Self-match: check scope element first (for non-comment scopes)
|
|
267
|
+
if (!commentInfo && scope.matches?.(selector)) return scope
|
|
268
|
+
|
|
269
|
+
// Enumerate candidates and apply filter
|
|
270
|
+
for (const candidate of candidatesInScope(scope, selector)) {
|
|
271
|
+
if (ignoreScope) return candidate
|
|
272
|
+
if (commentInfo) {
|
|
273
|
+
// Comment scope: top-level siblings in the comment range are always
|
|
274
|
+
// accepted (even if they have bf-s, like proxy elements). Descendants
|
|
275
|
+
// are accepted only if not inside a nested bf-s scope.
|
|
276
|
+
if (candidate.parentElement === commentInfo.commentNode.parentElement) return candidate
|
|
277
|
+
if (!candidate.closest(`[${BF_SCOPE}]`)) return candidate
|
|
278
|
+
} else {
|
|
279
|
+
if (belongsToScope(candidate, scope)) return candidate
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Portal search (outside scope's DOM subtree)
|
|
284
|
+
const scopeId = commentInfo?.scopeId ?? getScopeId(scope)
|
|
285
|
+
if (scopeId) return findInPortals(scopeId, selector)
|
|
286
|
+
|
|
287
|
+
return null
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Search in portals owned by a scope.
|
|
292
|
+
*/
|
|
293
|
+
function findInPortals(scopeId: string, selector: string): Element | null {
|
|
294
|
+
const portals = document.querySelectorAll(`[${BF_PORTAL_OWNER}="${scopeId}"]`)
|
|
295
|
+
for (const portal of portals) {
|
|
296
|
+
if (portal.matches?.(selector)) return portal
|
|
297
|
+
// Search within portal, excluding elements inside nested component scopes
|
|
298
|
+
const matches = portal.querySelectorAll(selector)
|
|
299
|
+
for (const match of matches) {
|
|
300
|
+
const nearestScope = match.closest(`[${BF_SCOPE}]`)
|
|
301
|
+
if (!nearestScope) {
|
|
302
|
+
return match
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return null
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// --- shorthand finders ---
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Find an element matching a selector, checking the element itself first,
|
|
313
|
+
* then its descendants. Unlike querySelector() which only searches descendants,
|
|
314
|
+
* this also matches the root element.
|
|
315
|
+
*
|
|
316
|
+
* Used by compiler-generated code for event binding and attribute updates
|
|
317
|
+
* on loop items where the target may be the loop item's root element itself.
|
|
318
|
+
*/
|
|
319
|
+
export function qsa(el: Element | null, selector: string): Element | null {
|
|
320
|
+
if (!el) return null
|
|
321
|
+
|
|
322
|
+
// Comma-separated selectors are tried in priority order (left-to-right)
|
|
323
|
+
// rather than relying on `querySelector`'s document-order semantics —
|
|
324
|
+
// the compiler-emitted slot-child selector
|
|
325
|
+
// `[bf-h="X"][bf-m="sN"], [bf-s$="_sN"]` resolves to the most specific
|
|
326
|
+
// match (#1249).
|
|
327
|
+
if (selector.includes(',')) {
|
|
328
|
+
for (const clause of splitTopLevelCommas(selector)) {
|
|
329
|
+
const c = clause.trim()
|
|
330
|
+
if (!c) continue
|
|
331
|
+
const hit = qsa(el, c)
|
|
332
|
+
if (hit) return hit
|
|
333
|
+
}
|
|
334
|
+
return null
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// #1220 cross-binding skip: bare slot-suffix lookups defer to
|
|
338
|
+
// `qsaChildScope` so candidates whose bf-s already carries a deeper
|
|
339
|
+
// `_sN_sN` path (synthesized child's nested scope) are skipped.
|
|
340
|
+
if (SLOT_SUFFIX_SELECTOR.test(selector)) {
|
|
341
|
+
return qsaChildScope(el, selector)
|
|
342
|
+
}
|
|
343
|
+
if (el.matches(selector)) return el
|
|
344
|
+
return el.querySelector(selector)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Split a CSS selector list on top-level commas, ignoring commas inside
|
|
348
|
+
* `[…]` attribute selectors or `(…)` pseudo-class arguments. */
|
|
349
|
+
function splitTopLevelCommas(selector: string): string[] {
|
|
350
|
+
const out: string[] = []
|
|
351
|
+
let depth = 0
|
|
352
|
+
let start = 0
|
|
353
|
+
for (let i = 0; i < selector.length; i++) {
|
|
354
|
+
const ch = selector.charCodeAt(i)
|
|
355
|
+
if (ch === 0x5b /* [ */ || ch === 0x28 /* ( */) depth++
|
|
356
|
+
else if (ch === 0x5d /* ] */ || ch === 0x29 /* ) */) depth--
|
|
357
|
+
else if (ch === 0x2c /* , */ && depth === 0) {
|
|
358
|
+
out.push(selector.slice(start, i))
|
|
359
|
+
start = i + 1
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
out.push(selector.slice(start))
|
|
363
|
+
return out
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Selector form `[bf-s$="_sN"]` — emitted by the compiler for bare child-
|
|
368
|
+
* component scope lookups. Used to gate the #1220 nested-slot skip so the
|
|
369
|
+
* filter only fires for these compiled bf-s suffix lookups (not for
|
|
370
|
+
* unrelated selectors that happen to match).
|
|
371
|
+
*/
|
|
372
|
+
const SLOT_SUFFIX_SELECTOR = /^\[bf-s\$="_s\d+"\]$/
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Recognises bf-s values whose final segment is a nested-slot path
|
|
376
|
+
* (`…_sM_sN`). These show up when a synthesized component (e.g.
|
|
377
|
+
* `BFInlineJsxCallback`) renders descendants whose own internal scope
|
|
378
|
+
* happens to end in `_sN`, coincidentally matching a sibling slot's loose
|
|
379
|
+
* suffix selector. The slot-suffix lookup helpers skip these so the wrong
|
|
380
|
+
* `initChild` never fires (#1220).
|
|
381
|
+
*
|
|
382
|
+
* Why this is a safe filter: legitimate child shapes anchored on a
|
|
383
|
+
* stateful intermediate parent (e.g. `Card_<rand>_<slot>`) have exactly
|
|
384
|
+
* one trailing `_sN`. The two-segment shape only arises when a
|
|
385
|
+
* stateless-only stack of intermediate components nests further, which
|
|
386
|
+
* never happens by design — `_parentScopeId` is set only by `insert()`
|
|
387
|
+
* (whose owning component carries client interactivity and therefore a
|
|
388
|
+
* fresh `${name}_<rand>` scope) and by `render()` (top-level entry).
|
|
389
|
+
*/
|
|
390
|
+
const NESTED_SLOT_SUFFIX = /_s\d+_s\d+$/
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* `querySelector` variant that skips #1220 cross-binding candidates: any
|
|
394
|
+
* descendant whose bf-s already carries a deeper nested-slot path is
|
|
395
|
+
* ignored. Falls back to the standalone match-or-descendant semantics
|
|
396
|
+
* (mirrors `qsa`'s self-match) when no candidate qualifies.
|
|
397
|
+
*
|
|
398
|
+
* Compiler-generated static-array child-init code calls this in place of
|
|
399
|
+
* a bare `containerVar.querySelector(...)` so the filter runs even on
|
|
400
|
+
* paths that don't pass through `qsa` (#1220 review feedback).
|
|
401
|
+
*/
|
|
402
|
+
export function qsaChildScope(scope: Element, selector: string): Element | null {
|
|
403
|
+
if (scope.matches(selector)) {
|
|
404
|
+
const bfs = scope.getAttribute(BF_SCOPE) || ''
|
|
405
|
+
if (!NESTED_SLOT_SUFFIX.test(bfs)) return scope
|
|
406
|
+
}
|
|
407
|
+
for (const candidate of scope.querySelectorAll(selector)) {
|
|
408
|
+
const bfs = candidate.getAttribute(BF_SCOPE) || ''
|
|
409
|
+
if (!NESTED_SLOT_SUFFIX.test(bfs)) return candidate
|
|
410
|
+
}
|
|
411
|
+
return null
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* `querySelectorAll` variant with the same #1220 filter. Returns the
|
|
416
|
+
* matching descendants in document order, with nested-slot collisions
|
|
417
|
+
* dropped so the caller's `forEach((el, idx) => …)` pairs scope
|
|
418
|
+
* elements with array items by position correctly.
|
|
419
|
+
*/
|
|
420
|
+
export function qsaChildScopes(scope: Element, selector: string): Element[] {
|
|
421
|
+
const out: Element[] = []
|
|
422
|
+
for (const candidate of scope.querySelectorAll(selector)) {
|
|
423
|
+
const bfs = candidate.getAttribute(BF_SCOPE) || ''
|
|
424
|
+
if (!NESTED_SLOT_SUFFIX.test(bfs)) out.push(candidate)
|
|
425
|
+
}
|
|
426
|
+
return out
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Find elements within a scope by slot IDs.
|
|
431
|
+
* Used by compiler-generated code for regular slot element references.
|
|
432
|
+
* Always returns an array — callers use destructuring.
|
|
433
|
+
*
|
|
434
|
+
* For parent-owned slots (^-prefixed IDs like '^s3'), searches all descendants
|
|
435
|
+
* ignoring scope boundaries. This handles elements passed as children to child
|
|
436
|
+
* components — they are owned by the parent but rendered inside the child's scope.
|
|
437
|
+
*/
|
|
438
|
+
export function $(scope: Element | null, ...ids: string[]): (Element | null)[] {
|
|
439
|
+
return ids.map(id => {
|
|
440
|
+
// Parent-owned slots (^-prefixed) search all descendants ignoring scope boundaries,
|
|
441
|
+
// because the ^ prefix guarantees the element is owned by the calling scope.
|
|
442
|
+
const ignoreScope = id.startsWith(BF_PARENT_OWNED_PREFIX)
|
|
443
|
+
return find(scope, `[${BF_SLOT}="${id}"]`, ignoreScope || undefined)
|
|
444
|
+
})
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Find child component scope elements by slot ID or component name.
|
|
449
|
+
* - Slot ID (e.g., 's1'): uses suffix match [bf-s$="_s1"]
|
|
450
|
+
* - Component name (e.g., 'Counter'): uses prefix match [bf-s^="Counter_"]
|
|
451
|
+
* Always returns an array — callers use destructuring.
|
|
452
|
+
*/
|
|
453
|
+
export function $c(scope: Element | null, ...ids: string[]): (Element | null)[] {
|
|
454
|
+
return ids.map(id => $cSingle(scope, id))
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Resolve a single child component scope by slot ID or component name.
|
|
459
|
+
*
|
|
460
|
+
* Two ID formats:
|
|
461
|
+
* - Slot ID ('s0', 's1', ...): Uses parent scope ID for precise suffix match.
|
|
462
|
+
* e.g., [bf-s$="Parent_abc_s3"] — matches "Parent_abc_s3" but NOT "Parent_abc_s4_s3".
|
|
463
|
+
* - Component name ('Counter'): Prefix match [bf-s^="Counter_"]. Unambiguous.
|
|
464
|
+
*
|
|
465
|
+
* Uses candidatesInScope directly (not find()) because child scope searches
|
|
466
|
+
* don't need slot-level scope boundary checks — the CSS selector itself is
|
|
467
|
+
* precise enough to identify the correct element.
|
|
468
|
+
*
|
|
469
|
+
* Dual-scope: A proxy element can host both a comment scope (fragment-root parent)
|
|
470
|
+
* and a bf-s scope (proxied child). getDualScopeIds() returns both IDs so the
|
|
471
|
+
* search tries each parent identity.
|
|
472
|
+
*/
|
|
473
|
+
function $cSingle(scope: Element | null, id: string): Element | null {
|
|
474
|
+
if (!scope) return null
|
|
475
|
+
// Strip ^ prefix defensively — component slot IDs should never have it,
|
|
476
|
+
// but guard against compiler edge cases to avoid silent initialization failures.
|
|
477
|
+
const cleanId = id.startsWith(BF_PARENT_OWNED_PREFIX) ? id.slice(1) : id
|
|
478
|
+
|
|
479
|
+
// --- Component name path (unambiguous) ---
|
|
480
|
+
if (!/^s\d/.test(cleanId)) {
|
|
481
|
+
return findChildScope(scope, `[${BF_SCOPE}^="${cleanId}_"]`)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// --- Slot ID path: precise suffix match using parent scope ID ---
|
|
485
|
+
const parentScopeIds = getDualScopeIds(scope)
|
|
486
|
+
|
|
487
|
+
if (parentScopeIds.length > 0) {
|
|
488
|
+
for (const parentId of parentScopeIds) {
|
|
489
|
+
const result = findChildScope(scope, `[${BF_SCOPE}$="${parentId}_${cleanId}"]`)
|
|
490
|
+
if (result) return result
|
|
491
|
+
}
|
|
492
|
+
// Precise match found nothing. Check if scope itself matches the short suffix
|
|
493
|
+
// (fragment root / inlined component where scope IS the child).
|
|
494
|
+
if (scope.matches?.(`[${BF_SCOPE}$="_${cleanId}"]`)) return scope
|
|
495
|
+
return null
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Fallback: no parent scope ID available — use short suffix match (best-effort)
|
|
499
|
+
return findChildScope(scope, `[${BF_SCOPE}$="_${cleanId}"]`)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Find a child scope element using candidatesInScope + portal search.
|
|
504
|
+
* Unlike find(), this accepts any matching candidate without slot-level
|
|
505
|
+
* scope boundary checks — the selector is assumed to be precise enough.
|
|
506
|
+
*/
|
|
507
|
+
function findChildScope(scope: Element, selector: string): Element | null {
|
|
508
|
+
// Check scope itself (handles self-match for fragment root / inlined components)
|
|
509
|
+
if (scope.matches?.(selector)) return scope
|
|
510
|
+
|
|
511
|
+
for (const candidate of candidatesInScope(scope, selector)) {
|
|
512
|
+
return candidate
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Portal search
|
|
516
|
+
const commentInfo = commentScopeRegistry.get(scope)
|
|
517
|
+
const scopeId = commentInfo?.scopeId ?? getScopeId(scope)
|
|
518
|
+
if (scopeId) return findInPortals(scopeId, selector)
|
|
519
|
+
|
|
520
|
+
return null
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Get all possible parent scope IDs for child resolution.
|
|
525
|
+
*
|
|
526
|
+
* Dual-registered elements (comment scope proxy + bf-s attribute) host children
|
|
527
|
+
* from two components: the fragment-root component (comment scope) and the
|
|
528
|
+
* proxied child component (bf-s). Both IDs are returned so $cSingle can try each.
|
|
529
|
+
*
|
|
530
|
+
* Returns deduplicated array of scope IDs, comment scope first (most common case).
|
|
531
|
+
*/
|
|
532
|
+
function getDualScopeIds(scope: Element | null): string[] {
|
|
533
|
+
if (!scope) return []
|
|
534
|
+
|
|
535
|
+
const bfScopeId = getScopeId(scope)
|
|
536
|
+
|
|
537
|
+
const commentInfo = commentScopeRegistry.get(scope)
|
|
538
|
+
const commentScopeId = commentInfo?.scopeId ?? null
|
|
539
|
+
|
|
540
|
+
if (commentScopeId && bfScopeId && commentScopeId !== bfScopeId) {
|
|
541
|
+
return [commentScopeId, bfScopeId]
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const id = commentScopeId ?? bfScopeId
|
|
545
|
+
return id ? [id] : []
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// --- $t: text node finder via comment markers ---
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Find Text nodes for reactive text expressions marked by comment nodes.
|
|
552
|
+
* Expects marker format: <!--bf:sX-->text<!--/-->
|
|
553
|
+
* Always returns an array — callers use destructuring.
|
|
554
|
+
*
|
|
555
|
+
* Uses a single TreeWalker pass to find all markers at once,
|
|
556
|
+
* with early exit when all are found.
|
|
557
|
+
*/
|
|
558
|
+
export function $t(scope: Element | null, ...ids: string[]): (Text | null)[] {
|
|
559
|
+
const results: (Text | null)[] = new Array(ids.length).fill(null)
|
|
560
|
+
if (!scope) return results
|
|
561
|
+
|
|
562
|
+
const commentInfo = commentScopeRegistry.get(scope)
|
|
563
|
+
const searchRoot: Node = commentInfo ? (commentInfo.commentNode.parentNode ?? scope) : scope
|
|
564
|
+
|
|
565
|
+
// When the element is not a component scope (e.g. a loop item element),
|
|
566
|
+
// skip ownership checks — all markers inside it belong to this element.
|
|
567
|
+
const isComponentScope = scope.hasAttribute(BF_SCOPE) || commentInfo != null
|
|
568
|
+
|
|
569
|
+
// Build marker → index map for O(1) lookup during walk
|
|
570
|
+
const markerMap = new Map<string, { index: number; isParentOwned: boolean }>()
|
|
571
|
+
for (let i = 0; i < ids.length; i++) {
|
|
572
|
+
markerMap.set(`bf:${ids[i]}`, {
|
|
573
|
+
index: i,
|
|
574
|
+
isParentOwned: ids[i].startsWith(BF_PARENT_OWNED_PREFIX),
|
|
575
|
+
})
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
let remaining = ids.length
|
|
579
|
+
const walker = document.createTreeWalker(searchRoot, NodeFilter.SHOW_COMMENT)
|
|
580
|
+
while (walker.nextNode() && remaining > 0) {
|
|
581
|
+
const comment = walker.currentNode as Comment
|
|
582
|
+
const entry = markerMap.get(comment.nodeValue ?? '')
|
|
583
|
+
if (!entry || results[entry.index] !== null) continue
|
|
584
|
+
|
|
585
|
+
if (isComponentScope && !entry.isParentOwned && !commentBelongsToScope(comment, scope, commentInfo)) {
|
|
586
|
+
continue
|
|
587
|
+
}
|
|
588
|
+
results[entry.index] = textNodeAfterComment(comment)
|
|
589
|
+
remaining--
|
|
590
|
+
}
|
|
591
|
+
return results
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Get or create the Text node immediately after a comment marker.
|
|
596
|
+
*/
|
|
597
|
+
function textNodeAfterComment(comment: Comment): Text {
|
|
598
|
+
const next = comment.nextSibling
|
|
599
|
+
if (next?.nodeType === Node.TEXT_NODE) {
|
|
600
|
+
return next as Text
|
|
601
|
+
}
|
|
602
|
+
// No text node exists (empty initial value) — create one
|
|
603
|
+
const textNode = document.createTextNode('')
|
|
604
|
+
comment.parentNode?.insertBefore(textNode, comment.nextSibling)
|
|
605
|
+
return textNode
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Check if a comment node belongs to the given scope (not inside a nested child scope).
|
|
610
|
+
*/
|
|
611
|
+
function commentBelongsToScope(
|
|
612
|
+
comment: Comment,
|
|
613
|
+
scope: Element,
|
|
614
|
+
commentInfo: { commentNode: Comment; scopeId: string } | undefined
|
|
615
|
+
): boolean {
|
|
616
|
+
// Walk up from the comment to find the nearest scope element
|
|
617
|
+
const parent = comment.parentElement
|
|
618
|
+
if (!parent) return false
|
|
619
|
+
|
|
620
|
+
// If the comment's parent element has a bf-s attribute that is NOT our scope,
|
|
621
|
+
// then the comment is inside a child component's scope
|
|
622
|
+
const parentScope = parent.closest(`[${BF_SCOPE}]`)
|
|
623
|
+
if (parentScope === scope) return true
|
|
624
|
+
|
|
625
|
+
// For comment-based scopes, the scope element is virtual
|
|
626
|
+
if (commentInfo) {
|
|
627
|
+
return isInCommentScopeRange(parent, commentInfo.commentNode)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// If the nearest scope is inside our scope, the comment is in a nested scope
|
|
631
|
+
return false
|
|
632
|
+
}
|