@brust/runtime-dom 2.0.0-alpha.3 → 2.0.0-alpha.30
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/README.md +15 -2
- package/package.json +19 -2
- package/src/attr-kinds.ts +6 -0
- package/src/core-api.ts +11 -0
- package/src/core.ts +8 -0
- package/src/directives/bind.ts +63 -16
- package/src/directives/common.ts +6 -0
- package/src/directives/for.ts +20 -12
- package/src/directives/html.ts +20 -0
- package/src/directives/if.ts +44 -11
- package/src/directives/index.ts +12 -1
- package/src/directives/model.ts +5 -7
- package/src/directives/on.ts +17 -2
- package/src/directives/ref.ts +2 -6
- package/src/directives/show.ts +1 -2
- package/src/directives/text.ts +1 -2
- package/src/features/basics.ts +6 -0
- package/src/features/collection.ts +40 -0
- package/src/features/for.ts +1 -0
- package/src/features/html.ts +1 -0
- package/src/features/if.ts +1 -0
- package/src/features/index.ts +9 -0
- package/src/features/island.ts +9 -0
- package/src/features/model.ts +1 -0
- package/src/features/store.ts +6 -0
- package/src/index.ts +4 -5
- package/src/instance.ts +76 -7
- package/src/island.ts +30 -9
- package/src/mount.ts +32 -10
- package/src/nav/chain.ts +57 -0
- package/src/nav/index.ts +5 -0
- package/src/nav/nav.ts +286 -0
- package/src/nav/swap.ts +160 -0
- package/src/props-bind.ts +10 -0
- package/src/registry.ts +6 -2
- package/src/signal.ts +15 -7
- package/src/store/define-store.ts +8 -10
- package/src/store/react.ts +3 -1
- package/src/store/registry.ts +39 -1
- package/src/directives/register.ts +0 -8
package/src/index.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 './
|
|
4
|
+
import { warnOnce, insideRaw } from './core-api'
|
|
5
5
|
|
|
6
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
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
|
-
|
|
61
|
+
for (const h of hosts) if (!insideRaw(h)) mountHost(h)
|
|
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
|
|
51
|
-
|
|
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
|
}
|
package/src/nav/chain.ts
ADDED
|
@@ -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
|
+
}
|
package/src/nav/index.ts
ADDED
|
@@ -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()
|