@brust/runtime-dom 2.0.0-alpha.16 → 2.0.0-alpha.17
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/package.json +1 -1
- package/src/core-api.ts +2 -1
- package/src/directives/bind.ts +3 -2
- package/src/features/collection.ts +40 -0
- package/src/features/index.ts +1 -0
- package/src/instance.ts +8 -1
- package/src/mount.ts +16 -1
- package/src/registry.ts +4 -0
package/package.json
CHANGED
package/src/core-api.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
export { BINDERS, bindHost, bindElement, walkChildren } from './directives/index'
|
|
5
5
|
export type { Binder } from './directives/index'
|
|
6
6
|
export { parsed, read, member, mismatch, insideRaw, RAW_SEL } from './directives/common'
|
|
7
|
-
export { hooks, islandHooks } from './instance'
|
|
7
|
+
export { hooks, islandHooks, collectionHooks } from './instance'
|
|
8
|
+
export type { CollectionItem } from './instance'
|
|
8
9
|
export { warnOnce } from './warn'
|
|
9
10
|
export { signal, computed, effect, batch, untracked, isSignal, isComputed } from './signal'
|
|
10
11
|
export type { Signal, Computed, Dispose } from './signal'
|
package/src/directives/bind.ts
CHANGED
|
@@ -15,7 +15,8 @@ BINDERS.push({
|
|
|
15
15
|
const r = read(inst, p, scope, attr); if (!r.ok) return
|
|
16
16
|
const v = r.value
|
|
17
17
|
// setAttribute: `className` is a getter-only SVGAnimatedString on SVG elements (F96a).
|
|
18
|
-
|
|
18
|
+
// F311: undefined/null removes `class` (React does; `className=""` keeps an empty one), and an empty `style` is no attribute (the first paint omits it)
|
|
19
|
+
if (name === 'class') { if (v == null) el.removeAttribute('class'); else el.setAttribute('class', `${v}`); return }
|
|
19
20
|
if (name === 'value' && 'value' in el) { (el as HTMLInputElement).value = v == null ? '' : String(v); return }
|
|
20
21
|
// React passes every attribute of a custom element (a tag with `-`) through as given: `true` is ``, `false` omits it.
|
|
21
22
|
const custom = el.localName.includes('-')
|
|
@@ -28,7 +29,7 @@ BINDERS.push({
|
|
|
28
29
|
// aria-*, data-* and the booleanish strings print `true`/`false`; download/capture print `true` as ``; on any other attribute React omits a boolean.
|
|
29
30
|
if (!custom && (name.startsWith('aria-') || name.startsWith('data-') || STRINGIFY.has(name))) { if (v == null) el.removeAttribute(name); else el.setAttribute(name, String(v)); return }
|
|
30
31
|
if ((custom || OVERLOADED.has(name)) && typeof v === 'boolean') { if (v) el.setAttribute(name, ''); else el.removeAttribute(name); return }
|
|
31
|
-
if (v == null || typeof v === 'boolean') { el.removeAttribute(name); return }
|
|
32
|
+
if (v == null || typeof v === 'boolean' || (name === 'style' && !v)) { el.removeAttribute(name); return }
|
|
32
33
|
// React writes no `src=""` / `href=""` (it would load the page again); an anchor keeps its empty href.
|
|
33
34
|
if (v === '' && (name === 'src' || name === 'href') && el.localName !== 'a' && !custom) { el.removeAttribute(name); return }
|
|
34
35
|
if (URL_ATTRS.has(name) && !safeUrl(String(v))) { warnOnce(`b:${inst.name}:${name}`, `${attr} in ${inst.name}: unsafe URL refused`); return }
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The collection feature (R34.140-R34.141): the items a Radix `createCollection` tracks, as the DOM already has them.
|
|
2
|
+
// A root is an element with `x-collection-root`; an item is an element with `data-radix-collection-item` whose NEAREST root
|
|
3
|
+
// above it is that root (a collection nested inside another keeps its items to itself). Document order is the DOM's own,
|
|
4
|
+
// so there is no registry to sort. An item's data (`id`, `focusable`, ...) is what its host's `x-collection-item` member
|
|
5
|
+
// evaluates to when asked: read at call time, so it is never stale.
|
|
6
|
+
import { BINDERS, collectionHooks, parsed, read, RAW_SEL } from '../core-api'
|
|
7
|
+
import type { CollectionItem } from '../core-api'
|
|
8
|
+
|
|
9
|
+
const ROOT = '[x-collection-root]'
|
|
10
|
+
const ITEM = '[data-radix-collection-item]'
|
|
11
|
+
const DATA = new WeakMap<Element, () => unknown>()
|
|
12
|
+
|
|
13
|
+
collectionHooks.items = (from) => {
|
|
14
|
+
const root = from.closest(ROOT)
|
|
15
|
+
if (!root) return []
|
|
16
|
+
const out: CollectionItem[] = []
|
|
17
|
+
for (const el of Array.from(root.querySelectorAll(ITEM))) {
|
|
18
|
+
// the nearest root ABOVE the item (not the item itself, which may be a root of its own)
|
|
19
|
+
if (el.parentElement?.closest(ROOT) !== root) continue
|
|
20
|
+
// an island or raw markup (x-html / x-raw) between the root and the item is inert: its items are not this collection's
|
|
21
|
+
// (Radix keeps only the nodes registered through its own context, never what a foreign widget or CMS markup holds)
|
|
22
|
+
const raw = el.parentElement?.closest(RAW_SEL)
|
|
23
|
+
if (raw && root.contains(raw)) continue
|
|
24
|
+
const d = DATA.get(el)?.()
|
|
25
|
+
out.push({ ...(d && typeof d === 'object' ? (d as object) : {}), ref: { current: el }, element: el })
|
|
26
|
+
}
|
|
27
|
+
return out
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// markers: a root, and a host that reads `ctx.items()` (so the module loads for it); neither binds anything
|
|
31
|
+
BINDERS.push({ match: (a) => a === 'x-collection-root' || a === 'x-collection-use', bind: () => {} })
|
|
32
|
+
BINDERS.push({
|
|
33
|
+
match: (a) => a === 'x-collection-item',
|
|
34
|
+
bind: ({ inst, el, attr, raw, scope }) => {
|
|
35
|
+
const p = parsed(inst, attr, raw)
|
|
36
|
+
if (!p) return
|
|
37
|
+
DATA.set(el, () => { const r = read(inst, p, scope, attr); return r.ok ? r.value : undefined })
|
|
38
|
+
inst.onCleanup(() => DATA.delete(el))
|
|
39
|
+
},
|
|
40
|
+
})
|
package/src/features/index.ts
CHANGED
package/src/instance.ts
CHANGED
|
@@ -21,6 +21,8 @@ export class Instance {
|
|
|
21
21
|
disposed = false
|
|
22
22
|
/** false until the initial bind pass finishes; the first-paint tripwire only applies before then. */
|
|
23
23
|
booted = false
|
|
24
|
+
/** Mounted after the page booted: nothing of it was in the server markup (`BehaviorCtx.late`). */
|
|
25
|
+
late = false
|
|
24
26
|
/** F277: started from props other than the ones its markup was printed with (a host an `x-if` or a row created after
|
|
25
27
|
* first paint, linked to its parent's current value): its first bind is not a first paint, so no tripwire. */
|
|
26
28
|
reseeded = false
|
|
@@ -68,7 +70,7 @@ export class Instance {
|
|
|
68
70
|
return r as { current: E | null }
|
|
69
71
|
}
|
|
70
72
|
ctx(): BehaviorCtx {
|
|
71
|
-
return { el: this.host, props: this.props, effect: (fn) => this.componentEffect(fn), onCleanup: (fn) => this.onCleanup(fn), ref: (n, init) => this.ref(n, init) }
|
|
73
|
+
return { el: this.host, props: this.props, effect: (fn) => this.componentEffect(fn), onCleanup: (fn) => this.onCleanup(fn), ref: (n, init) => this.ref(n, init), late: this.late, items: () => collectionHooks.items(this.host) }
|
|
72
74
|
}
|
|
73
75
|
init(factory: BehaviorFactory): void {
|
|
74
76
|
const m = factory(this.ctx())
|
|
@@ -98,5 +100,10 @@ export function nearestInstance(el: Element): Instance | null {
|
|
|
98
100
|
/** Late-bound by mount.ts so binders (x-if, x-for) can mount nested hosts synchronously without an import cycle. */
|
|
99
101
|
export const hooks: { mountTree: (root: ParentNode) => void } = { mountTree: () => {} }
|
|
100
102
|
|
|
103
|
+
/** One item of a collection (R34.141): the data its host handed over, the element, and `ref.current` (Radix's item shape). */
|
|
104
|
+
export type CollectionItem = { ref: { current: Element }; element: Element } & Record<string, unknown>
|
|
105
|
+
/** Late-bound by the `collection` feature (features/collection.ts): `ctx.items()` is a no-op list until it loads. */
|
|
106
|
+
export const collectionHooks: { items: (from: Element) => CollectionItem[] } = { items: () => [] }
|
|
107
|
+
|
|
101
108
|
/** Late-bound by the `island` feature (features/island.ts) so a page without React islands never loads the hydration glue. */
|
|
102
109
|
export const islandHooks: { schedule: (root: ParentNode) => void; cancel: (root: Node) => void } = { schedule: () => {}, cancel: () => {} }
|
package/src/mount.ts
CHANGED
|
@@ -7,6 +7,10 @@ import { warnOnce } from './warn'
|
|
|
7
7
|
import { bindPropsFromParent, initialProps } from './props-bind'
|
|
8
8
|
|
|
9
9
|
let observer: MutationObserver | null = null
|
|
10
|
+
/** True once the first `mount()` pass has run: a host that mounts after it is `late`. */
|
|
11
|
+
let pageBooted = false
|
|
12
|
+
/** > 0 while a binder (x-if, x-for) mounts the hosts of a clone it just created: nothing of them was server-rendered. */
|
|
13
|
+
let cloning = 0
|
|
10
14
|
|
|
11
15
|
function readProps(host: HTMLElement): Record<string, unknown> {
|
|
12
16
|
const raw = host.getAttribute('x-props')
|
|
@@ -22,6 +26,9 @@ function sameJson(a: unknown, b: unknown): boolean {
|
|
|
22
26
|
function mountHost(host: HTMLElement): void {
|
|
23
27
|
if (instances.has(host) || !host.isConnected) return
|
|
24
28
|
const name = host.getAttribute('x-data')!
|
|
29
|
+
// decided now, not when a lazy chunk arrives: a server-rendered host stays hydrated however late its code loads
|
|
30
|
+
const late = pageBooted
|
|
31
|
+
const cloned = cloning > 0
|
|
25
32
|
whenBehavior(name, (factory) => {
|
|
26
33
|
if (instances.has(host) || !host.isConnected) return
|
|
27
34
|
const parent = nearestInstance(host)
|
|
@@ -29,6 +36,9 @@ function mountHost(host: HTMLElement): void {
|
|
|
29
36
|
const props = initialProps(host, parent, printed)
|
|
30
37
|
const inst = new Instance(host, name, parent, props)
|
|
31
38
|
inst.reseeded = props !== printed && !sameJson(props, printed)
|
|
39
|
+
inst.late = late
|
|
40
|
+
// a clone made by a binder has no server markup to compare its first paint with (a navigation's content has)
|
|
41
|
+
inst.booted = cloned
|
|
32
42
|
instances.set(host, inst)
|
|
33
43
|
try { inst.init(factory) } catch (e) { console.error(`[brust] init threw: ${name}`, e); inst.dispose(); instances.delete(host); return }
|
|
34
44
|
bindHost(inst, host, {})
|
|
@@ -51,7 +61,11 @@ function mountTree(root: ParentNode): void {
|
|
|
51
61
|
for (const h of hosts) if (!insideRaw(h)) mountHost(h)
|
|
52
62
|
islandHooks.schedule(root)
|
|
53
63
|
}
|
|
54
|
-
hooks.mountTree = (root) =>
|
|
64
|
+
hooks.mountTree = (root) =>
|
|
65
|
+
untracked(() => {
|
|
66
|
+
cloning++
|
|
67
|
+
try { mountTree(root) } finally { cloning-- }
|
|
68
|
+
})
|
|
55
69
|
|
|
56
70
|
function disposeTree(root: Node): void {
|
|
57
71
|
if (!(root instanceof Element)) return
|
|
@@ -65,6 +79,7 @@ export function mount(root: ParentNode = document.body): void {
|
|
|
65
79
|
// Observe first: binders (x-if, x-for) insert nested hosts during the initial pass.
|
|
66
80
|
if (!observer) startObserver(root); else if (root !== observedRoot) observer.observe(root, { childList: true, subtree: true })
|
|
67
81
|
mountTree(root)
|
|
82
|
+
pageBooted = true
|
|
68
83
|
}
|
|
69
84
|
let observedRoot: ParentNode | null = null
|
|
70
85
|
function startObserver(root: ParentNode): void {
|
package/src/registry.ts
CHANGED
|
@@ -6,6 +6,10 @@ export interface BehaviorCtx {
|
|
|
6
6
|
effect: (fn: () => void | (() => void)) => Dispose
|
|
7
7
|
onCleanup: (fn: () => void) => void
|
|
8
8
|
ref: <E extends Element = HTMLElement>(name: string, init?: unknown) => { current: E | null }
|
|
9
|
+
/** True when this instance mounted after the page booted (an x-if clone, an x-for row, a navigation): nothing of it was server-rendered. */
|
|
10
|
+
late: boolean
|
|
11
|
+
/** The collection items under the nearest collection root of this instance, in document order (R34.141). */
|
|
12
|
+
items: () => Array<{ ref: { current: Element }; element: Element } & Record<string, unknown>>
|
|
9
13
|
}
|
|
10
14
|
export type BehaviorInstance = Record<string, unknown>
|
|
11
15
|
export type BehaviorFactory = (ctx: BehaviorCtx) => BehaviorInstance | void
|