@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brust/runtime-dom",
3
- "version": "2.0.0-alpha.16",
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,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
- 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
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
+ })
@@ -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,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) => untracked(() => 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