@brust/runtime-dom 2.0.0-alpha.3 → 2.0.0-alpha.31

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/src/index.ts CHANGED
@@ -1,9 +1,8 @@
1
- export { signal, computed, effect, batch, untracked, isSignal, isComputed } from './signal'
2
- export type { Signal, Computed, Dispose } from './signal'
1
+ // The full runtime: the core plus every feature. Tests and non-split consumers import this; the browser build
2
+ // loads `./core` and only the `./features/*` a route needs.
3
+ export * from './core'
3
4
  export { defineStore } from './store/define-store'
4
5
  export type { StoreHandle, Snapshot } from './store/define-store'
5
- export { defineBehavior, setChunkLoader } from './registry'
6
- export type { BehaviorCtx, BehaviorFactory, BehaviorInstance } from './registry'
7
- export { mount, unmount } from './mount'
8
6
  export { defineIsland } from './island'
9
7
  export type { IslandHydrate } from './island'
8
+ import './features/index'
package/src/instance.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  import { effect as coreEffect, signal, type Dispose, type Signal } from './signal'
2
2
  import type { BehaviorCtx, BehaviorFactory, BehaviorInstance } from './registry'
3
3
 
4
+ /** Defined by `brust build --dev` (true) and every other build (false): the dev code below is removed from production bundles. */
5
+ declare const __BRUST_DEV__: boolean | undefined
6
+ /** Dev only (L14): the live instances, for the devtools (`globalThis.__brustDev.instances`). */
7
+ function devTrack(i: Instance, on: boolean): void {
8
+ const g = globalThis as { __brustDev?: { instances: Set<Instance> } }
9
+ const dev = (g.__brustDev ??= { instances: new Set<Instance>() })
10
+ if (on) dev.instances.add(i)
11
+ else dev.instances.delete(i)
12
+ }
13
+
4
14
  export class Instance {
5
15
  members: BehaviorInstance = {}
6
16
  props: Signal<Record<string, unknown>>
@@ -8,26 +18,71 @@ export class Instance {
8
18
  children = new Set<Instance>()
9
19
  private disposers: Dispose[] = []
10
20
  private cleanups: Array<() => void> = []
21
+ /** F415: while an `x-if` branch or an `x-for` row binds, what it registers is owned by that part (`capture`). */
22
+ private sink: Array<() => void> | null = null
11
23
  disposed = false
12
24
  /** false until the initial bind pass finishes; the first-paint tripwire only applies before then. */
13
25
  booted = false
26
+ /** Mounted after the page booted: nothing of it was in the server markup (`BehaviorCtx.late`). */
27
+ late = false
28
+ /** F277: started from props other than the ones its markup was printed with (a host an `x-if` or a row created after
29
+ * first paint, linked to its parent's current value): its first bind is not a first paint, so no tripwire. */
30
+ reseeded = false
31
+ /** Effects the component declared while its factory ran: they start once the instance is bound (x-ref, x-on), like React's commit (F97). */
32
+ private pending: Array<[() => void | (() => void), Dispose?, number?]> | null = []
14
33
 
15
34
  constructor(public host: HTMLElement, public name: string, public parent: Instance | null, initialProps: Record<string, unknown>) {
16
35
  this.props = signal(initialProps)
17
36
  parent?.children.add(this)
37
+ if (typeof __BRUST_DEV__ !== 'undefined' && __BRUST_DEV__) devTrack(this, true)
18
38
  }
19
39
 
20
- effect(fn: () => void | (() => void)): Dispose {
21
- const d = coreEffect(fn); this.disposers.push(d); return d
40
+ effect(fn: () => void | (() => void), tier = 0): Dispose {
41
+ const d = coreEffect(fn, tier); (this.sink ?? this.disposers).push(d); return d
42
+ }
43
+ /** `ctx.effect`: deferred until `start()` while the factory runs, immediate afterwards. A compiled effect re-runs after
44
+ * the bindings: layout (`layout` true) before passive (F288, ruling 94404307 amended). */
45
+ private componentEffect(fn: () => void | (() => void), layout = false): Dispose {
46
+ const tier = layout ? 1 : 2
47
+ if (!this.pending) return this.effect(fn, tier)
48
+ const q: [() => void | (() => void), Dispose?, number?] = [fn, undefined, tier]
49
+ this.pending.push(q)
50
+ return () => { q[0] = () => {}; q[1]?.() }
51
+ }
52
+ /**
53
+ * Runs the queued component effects, in declaration order. Call after the initial bind pass.
54
+ * Order across hosts: a static nested host mounts after its parent (document order), so a
55
+ * parent's effects start before the child is bound; hosts created by x-if / x-for during the
56
+ * parent's bind pass start their own effects first. A throwing effect is handled like a
57
+ * throwing factory: logged, the instance disposed; returns false then.
58
+ */
59
+ start(): boolean {
60
+ const queue = this.pending
61
+ this.pending = null
62
+ for (const q of queue ?? []) {
63
+ if (this.disposed) break
64
+ try { q[1] = this.effect(q[0], q[2]) }
65
+ catch (e) { console.error(`[brust] effect threw: ${this.name}`, e); this.dispose(); instances.delete(this.host); return false }
66
+ }
67
+ return true
68
+ }
69
+ onCleanup(fn: () => void): void { (this.sink ?? this.cleanups).push(fn) }
70
+ /** F415/F416: runs `fn` with the effects and cleanups it registers owned by the returned disposer, which an `x-if`
71
+ * branch or an `x-for` row runs when it is removed: its effects stop and its cleanups run (an `x-ref` lets go of the
72
+ * node), in the order they were registered. */
73
+ capture(fn: () => void): () => void {
74
+ const prev = this.sink; const own: Array<() => void> = (this.sink = [])
75
+ try { fn() } finally { this.sink = prev }
76
+ return () => runCleanups(own)
22
77
  }
23
- onCleanup(fn: () => void): void { this.cleanups.push(fn) }
24
- ref<E extends Element = HTMLElement>(name: string): { current: E | null } {
78
+ ref<E extends Element = HTMLElement>(name: string, init?: unknown): { current: E | null } {
25
79
  let r = this.refs.get(name)
26
- if (!r) { r = { current: null }; this.refs.set(name, r) }
80
+ // `useRef(init)`: the cell starts at `init`; an `x-ref` element replaces it when it binds (React attaches the same way).
81
+ if (!r) { r = { current: (init ?? null) as Element | null }; this.refs.set(name, r) }
27
82
  return r as { current: E | null }
28
83
  }
29
84
  ctx(): BehaviorCtx {
30
- return { el: this.host, props: this.props, effect: (fn) => this.effect(fn), onCleanup: (fn) => this.onCleanup(fn), ref: (n) => this.ref(n) }
85
+ return { el: this.host, props: this.props, effect: (fn, layout) => this.componentEffect(fn, layout), onCleanup: (fn) => this.onCleanup(fn), ref: (n, init) => this.ref(n, init), late: this.late, items: () => collectionHooks.items(this.host) }
31
86
  }
32
87
  init(factory: BehaviorFactory): void {
33
88
  const m = factory(this.ctx())
@@ -37,13 +92,19 @@ export class Instance {
37
92
  dispose(): void {
38
93
  if (this.disposed) return
39
94
  this.disposed = true
95
+ if (typeof __BRUST_DEV__ !== 'undefined' && __BRUST_DEV__) devTrack(this, false)
96
+ this.pending = null // effects not yet started never run
40
97
  for (const c of Array.from(this.children)) c.dispose()
41
98
  this.parent?.children.delete(this)
42
99
  for (const d of this.disposers.splice(0)) d()
43
- for (const c of this.cleanups.splice(0)) { try { c() } catch (e) { console.error('[brust] cleanup threw', e) } }
100
+ runCleanups(this.cleanups)
44
101
  }
45
102
  }
46
103
 
104
+ function runCleanups(list: Array<() => void>): void {
105
+ for (const c of list.splice(0)) { try { c() } catch (e) { console.error('[brust] cleanup threw', e) } }
106
+ }
107
+
47
108
  export const instances = new WeakMap<Element, Instance>()
48
109
  export function instanceOf(el: Element): Instance | undefined { return instances.get(el) }
49
110
  /** Instance of the nearest ancestor HOST. A host that has not mounted yet (chunk loading) yields null: wait, never skip to a farther ancestor. */
@@ -54,3 +115,11 @@ export function nearestInstance(el: Element): Instance | null {
54
115
 
55
116
  /** Late-bound by mount.ts so binders (x-if, x-for) can mount nested hosts synchronously without an import cycle. */
56
117
  export const hooks: { mountTree: (root: ParentNode) => void } = { mountTree: () => {} }
118
+
119
+ /** One item of a collection (R34.141): the data its host handed over, the element, and `ref.current` (Radix's item shape). */
120
+ export type CollectionItem = { ref: { current: Element }; element: Element } & Record<string, unknown>
121
+ /** Late-bound by the `collection` feature (features/collection.ts): `ctx.items()` is a no-op list until it loads. */
122
+ export const collectionHooks: { items: (from: Element) => CollectionItem[] } = { items: () => [] }
123
+
124
+ /** Late-bound by the `island` feature (features/island.ts) so a page without React islands never loads the hydration glue. */
125
+ export const islandHooks: { schedule: (root: ParentNode) => void; cancel: (root: Node) => void } = { schedule: () => {}, cancel: () => {} }
package/src/island.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  // React islands: the server paints <brust-island data-id x-props>HTML</brust-island>; the island's
2
2
  // chunk registers a hydrate function through a global queue (no import of the runtime); the runtime
3
3
  // hydrates each host once, when idle (spec S12). Load order between runtime and chunks is free.
4
- import { warnOnce } from './warn'
4
+ import { warnOnce, insideRaw } from './core-api'
5
5
 
6
- export type IslandHydrate = (host: HTMLElement, props: Record<string, unknown>) => void
6
+ /** Hydrates `host`. It may return a dispose function (the React shim returns `() => root.unmount()`): the runtime
7
+ * calls it once, when the host leaves the document, so effects clean up like a React route change. */
8
+ export type IslandHydrate = (host: HTMLElement, props: Record<string, unknown>) => void | (() => void)
7
9
  type QueueEntry = [string, IslandHydrate]
8
10
  declare global { var __brustIslands: QueueEntry[] | undefined; var __brustIslandReady: (() => void) | undefined }
9
11
 
@@ -33,14 +35,20 @@ export function whenIsland(id: string, cb: (h: IslandHydrate) => void): void {
33
35
 
34
36
  // ---- scheduler ----------------------------------------------------------------------------
35
37
  type Idle = (cb: () => void) => void
38
+ /** Longest an island waits for an idle period. Without a timeout `requestIdleCallback` never fires while
39
+ * the main thread has no idle gap (a loaded runner, a throttled or hidden tab), and an island whose host
40
+ * is already painted would stay server HTML indefinitely. */
41
+ export const IDLE_TIMEOUT_MS = 250
36
42
  const defaultIdle: Idle = (cb) => {
37
- const ric = (globalThis as { requestIdleCallback?: (cb: () => void) => number }).requestIdleCallback
38
- if (typeof ric === 'function') ric(cb); else setTimeout(cb, 1)
43
+ const ric = (globalThis as { requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number }).requestIdleCallback
44
+ if (typeof ric === 'function') ric(cb, { timeout: IDLE_TIMEOUT_MS }); else setTimeout(cb, 1)
39
45
  }
40
46
  let idle: Idle = defaultIdle
41
47
  export function _setIdle(fn: Idle | null): void { idle = fn ?? defaultIdle }
42
48
 
43
49
  const pending = new Set<HTMLElement>()
50
+ /** Dispose functions of hydrated hosts, until the host leaves the document. */
51
+ const live = new Map<HTMLElement, () => void>()
44
52
 
45
53
  function readIslandProps(host: HTMLElement, id: string): Record<string, unknown> {
46
54
  const raw = host.getAttribute('x-props')
@@ -52,11 +60,16 @@ function readIslandProps(host: HTMLElement, id: string): Record<string, unknown>
52
60
  function hydrateHost(host: HTMLElement, id: string, hydrate: IslandHydrate): void {
53
61
  pending.delete(host)
54
62
  if (!host.isConnected || host.hasAttribute('data-hydrated')) return
55
- try { hydrate(host, readIslandProps(host, id)); host.setAttribute('data-hydrated', '1') }
63
+ try {
64
+ const dispose = hydrate(host, readIslandProps(host, id))
65
+ if (typeof dispose === 'function') live.set(host, dispose)
66
+ host.setAttribute('data-hydrated', '1')
67
+ }
56
68
  catch (e) { console.error(`[brust] hydrate threw: ${id}`, e) }
57
69
  }
58
70
 
59
71
  function scheduleHost(host: HTMLElement): void {
72
+ if (insideRaw(host)) return // inside dangerouslySetInnerHTML markup: never hydrated
60
73
  if (pending.has(host) || host.hasAttribute('data-hydrated')) return
61
74
  const id = host.getAttribute('data-id')!
62
75
  pending.add(host)
@@ -69,16 +82,24 @@ export function scheduleIslands(root: ParentNode): void {
69
82
  root.querySelectorAll<HTMLElement>('brust-island[data-id]').forEach(scheduleHost)
70
83
  }
71
84
 
72
- /** Forget pending hosts under a subtree that left the document. */
85
+ /** A subtree left the document: forget its pending hosts and unmount the roots of its hydrated ones. */
73
86
  export function cancelIslands(root: Node): void {
74
87
  if (!(root instanceof Element)) return
75
- if (root instanceof HTMLElement && pending.has(root)) pending.delete(root)
76
- root.querySelectorAll<HTMLElement>('brust-island[data-id]').forEach((h) => pending.delete(h))
88
+ const hosts = root.querySelectorAll<HTMLElement>('brust-island[data-id]')
89
+ const forget = (h: HTMLElement) => {
90
+ pending.delete(h)
91
+ const dispose = live.get(h)
92
+ if (!dispose) return
93
+ live.delete(h)
94
+ try { dispose() } catch (e) { console.error('[brust] island unmount threw', e) }
95
+ }
96
+ if (root instanceof HTMLElement) forget(root)
97
+ hosts.forEach(forget)
77
98
  }
78
99
 
79
100
  /** Test helper: forget registrations, waiters, pending hosts and the global queue. */
80
101
  export function _resetIslands(): void {
81
- slots.clear(); pending.clear(); idle = defaultIdle
102
+ slots.clear(); pending.clear(); live.clear(); idle = defaultIdle
82
103
  globalThis.__brustIslands = []
83
104
  globalThis.__brustIslandReady = undefined
84
105
  }
package/src/mount.ts CHANGED
@@ -1,13 +1,16 @@
1
- import { Instance, hooks, instances, nearestInstance } from './instance'
1
+ import { Instance, hooks, instances, islandHooks, nearestInstance } from './instance'
2
2
  import { untracked } from './signal'
3
3
  import { whenBehavior } from './registry'
4
4
  import { bindHost } from './directives/index'
5
+ import { insideRaw } from './directives/common'
5
6
  import { warnOnce } from './warn'
6
- import { bindPropsFromParent } from './props-bind'
7
- import { scheduleIslands, cancelIslands } from './island'
8
- import './directives/register' // Tasks 3–6 add binders here (file created in Task 3; create it empty now)
7
+ import { bindPropsFromParent, initialProps } from './props-bind'
9
8
 
10
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
11
14
 
12
15
  function readProps(host: HTMLElement): Record<string, unknown> {
13
16
  const raw = host.getAttribute('x-props')
@@ -16,17 +19,31 @@ function readProps(host: HTMLElement): Record<string, unknown> {
16
19
  catch { warnOnce(`j:${host.outerHTML.slice(0, 80)}`, `bad x-props JSON in ${host.getAttribute('x-data')}`); return {} }
17
20
  }
18
21
 
22
+ function sameJson(a: unknown, b: unknown): boolean {
23
+ try { return JSON.stringify(a) === JSON.stringify(b) } catch { return false }
24
+ }
25
+
19
26
  function mountHost(host: HTMLElement): void {
20
27
  if (instances.has(host) || !host.isConnected) return
21
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
22
32
  whenBehavior(name, (factory) => {
23
33
  if (instances.has(host) || !host.isConnected) return
24
34
  const parent = nearestInstance(host)
25
- 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
26
42
  instances.set(host, inst)
27
43
  try { inst.init(factory) } catch (e) { console.error(`[brust] init threw: ${name}`, e); inst.dispose(); instances.delete(host); return }
28
44
  bindHost(inst, host, {})
29
45
  inst.booted = true
46
+ if (!inst.start()) return // F97: refs and handlers are bound; now the component's effects
30
47
  bindPropsFromParent(inst)
31
48
  // Children that mounted before this instance existed (parent chunk arrived late) link up now.
32
49
  host.querySelectorAll<HTMLElement>('[x-data]').forEach((h) => {
@@ -41,14 +58,18 @@ function mountTree(root: ParentNode): void {
41
58
  if (root instanceof HTMLElement && root.hasAttribute('x-data')) hosts.push(root)
42
59
  root.querySelectorAll<HTMLElement>('[x-data]').forEach((h) => hosts.push(h))
43
60
  // document order = parents before children, so nearestInstance finds a mounted parent
44
- for (const h of hosts) mountHost(h)
45
- scheduleIslands(root)
61
+ for (const h of hosts) if (!insideRaw(h)) mountHost(h)
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
51
- cancelIslands(root)
72
+ islandHooks.cancel(root)
52
73
  const hosts: Element[] = root.hasAttribute('x-data') ? [root] : []
53
74
  root.querySelectorAll('[x-data]').forEach((h) => hosts.push(h))
54
75
  for (const h of hosts) { const i = instances.get(h); if (i) { i.dispose(); instances.delete(h) } }
@@ -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 {
@@ -71,6 +93,6 @@ function startObserver(root: ParentNode): void {
71
93
  observer.observe(root, { childList: true, subtree: true })
72
94
  }
73
95
  export function unmount(root: ParentNode = document.body): void {
74
- if (root === observedRoot) { observer?.disconnect(); observer = null; observedRoot = null }
96
+ if (root === observedRoot) { observer?.disconnect(); observer = null; observedRoot = null; pageBooted = false } // torn down: the next mount() is a fresh boot
75
97
  disposeTree(root as Node)
76
98
  }
@@ -0,0 +1,57 @@
1
+ // The layout chain of a brust document (design §5.9): the root level from the nav tag's `data-brust-root`, then the
2
+ // `<!--brust-l:<id>:<hash>-->…<!--/brust-l:<id>-->` pairs the server wraps around every inner level, outermost first.
3
+
4
+ /** One level: `key` is `<id>:<shellHash>`; an inner level has its two comments. */
5
+ export interface Level { key: string; open?: Comment; close?: Comment }
6
+
7
+ /** null when the document has no usable chain (no root, unbalanced, crossing or sibling pairs): swap the whole body. */
8
+ export function readChain(doc: Document): Level[] | null {
9
+ const root = doc.querySelector('script[data-brust-nav]')?.getAttribute('data-brust-root')
10
+ if (!root || !doc.body) return null
11
+ const out: Level[] = [{ key: root }]
12
+ const open: Level[] = []
13
+ const w = doc.createTreeWalker(doc.body, 128 /* NodeFilter.SHOW_COMMENT */)
14
+ for (let n: Node | null; (n = w.nextNode()); ) {
15
+ const v = (n as Comment).data
16
+ if (v.startsWith('brust-l:')) {
17
+ // a level opens inside the deepest open one, never beside a closed one: the chain is a line
18
+ if (open.length !== out.length - 1) return null
19
+ const l: Level = { key: v.slice(8), open: n as Comment }
20
+ out.push(l)
21
+ open.push(l)
22
+ } else if (v.startsWith('/brust-l:')) {
23
+ const l = open.pop()
24
+ if (!l || l.key.slice(0, l.key.lastIndexOf(':')) !== v.slice(9)) return null
25
+ l.close = n as Comment
26
+ }
27
+ }
28
+ return open.length ? null : out
29
+ }
30
+
31
+ /**
32
+ * A pair can be a range only when the parser kept its two comments siblings (a block in a `<p>` splits them), and not
33
+ * in a table section: there the comments stay put while the parser moves the content out in front of the table
34
+ * (foster parenting), so the range would hold nothing of the level.
35
+ */
36
+ const usable = (l: Level) => l.open!.parentNode === l.close!.parentNode && !/^T(ABLE|BODY|HEAD|FOOT|R)$/.test(l.open!.parentNode!.nodeName)
37
+
38
+ /**
39
+ * The level to replace: the first one whose id or hash differs, else the page (never kept), moved up while its pair is
40
+ * not a sibling pair in either document. 0 = the whole body.
41
+ */
42
+ export function swapLevel(a: Level[] | null, b: Level[] | null): number {
43
+ if (!a || !b || a[0]!.key !== b[0]!.key) return 0
44
+ const leaf = Math.min(a.length, b.length) - 1
45
+ let i = 1
46
+ while (i < leaf && a[i]!.key === b[i]!.key) i++
47
+ if (i > leaf) return 0
48
+ while (i && !(usable(a[i]!) && usable(b[i]!))) i--
49
+ return i
50
+ }
51
+
52
+ /** The nodes of a level's range, both comments included. */
53
+ export function between(l: Level): ChildNode[] {
54
+ const out: ChildNode[] = []
55
+ for (let n: ChildNode | null = l.open!; n; n = n === l.close ? null : n.nextSibling) out.push(n)
56
+ return out
57
+ }
@@ -0,0 +1,5 @@
1
+ // The `nav` asset (design §5.8): SPA navigation on every full brust document, also on a page that loads no runtime.
2
+ // Standalone: nothing under nav/ imports the runtime core. `navigate()` reaches it through `Symbol.for('brust.nav')`.
3
+ import { boot } from './nav'
4
+
5
+ boot()