@brust/runtime-dom 2.0.0-alpha.15 → 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 +9 -6
- package/src/directives/common.ts +1 -0
- package/src/features/collection.ts +40 -0
- package/src/features/index.ts +1 -0
- package/src/instance.ts +11 -1
- package/src/mount.ts +25 -3
- package/src/props-bind.ts +10 -0
- 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,20 +15,23 @@ 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.
|
|
22
|
+
const custom = el.localName.includes('-')
|
|
23
|
+
if (!custom && BOOLEAN.has(name)) {
|
|
21
24
|
const on = Boolean(v)
|
|
22
25
|
if (name in el) (el as unknown as Record<string, unknown>)[name] = on
|
|
23
26
|
if (on) el.setAttribute(name, '') ; else el.removeAttribute(name)
|
|
24
27
|
return
|
|
25
28
|
}
|
|
26
29
|
// aria-*, data-* and the booleanish strings print `true`/`false`; download/capture print `true` as ``; on any other attribute React omits a boolean.
|
|
27
|
-
if (name.startsWith('aria-') || name.startsWith('data-') || STRINGIFY.has(name)) { if (v == null) el.removeAttribute(name); else el.setAttribute(name, String(v)); return }
|
|
28
|
-
if (OVERLOADED.has(name) && typeof v === 'boolean') { if (v) el.setAttribute(name, ''); else el.removeAttribute(name); return }
|
|
29
|
-
if (v == null || typeof v === 'boolean') { el.removeAttribute(name); return }
|
|
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 }
|
|
31
|
+
if ((custom || OVERLOADED.has(name)) && typeof v === 'boolean') { if (v) el.setAttribute(name, ''); else el.removeAttribute(name); return }
|
|
32
|
+
if (v == null || typeof v === 'boolean' || (name === 'style' && !v)) { el.removeAttribute(name); return }
|
|
30
33
|
// React writes no `src=""` / `href=""` (it would load the page again); an anchor keeps its empty href.
|
|
31
|
-
if (v === '' && (name === 'src' || name === 'href') && el.localName !== 'a') { el.removeAttribute(name); return }
|
|
34
|
+
if (v === '' && (name === 'src' || name === 'href') && el.localName !== 'a' && !custom) { el.removeAttribute(name); return }
|
|
32
35
|
if (URL_ATTRS.has(name) && !safeUrl(String(v))) { warnOnce(`b:${inst.name}:${name}`, `${attr} in ${inst.name}: unsafe URL refused`); return }
|
|
33
36
|
el.setAttribute(name, String(v))
|
|
34
37
|
})
|
package/src/directives/common.ts
CHANGED
|
@@ -23,5 +23,6 @@ export function member(inst: Instance, p: ParsedValue, attr: string): unknown {
|
|
|
23
23
|
}
|
|
24
24
|
export function mismatch(inst: Instance, attr: string, _el: Element, server: unknown, client: unknown): void {
|
|
25
25
|
if (inst.booted) return // element created after mount (new x-for row / x-if clone): nothing was server-rendered
|
|
26
|
+
if (inst.reseeded) return // F277: a host created later, started from its parent's current props, not the printed ones
|
|
26
27
|
warnOnce(`x:${inst.name}:${attr}`, `first-paint mismatch ${attr} in ${inst.name}: ${JSON.stringify(server)} vs ${JSON.stringify(client)}`)
|
|
27
28
|
}
|
|
@@ -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,11 @@ 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
|
|
26
|
+
/** F277: started from props other than the ones its markup was printed with (a host an `x-if` or a row created after
|
|
27
|
+
* first paint, linked to its parent's current value): its first bind is not a first paint, so no tripwire. */
|
|
28
|
+
reseeded = false
|
|
24
29
|
/** Effects the component declared while its factory ran: they start once the instance is bound (x-ref, x-on), like React's commit (F97). */
|
|
25
30
|
private pending: Array<[() => void | (() => void), Dispose?]> | null = []
|
|
26
31
|
|
|
@@ -65,7 +70,7 @@ export class Instance {
|
|
|
65
70
|
return r as { current: E | null }
|
|
66
71
|
}
|
|
67
72
|
ctx(): BehaviorCtx {
|
|
68
|
-
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) }
|
|
69
74
|
}
|
|
70
75
|
init(factory: BehaviorFactory): void {
|
|
71
76
|
const m = factory(this.ctx())
|
|
@@ -95,5 +100,10 @@ export function nearestInstance(el: Element): Instance | null {
|
|
|
95
100
|
/** Late-bound by mount.ts so binders (x-if, x-for) can mount nested hosts synchronously without an import cycle. */
|
|
96
101
|
export const hooks: { mountTree: (root: ParentNode) => void } = { mountTree: () => {} }
|
|
97
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
|
+
|
|
98
108
|
/** Late-bound by the `island` feature (features/island.ts) so a page without React islands never loads the hydration glue. */
|
|
99
109
|
export const islandHooks: { schedule: (root: ParentNode) => void; cancel: (root: Node) => void } = { schedule: () => {}, cancel: () => {} }
|
package/src/mount.ts
CHANGED
|
@@ -4,9 +4,13 @@ import { whenBehavior } from './registry'
|
|
|
4
4
|
import { bindHost } from './directives/index'
|
|
5
5
|
import { insideRaw } from './directives/common'
|
|
6
6
|
import { warnOnce } from './warn'
|
|
7
|
-
import { bindPropsFromParent } from './props-bind'
|
|
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')
|
|
@@ -15,13 +19,26 @@ function readProps(host: HTMLElement): Record<string, unknown> {
|
|
|
15
19
|
catch { warnOnce(`j:${host.outerHTML.slice(0, 80)}`, `bad x-props JSON in ${host.getAttribute('x-data')}`); return {} }
|
|
16
20
|
}
|
|
17
21
|
|
|
22
|
+
function sameJson(a: unknown, b: unknown): boolean {
|
|
23
|
+
try { return JSON.stringify(a) === JSON.stringify(b) } catch { return false }
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
function mountHost(host: HTMLElement): void {
|
|
19
27
|
if (instances.has(host) || !host.isConnected) return
|
|
20
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
|
|
21
32
|
whenBehavior(name, (factory) => {
|
|
22
33
|
if (instances.has(host) || !host.isConnected) return
|
|
23
34
|
const parent = nearestInstance(host)
|
|
24
|
-
const
|
|
35
|
+
const printed = readProps(host)
|
|
36
|
+
const props = initialProps(host, parent, printed)
|
|
37
|
+
const inst = new Instance(host, name, parent, props)
|
|
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
|
|
25
42
|
instances.set(host, inst)
|
|
26
43
|
try { inst.init(factory) } catch (e) { console.error(`[brust] init threw: ${name}`, e); inst.dispose(); instances.delete(host); return }
|
|
27
44
|
bindHost(inst, host, {})
|
|
@@ -44,7 +61,11 @@ function mountTree(root: ParentNode): void {
|
|
|
44
61
|
for (const h of hosts) if (!insideRaw(h)) mountHost(h)
|
|
45
62
|
islandHooks.schedule(root)
|
|
46
63
|
}
|
|
47
|
-
hooks.mountTree = (root) =>
|
|
64
|
+
hooks.mountTree = (root) =>
|
|
65
|
+
untracked(() => {
|
|
66
|
+
cloning++
|
|
67
|
+
try { mountTree(root) } finally { cloning-- }
|
|
68
|
+
})
|
|
48
69
|
|
|
49
70
|
function disposeTree(root: Node): void {
|
|
50
71
|
if (!(root instanceof Element)) return
|
|
@@ -58,6 +79,7 @@ export function mount(root: ParentNode = document.body): void {
|
|
|
58
79
|
// Observe first: binders (x-if, x-for) insert nested hosts during the initial pass.
|
|
59
80
|
if (!observer) startObserver(root); else if (root !== observedRoot) observer.observe(root, { childList: true, subtree: true })
|
|
60
81
|
mountTree(root)
|
|
82
|
+
pageBooted = true
|
|
61
83
|
}
|
|
62
84
|
let observedRoot: ParentNode | null = null
|
|
63
85
|
function startObserver(root: ParentNode): void {
|
package/src/props-bind.ts
CHANGED
|
@@ -9,6 +9,16 @@ function scopeOf(el: Element): Record<string, unknown> {
|
|
|
9
9
|
return {}
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/** F277: the props a linked host starts with: its parent's current `x-props-bind` value when the parent is mounted (a
|
|
13
|
+
* host an `x-if` or a row creates after first paint gets what the parent passes NOW, as React mounts with the current
|
|
14
|
+
* props: a `useState(prop)` starts from it), else its printed `x-props`. */
|
|
15
|
+
export function initialProps(host: HTMLElement, parent: Instance | null, printed: Record<string, unknown>): Record<string, unknown> {
|
|
16
|
+
const raw = host.getAttribute('x-props-bind'); if (!raw || !parent) return printed
|
|
17
|
+
const p = parseValue(raw); if (!p) return printed
|
|
18
|
+
const r = resolve(parent.members, p, scopeOf(host))
|
|
19
|
+
return r.ok && r.value && typeof r.value === 'object' ? (r.value as Record<string, unknown>) : printed
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
export function bindPropsFromParent(inst: Instance): void {
|
|
13
23
|
const raw = inst.host.getAttribute('x-props-bind'); if (!raw) return
|
|
14
24
|
const p = parseValue(raw)
|
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
|