@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brust/runtime-dom",
3
- "version": "2.0.0-alpha.15",
3
+ "version": "2.0.0-alpha.17",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
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'
@@ -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
- if (name === 'class') { el.setAttribute('class', v == null ? '' : String(v)); return }
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
- if (BOOLEAN.has(name)) {
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
  })
@@ -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
+ })
@@ -6,3 +6,4 @@ import './for'
6
6
  import './model'
7
7
  import './html'
8
8
  import './island'
9
+ import './collection'
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 inst = new Instance(host, name, parent, readProps(host))
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) => untracked(() => 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