@brust/runtime-dom 0.0.0-stage → 2.0.0-alpha.10
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 +106 -2
- package/package.json +68 -4
- package/src/core-api.ts +10 -0
- package/src/core.ts +8 -0
- package/src/directives/bind.ts +31 -0
- package/src/directives/common.ts +27 -0
- package/src/directives/for.ts +84 -0
- package/src/directives/html.ts +20 -0
- package/src/directives/if.ts +25 -0
- package/src/directives/index.ts +34 -0
- package/src/directives/model.ts +30 -0
- package/src/directives/on.ts +16 -0
- package/src/directives/ref.ts +9 -0
- package/src/directives/show.ts +11 -0
- package/src/directives/text.ts +14 -0
- package/src/features/basics.ts +6 -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 +8 -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 +8 -0
- package/src/instance.ts +98 -0
- package/src/island.ts +89 -0
- package/src/mount.ts +76 -0
- package/src/props-bind.ts +30 -0
- package/src/registry.ts +38 -0
- package/src/signal.ts +110 -0
- package/src/store/define-store.ts +106 -0
- package/src/store/index.ts +5 -0
- package/src/store/internal.ts +5 -0
- package/src/store/react.ts +21 -0
- package/src/store/registry.ts +103 -0
- package/src/store/serialize.ts +33 -0
- package/src/value.ts +47 -0
- package/src/warn.ts +9 -0
package/src/instance.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { effect as coreEffect, signal, type Dispose, type Signal } from './signal'
|
|
2
|
+
import type { BehaviorCtx, BehaviorFactory, BehaviorInstance } from './registry'
|
|
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
|
+
|
|
14
|
+
export class Instance {
|
|
15
|
+
members: BehaviorInstance = {}
|
|
16
|
+
props: Signal<Record<string, unknown>>
|
|
17
|
+
refs = new Map<string, { current: Element | null }>()
|
|
18
|
+
children = new Set<Instance>()
|
|
19
|
+
private disposers: Dispose[] = []
|
|
20
|
+
private cleanups: Array<() => void> = []
|
|
21
|
+
disposed = false
|
|
22
|
+
/** false until the initial bind pass finishes; the first-paint tripwire only applies before then. */
|
|
23
|
+
booted = false
|
|
24
|
+
/** 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
|
+
private pending: Array<[() => void | (() => void), Dispose?]> | null = []
|
|
26
|
+
|
|
27
|
+
constructor(public host: HTMLElement, public name: string, public parent: Instance | null, initialProps: Record<string, unknown>) {
|
|
28
|
+
this.props = signal(initialProps)
|
|
29
|
+
parent?.children.add(this)
|
|
30
|
+
if (typeof __BRUST_DEV__ !== 'undefined' && __BRUST_DEV__) devTrack(this, true)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
effect(fn: () => void | (() => void)): Dispose {
|
|
34
|
+
const d = coreEffect(fn); this.disposers.push(d); return d
|
|
35
|
+
}
|
|
36
|
+
/** `ctx.effect`: deferred until `start()` while the factory runs, immediate afterwards. */
|
|
37
|
+
private componentEffect(fn: () => void | (() => void)): Dispose {
|
|
38
|
+
if (!this.pending) return this.effect(fn)
|
|
39
|
+
const q: [() => void | (() => void), Dispose?] = [fn]
|
|
40
|
+
this.pending.push(q)
|
|
41
|
+
return () => { q[0] = () => {}; q[1]?.() }
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Runs the queued component effects, in declaration order. Call after the initial bind pass.
|
|
45
|
+
* Order across hosts: a static nested host mounts after its parent (document order), so a
|
|
46
|
+
* parent's effects start before the child is bound; hosts created by x-if / x-for during the
|
|
47
|
+
* parent's bind pass start their own effects first. A throwing effect is handled like a
|
|
48
|
+
* throwing factory: logged, the instance disposed; returns false then.
|
|
49
|
+
*/
|
|
50
|
+
start(): boolean {
|
|
51
|
+
const queue = this.pending
|
|
52
|
+
this.pending = null
|
|
53
|
+
for (const q of queue ?? []) {
|
|
54
|
+
if (this.disposed) break
|
|
55
|
+
try { q[1] = this.effect(q[0]) }
|
|
56
|
+
catch (e) { console.error(`[brust] effect threw: ${this.name}`, e); this.dispose(); instances.delete(this.host); return false }
|
|
57
|
+
}
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
onCleanup(fn: () => void): void { this.cleanups.push(fn) }
|
|
61
|
+
ref<E extends Element = HTMLElement>(name: string): { current: E | null } {
|
|
62
|
+
let r = this.refs.get(name)
|
|
63
|
+
if (!r) { r = { current: null }; this.refs.set(name, r) }
|
|
64
|
+
return r as { current: E | null }
|
|
65
|
+
}
|
|
66
|
+
ctx(): BehaviorCtx {
|
|
67
|
+
return { el: this.host, props: this.props, effect: (fn) => this.componentEffect(fn), onCleanup: (fn) => this.onCleanup(fn), ref: (n) => this.ref(n) }
|
|
68
|
+
}
|
|
69
|
+
init(factory: BehaviorFactory): void {
|
|
70
|
+
const m = factory(this.ctx())
|
|
71
|
+
if (m && typeof m === 'object') this.members = m
|
|
72
|
+
if (typeof this.members.init === 'function') (this.members.init as () => void)()
|
|
73
|
+
}
|
|
74
|
+
dispose(): void {
|
|
75
|
+
if (this.disposed) return
|
|
76
|
+
this.disposed = true
|
|
77
|
+
if (typeof __BRUST_DEV__ !== 'undefined' && __BRUST_DEV__) devTrack(this, false)
|
|
78
|
+
this.pending = null // effects not yet started never run
|
|
79
|
+
for (const c of Array.from(this.children)) c.dispose()
|
|
80
|
+
this.parent?.children.delete(this)
|
|
81
|
+
for (const d of this.disposers.splice(0)) d()
|
|
82
|
+
for (const c of this.cleanups.splice(0)) { try { c() } catch (e) { console.error('[brust] cleanup threw', e) } }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const instances = new WeakMap<Element, Instance>()
|
|
87
|
+
export function instanceOf(el: Element): Instance | undefined { return instances.get(el) }
|
|
88
|
+
/** Instance of the nearest ancestor HOST. A host that has not mounted yet (chunk loading) yields null: wait, never skip to a farther ancestor. */
|
|
89
|
+
export function nearestInstance(el: Element): Instance | null {
|
|
90
|
+
const h = el.parentElement?.closest('[x-data]')
|
|
91
|
+
return (h && instances.get(h)) || null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Late-bound by mount.ts so binders (x-if, x-for) can mount nested hosts synchronously without an import cycle. */
|
|
95
|
+
export const hooks: { mountTree: (root: ParentNode) => void } = { mountTree: () => {} }
|
|
96
|
+
|
|
97
|
+
/** Late-bound by the `island` feature (features/island.ts) so a page without React islands never loads the hydration glue. */
|
|
98
|
+
export const islandHooks: { schedule: (root: ParentNode) => void; cancel: (root: Node) => void } = { schedule: () => {}, cancel: () => {} }
|
package/src/island.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// React islands: the server paints <brust-island data-id x-props>HTML</brust-island>; the island's
|
|
2
|
+
// chunk registers a hydrate function through a global queue (no import of the runtime); the runtime
|
|
3
|
+
// hydrates each host once, when idle (spec S12). Load order between runtime and chunks is free.
|
|
4
|
+
import { warnOnce, insideRaw } from './core-api'
|
|
5
|
+
|
|
6
|
+
export type IslandHydrate = (host: HTMLElement, props: Record<string, unknown>) => void
|
|
7
|
+
type QueueEntry = [string, IslandHydrate]
|
|
8
|
+
declare global { var __brustIslands: QueueEntry[] | undefined; var __brustIslandReady: (() => void) | undefined }
|
|
9
|
+
|
|
10
|
+
// One entry per island id: the hydrate fn once registered, else the callbacks waiting for it.
|
|
11
|
+
const slots = new Map<string, IslandHydrate | Array<(h: IslandHydrate) => void>>()
|
|
12
|
+
|
|
13
|
+
export function defineIsland(id: string, hydrate: IslandHydrate): void {
|
|
14
|
+
const w = slots.get(id)
|
|
15
|
+
slots.set(id, hydrate)
|
|
16
|
+
if (Array.isArray(w)) w.forEach((cb) => cb(hydrate))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Move every queued [id, hydrate] from the global array into the registry. Idempotent. */
|
|
20
|
+
function drain(): void {
|
|
21
|
+
const q = (globalThis.__brustIslands ||= [])
|
|
22
|
+
while (q.length) defineIsland(...q.shift()!)
|
|
23
|
+
globalThis.__brustIslandReady = drain
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function whenIsland(id: string, cb: (h: IslandHydrate) => void): void {
|
|
27
|
+
drain()
|
|
28
|
+
const s = slots.get(id)
|
|
29
|
+
if (typeof s === 'function') cb(s)
|
|
30
|
+
else if (s) s.push(cb)
|
|
31
|
+
else slots.set(id, [cb])
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---- scheduler ----------------------------------------------------------------------------
|
|
35
|
+
type Idle = (cb: () => void) => void
|
|
36
|
+
/** Longest an island waits for an idle period. Without a timeout `requestIdleCallback` never fires while
|
|
37
|
+
* the main thread has no idle gap (a loaded runner, a throttled or hidden tab), and an island whose host
|
|
38
|
+
* is already painted would stay server HTML indefinitely. */
|
|
39
|
+
export const IDLE_TIMEOUT_MS = 250
|
|
40
|
+
const defaultIdle: Idle = (cb) => {
|
|
41
|
+
const ric = (globalThis as { requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number }).requestIdleCallback
|
|
42
|
+
if (typeof ric === 'function') ric(cb, { timeout: IDLE_TIMEOUT_MS }); else setTimeout(cb, 1)
|
|
43
|
+
}
|
|
44
|
+
let idle: Idle = defaultIdle
|
|
45
|
+
export function _setIdle(fn: Idle | null): void { idle = fn ?? defaultIdle }
|
|
46
|
+
|
|
47
|
+
const pending = new Set<HTMLElement>()
|
|
48
|
+
|
|
49
|
+
function readIslandProps(host: HTMLElement, id: string): Record<string, unknown> {
|
|
50
|
+
const raw = host.getAttribute('x-props')
|
|
51
|
+
if (!raw) return {}
|
|
52
|
+
try { const v = JSON.parse(raw); return v && typeof v === 'object' ? v : {} }
|
|
53
|
+
catch { warnOnce(`ij:${id}:${raw.slice(0, 40)}`, `bad x-props JSON in island ${id}`); return {} }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hydrateHost(host: HTMLElement, id: string, hydrate: IslandHydrate): void {
|
|
57
|
+
pending.delete(host)
|
|
58
|
+
if (!host.isConnected || host.hasAttribute('data-hydrated')) return
|
|
59
|
+
try { hydrate(host, readIslandProps(host, id)); host.setAttribute('data-hydrated', '1') }
|
|
60
|
+
catch (e) { console.error(`[brust] hydrate threw: ${id}`, e) }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function scheduleHost(host: HTMLElement): void {
|
|
64
|
+
if (insideRaw(host)) return // inside dangerouslySetInnerHTML markup: never hydrated
|
|
65
|
+
if (pending.has(host) || host.hasAttribute('data-hydrated')) return
|
|
66
|
+
const id = host.getAttribute('data-id')!
|
|
67
|
+
pending.add(host)
|
|
68
|
+
whenIsland(id, (hydrate) => { if (pending.has(host)) idle(() => hydrateHost(host, id, hydrate)) })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Schedule every not-yet-hydrated island under `root` (inclusive). Called by mount() and the observer. */
|
|
72
|
+
export function scheduleIslands(root: ParentNode): void {
|
|
73
|
+
if (root instanceof HTMLElement && root.tagName === 'BRUST-ISLAND' && root.hasAttribute('data-id')) scheduleHost(root)
|
|
74
|
+
root.querySelectorAll<HTMLElement>('brust-island[data-id]').forEach(scheduleHost)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Forget pending hosts under a subtree that left the document. */
|
|
78
|
+
export function cancelIslands(root: Node): void {
|
|
79
|
+
if (!(root instanceof Element)) return
|
|
80
|
+
if (root instanceof HTMLElement && pending.has(root)) pending.delete(root)
|
|
81
|
+
root.querySelectorAll<HTMLElement>('brust-island[data-id]').forEach((h) => pending.delete(h))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Test helper: forget registrations, waiters, pending hosts and the global queue. */
|
|
85
|
+
export function _resetIslands(): void {
|
|
86
|
+
slots.clear(); pending.clear(); idle = defaultIdle
|
|
87
|
+
globalThis.__brustIslands = []
|
|
88
|
+
globalThis.__brustIslandReady = undefined
|
|
89
|
+
}
|
package/src/mount.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Instance, hooks, instances, islandHooks, nearestInstance } from './instance'
|
|
2
|
+
import { untracked } from './signal'
|
|
3
|
+
import { whenBehavior } from './registry'
|
|
4
|
+
import { bindHost } from './directives/index'
|
|
5
|
+
import { insideRaw } from './directives/common'
|
|
6
|
+
import { warnOnce } from './warn'
|
|
7
|
+
import { bindPropsFromParent } from './props-bind'
|
|
8
|
+
|
|
9
|
+
let observer: MutationObserver | null = null
|
|
10
|
+
|
|
11
|
+
function readProps(host: HTMLElement): Record<string, unknown> {
|
|
12
|
+
const raw = host.getAttribute('x-props')
|
|
13
|
+
if (!raw) return {}
|
|
14
|
+
try { const v = JSON.parse(raw); return v && typeof v === 'object' ? v : {} }
|
|
15
|
+
catch { warnOnce(`j:${host.outerHTML.slice(0, 80)}`, `bad x-props JSON in ${host.getAttribute('x-data')}`); return {} }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function mountHost(host: HTMLElement): void {
|
|
19
|
+
if (instances.has(host) || !host.isConnected) return
|
|
20
|
+
const name = host.getAttribute('x-data')!
|
|
21
|
+
whenBehavior(name, (factory) => {
|
|
22
|
+
if (instances.has(host) || !host.isConnected) return
|
|
23
|
+
const parent = nearestInstance(host)
|
|
24
|
+
const inst = new Instance(host, name, parent, readProps(host))
|
|
25
|
+
instances.set(host, inst)
|
|
26
|
+
try { inst.init(factory) } catch (e) { console.error(`[brust] init threw: ${name}`, e); inst.dispose(); instances.delete(host); return }
|
|
27
|
+
bindHost(inst, host, {})
|
|
28
|
+
inst.booted = true
|
|
29
|
+
if (!inst.start()) return // F97: refs and handlers are bound; now the component's effects
|
|
30
|
+
bindPropsFromParent(inst)
|
|
31
|
+
// Children that mounted before this instance existed (parent chunk arrived late) link up now.
|
|
32
|
+
host.querySelectorAll<HTMLElement>('[x-data]').forEach((h) => {
|
|
33
|
+
const child = instances.get(h)
|
|
34
|
+
if (child && !child.parent && h.parentElement?.closest('[x-data]') === host) { child.parent = inst; inst.children.add(child); bindPropsFromParent(child) }
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mountTree(root: ParentNode): void {
|
|
40
|
+
const hosts: HTMLElement[] = []
|
|
41
|
+
if (root instanceof HTMLElement && root.hasAttribute('x-data')) hosts.push(root)
|
|
42
|
+
root.querySelectorAll<HTMLElement>('[x-data]').forEach((h) => hosts.push(h))
|
|
43
|
+
// document order = parents before children, so nearestInstance finds a mounted parent
|
|
44
|
+
for (const h of hosts) if (!insideRaw(h)) mountHost(h)
|
|
45
|
+
islandHooks.schedule(root)
|
|
46
|
+
}
|
|
47
|
+
hooks.mountTree = (root) => untracked(() => mountTree(root))
|
|
48
|
+
|
|
49
|
+
function disposeTree(root: Node): void {
|
|
50
|
+
if (!(root instanceof Element)) return
|
|
51
|
+
islandHooks.cancel(root)
|
|
52
|
+
const hosts: Element[] = root.hasAttribute('x-data') ? [root] : []
|
|
53
|
+
root.querySelectorAll('[x-data]').forEach((h) => hosts.push(h))
|
|
54
|
+
for (const h of hosts) { const i = instances.get(h); if (i) { i.dispose(); instances.delete(h) } }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function mount(root: ParentNode = document.body): void {
|
|
58
|
+
// Observe first: binders (x-if, x-for) insert nested hosts during the initial pass.
|
|
59
|
+
if (!observer) startObserver(root); else if (root !== observedRoot) observer.observe(root, { childList: true, subtree: true })
|
|
60
|
+
mountTree(root)
|
|
61
|
+
}
|
|
62
|
+
let observedRoot: ParentNode | null = null
|
|
63
|
+
function startObserver(root: ParentNode): void {
|
|
64
|
+
observedRoot = root
|
|
65
|
+
observer = new MutationObserver((records) => {
|
|
66
|
+
for (const r of records) {
|
|
67
|
+
r.removedNodes.forEach((n) => { if (!n.isConnected) disposeTree(n) })
|
|
68
|
+
r.addedNodes.forEach((n) => { if (n instanceof Element && n.isConnected) mountTree(n) })
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
observer.observe(root, { childList: true, subtree: true })
|
|
72
|
+
}
|
|
73
|
+
export function unmount(root: ParentNode = document.body): void {
|
|
74
|
+
if (root === observedRoot) { observer?.disconnect(); observer = null; observedRoot = null }
|
|
75
|
+
disposeTree(root as Node)
|
|
76
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Instance } from './instance'
|
|
2
|
+
import { parseValue, resolve } from './value'
|
|
3
|
+
import { warnOnce } from './warn'
|
|
4
|
+
import { nearestInstance } from './instance'
|
|
5
|
+
|
|
6
|
+
function scopeOf(el: Element): Record<string, unknown> {
|
|
7
|
+
let e: Element | null = el
|
|
8
|
+
while (e) { const s = (e as unknown as { __scope?: Record<string, unknown> }).__scope; if (s) return s; e = e.parentElement }
|
|
9
|
+
return {}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function bindPropsFromParent(inst: Instance): void {
|
|
13
|
+
const raw = inst.host.getAttribute('x-props-bind'); if (!raw) return
|
|
14
|
+
const p = parseValue(raw)
|
|
15
|
+
if (!p) { warnOnce(`pp:${inst.name}`, `x-props-bind="${raw}" in ${inst.name}: not a member path`); return }
|
|
16
|
+
const parent = inst.parent ?? nearestInstance(inst.host)
|
|
17
|
+
if (!parent) {
|
|
18
|
+
// An unmounted ancestor host means "wait" (the parent's mount links us); only a truly parentless host is an error.
|
|
19
|
+
if (!inst.host.parentElement?.closest('[x-data]')) warnOnce(`po:${inst.name}`, `x-props-bind="${raw}" in ${inst.name}: no parent`)
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
const scope = scopeOf(inst.host)
|
|
23
|
+
inst.effect(() => {
|
|
24
|
+
const r = resolve(parent.members, p, scope)
|
|
25
|
+
if (!r.ok) { warnOnce(`pm:${inst.name}`, `x-props-bind="${raw}": no such member in ${parent.name}`); return }
|
|
26
|
+
const v = r.value
|
|
27
|
+
if (v && typeof v === 'object') inst.props.set(v as Record<string, unknown>)
|
|
28
|
+
else warnOnce(`pn:${inst.name}`, `x-props-bind="${raw}" in ${inst.name}: not an object`)
|
|
29
|
+
})
|
|
30
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Dispose, Signal } from './signal'
|
|
2
|
+
|
|
3
|
+
export interface BehaviorCtx {
|
|
4
|
+
el: HTMLElement
|
|
5
|
+
props: Signal<Record<string, unknown>>
|
|
6
|
+
effect: (fn: () => void | (() => void)) => Dispose
|
|
7
|
+
onCleanup: (fn: () => void) => void
|
|
8
|
+
ref: <E extends Element = HTMLElement>(name: string) => { current: E | null }
|
|
9
|
+
}
|
|
10
|
+
export type BehaviorInstance = Record<string, unknown>
|
|
11
|
+
export type BehaviorFactory = (ctx: BehaviorCtx) => BehaviorInstance | void
|
|
12
|
+
|
|
13
|
+
const registry = new Map<string, BehaviorFactory>()
|
|
14
|
+
const waiters = new Map<string, Array<(f: BehaviorFactory) => void>>()
|
|
15
|
+
let loader: ((name: string) => Promise<unknown>) | null = null
|
|
16
|
+
const requested = new Set<string>()
|
|
17
|
+
|
|
18
|
+
export function defineBehavior(name: string, factory: BehaviorFactory): BehaviorFactory {
|
|
19
|
+
registry.set(name, factory)
|
|
20
|
+
const w = waiters.get(name); waiters.delete(name)
|
|
21
|
+
w?.forEach((cb) => cb(factory))
|
|
22
|
+
return factory
|
|
23
|
+
}
|
|
24
|
+
export function setChunkLoader(load: (name: string) => Promise<unknown>): void { loader = load; requested.clear() }
|
|
25
|
+
export function getBehavior(name: string): BehaviorFactory | undefined { return registry.get(name) }
|
|
26
|
+
|
|
27
|
+
/** Resolve a factory now or when its chunk arrives. Requests the chunk at most once per name. */
|
|
28
|
+
export function whenBehavior(name: string, cb: (f: BehaviorFactory) => void): void {
|
|
29
|
+
const f = registry.get(name)
|
|
30
|
+
if (f) { cb(f); return }
|
|
31
|
+
const list = waiters.get(name) ?? []; list.push(cb); waiters.set(name, list)
|
|
32
|
+
if (loader && !requested.has(name)) {
|
|
33
|
+
requested.add(name)
|
|
34
|
+
loader(name).catch((e) => console.error(`[brust] chunk load failed: ${name}`, e))
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Test helper: forget everything. */
|
|
38
|
+
export function _resetRegistry(): void { registry.clear(); waiters.clear(); loader = null; requested.clear() }
|
package/src/signal.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// A small push-pull reactive core. Signals push "dirty" to subscribers; computeds pull lazily.
|
|
2
|
+
type Subscriber = { mark(): void; deps: Set<Node> }
|
|
3
|
+
type Node = { subs: Set<Subscriber> }
|
|
4
|
+
type Runnable = { disposed: boolean; run(): void }
|
|
5
|
+
interface ReactiveCtx { currentSub: Subscriber | null; batchDepth: number; pending: Set<Runnable> }
|
|
6
|
+
|
|
7
|
+
const CTX = Symbol.for('brust.reactive.ctx') // M3-F C7
|
|
8
|
+
const SIGNAL = Symbol.for('brust.signal') // M3-F C7
|
|
9
|
+
const COMPUTED = Symbol.for('brust.computed') // M3-F C7
|
|
10
|
+
// ONE object per realm. Every copy of this module binds `ctx` once at load; hot paths do one
|
|
11
|
+
// property load on a module-constant object. The shape is fixed: a copy never adds fields, so
|
|
12
|
+
// copies of any version agree.
|
|
13
|
+
const ctx: ReactiveCtx = ((globalThis as { [CTX]?: ReactiveCtx })[CTX] ??= { currentSub: null, batchDepth: 0, pending: new Set() })
|
|
14
|
+
|
|
15
|
+
function track(node: Node) {
|
|
16
|
+
const s = ctx.currentSub
|
|
17
|
+
if (s) { node.subs.add(s); s.deps.add(node) }
|
|
18
|
+
}
|
|
19
|
+
function notify(node: Node) {
|
|
20
|
+
for (const s of Array.from(node.subs)) s.mark()
|
|
21
|
+
}
|
|
22
|
+
function flush() {
|
|
23
|
+
if (ctx.batchDepth > 0) return
|
|
24
|
+
const pending = ctx.pending
|
|
25
|
+
while (pending.size) {
|
|
26
|
+
const batch = Array.from(pending); pending.clear()
|
|
27
|
+
for (const e of batch) if (!e.disposed) e.run()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Signal<T> { (): T; set(next: T | ((prev: T) => T)): void; readonly peek: () => T; readonly [SIGNAL]: true }
|
|
32
|
+
export interface Computed<T> { (): T; readonly peek: () => T; readonly [COMPUTED]: true }
|
|
33
|
+
export type Dispose = () => void
|
|
34
|
+
|
|
35
|
+
export function signal<T>(initial: T): Signal<T> {
|
|
36
|
+
let value = initial
|
|
37
|
+
const node: Node = { subs: new Set() }
|
|
38
|
+
const read = (() => { track(node); return value }) as Signal<T>
|
|
39
|
+
read.set = (next) => {
|
|
40
|
+
const v = typeof next === 'function' ? (next as (p: T) => T)(value) : next
|
|
41
|
+
if (Object.is(v, value)) return
|
|
42
|
+
value = v
|
|
43
|
+
notify(node)
|
|
44
|
+
flush()
|
|
45
|
+
}
|
|
46
|
+
;(read as { peek: () => T }).peek = () => value
|
|
47
|
+
;(read as { [SIGNAL]: true })[SIGNAL] = true
|
|
48
|
+
return read
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function computed<T>(fn: () => T): Computed<T> {
|
|
52
|
+
let value: T; let dirty = true
|
|
53
|
+
const node: Node = { subs: new Set() }
|
|
54
|
+
const sub: Subscriber = { deps: new Set(), mark() { if (!dirty) { dirty = true; notify(node) } } }
|
|
55
|
+
const recompute = () => {
|
|
56
|
+
for (const d of sub.deps) d.subs.delete(sub); sub.deps.clear()
|
|
57
|
+
const prev = ctx.currentSub; ctx.currentSub = sub
|
|
58
|
+
try { value = fn() } finally { ctx.currentSub = prev }
|
|
59
|
+
dirty = false
|
|
60
|
+
}
|
|
61
|
+
const read = (() => { track(node); if (dirty) recompute(); return value }) as Computed<T>
|
|
62
|
+
;(read as { peek: () => T }).peek = () => { if (dirty) recompute(); return value }
|
|
63
|
+
;(read as { [COMPUTED]: true })[COMPUTED] = true
|
|
64
|
+
return read
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Cleanups run untracked: a read inside one must not subscribe whichever effect is running around it.
|
|
68
|
+
function runCleanup(c: () => void) { const prev = ctx.currentSub; ctx.currentSub = null; try { c() } finally { ctx.currentSub = prev } }
|
|
69
|
+
|
|
70
|
+
class EffectImpl implements Subscriber {
|
|
71
|
+
deps = new Set<Node>(); disposed = false; cleanup: void | (() => void) = undefined
|
|
72
|
+
constructor(private fn: () => void | (() => void)) {}
|
|
73
|
+
mark() { ctx.pending.add(this) }
|
|
74
|
+
run() {
|
|
75
|
+
if (this.disposed) return
|
|
76
|
+
if (this.cleanup) { const c = this.cleanup; this.cleanup = undefined; runCleanup(c) }
|
|
77
|
+
for (const d of this.deps) d.subs.delete(this); this.deps.clear()
|
|
78
|
+
const prev = ctx.currentSub; ctx.currentSub = this
|
|
79
|
+
try { this.cleanup = this.fn() } finally { ctx.currentSub = prev }
|
|
80
|
+
}
|
|
81
|
+
dispose() {
|
|
82
|
+
if (this.disposed) return
|
|
83
|
+
this.disposed = true; ctx.pending.delete(this)
|
|
84
|
+
for (const d of this.deps) d.subs.delete(this); this.deps.clear()
|
|
85
|
+
if (this.cleanup) { const c = this.cleanup; this.cleanup = undefined; runCleanup(c) }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function effect(fn: () => void | (() => void)): Dispose {
|
|
90
|
+
const e = new EffectImpl(fn)
|
|
91
|
+
try { e.run() } catch (x) { e.dispose(); throw x } // F98: a first run that throws must not stay subscribed
|
|
92
|
+
return () => e.dispose()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function batch(fn: () => void): void {
|
|
96
|
+
ctx.batchDepth++
|
|
97
|
+
try { fn() } finally { ctx.batchDepth--; flush() }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function untracked<T>(fn: () => T): T {
|
|
101
|
+
const prev = ctx.currentSub; ctx.currentSub = null
|
|
102
|
+
try { return fn() } finally { ctx.currentSub = prev }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isSignal(x: unknown): x is Signal<unknown> { return typeof x === 'function' && (x as { [SIGNAL]?: true })[SIGNAL] === true }
|
|
106
|
+
export function isComputed(x: unknown): x is Computed<unknown> { return typeof x === 'function' && (x as { [COMPUTED]?: true })[COMPUTED] === true }
|
|
107
|
+
/** Signal or computed: what directive paths unwrap. Internal (not in D1). */
|
|
108
|
+
export function isReactive(x: unknown): x is Signal<unknown> | Computed<unknown> {
|
|
109
|
+
return typeof x === 'function' && ((x as { [SIGNAL]?: true })[SIGNAL] === true || (x as { [COMPUTED]?: true })[COMPUTED] === true)
|
|
110
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// defineStore (M3-F D1): the 0.1.x `brustjs/store` handle on the shared reactive core and the
|
|
2
|
+
// globalThis registry. Resolution (browser registry / server resolver) lives in ./registry.
|
|
3
|
+
import { type Computed, type Signal, isSignal } from '../core-api'
|
|
4
|
+
import { type StoreInstanceRecord, computeSnapshot, resolveRecord } from './registry'
|
|
5
|
+
|
|
6
|
+
// Unwrap a Signal/Computed to its value type; `never` if it is neither. Uses `infer` (not
|
|
7
|
+
// `extends Signal<unknown>`): `Signal<T>.set(next: T)` is contravariant in T, so
|
|
8
|
+
// `Signal<TeamMember[]>` is NOT assignable to `Signal<unknown>` — a concrete-`unknown` check wrongly
|
|
9
|
+
// drops every typed signal key from the snapshot.
|
|
10
|
+
type StoreValue<X> = X extends Signal<infer T> ? T : X extends Computed<infer T> ? T : never
|
|
11
|
+
|
|
12
|
+
// Snapshot = plain-value view of a store: Signal<T>/Computed<T> → T, plain values kept as-is, action
|
|
13
|
+
// functions dropped. The `[…] extends [never]` tuple wrap stops `never` from distributing.
|
|
14
|
+
export type Snapshot<S> = {
|
|
15
|
+
[K in keyof S as [StoreValue<S[K]>] extends [never]
|
|
16
|
+
? S[K] extends (...a: never[]) => unknown
|
|
17
|
+
? never
|
|
18
|
+
: K
|
|
19
|
+
: K]: [StoreValue<S[K]>] extends [never] ? S[K] : StoreValue<S[K]>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// C8. `then` is reserved so a store with a signal named `then` can't make the proxy an accidental
|
|
23
|
+
// thenable (await/Promise.resolve(handle) misbehaving).
|
|
24
|
+
const RESERVED = new Set(['name', 'subscribe', 'snapshot', 'serialize', 'hydrate', 'then'])
|
|
25
|
+
|
|
26
|
+
export interface StoreHandle<S extends object> {
|
|
27
|
+
(): S
|
|
28
|
+
readonly name: string
|
|
29
|
+
subscribe(cb: () => void): () => void
|
|
30
|
+
snapshot(): Snapshot<S>
|
|
31
|
+
serialize(): Record<string, unknown>
|
|
32
|
+
hydrate(state: Record<string, unknown>): void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Each instance's signals must notify the store's subscriber set on write. After factory(), for every
|
|
36
|
+
// signal property we wrap .set to also bump the version and fire the store-level subscribers (computeds
|
|
37
|
+
// downstream recompute lazily; React re-reads snapshot).
|
|
38
|
+
function bridgeSubscribers(instance: Record<string, unknown>, subs: Set<() => void>, version: { n: number }): void {
|
|
39
|
+
for (const key of Object.keys(instance)) {
|
|
40
|
+
const sig = instance[key]
|
|
41
|
+
if (!isSignal(sig)) continue
|
|
42
|
+
const origSet = sig.set.bind(sig)
|
|
43
|
+
sig.set = (next) => {
|
|
44
|
+
// peek, not sig(): 0.1.x read with sig(), which subscribes a running effect to the written signal.
|
|
45
|
+
const before = sig.peek()
|
|
46
|
+
origSet(next as never)
|
|
47
|
+
if (Object.is(before, sig.peek())) return
|
|
48
|
+
version.n += 1
|
|
49
|
+
for (const cb of Array.from(subs)) cb()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function hydrateRecord(rec: StoreInstanceRecord, state: Record<string, unknown>): void {
|
|
55
|
+
const inst = rec.instance as Record<string, unknown>
|
|
56
|
+
for (const key of Object.keys(state)) {
|
|
57
|
+
const v = inst[key]
|
|
58
|
+
if (isSignal(v)) v.set(state[key])
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function defineStore<S extends object>(name: string, factory: () => S): StoreHandle<S> & S {
|
|
63
|
+
const create = (server: boolean): StoreInstanceRecord => {
|
|
64
|
+
const instance = factory() as Record<string, unknown>
|
|
65
|
+
const rec: StoreInstanceRecord = { instance, subs: new Set(), version: { n: 0 }, snap: null }
|
|
66
|
+
bridgeSubscribers(instance, rec.subs, rec.version)
|
|
67
|
+
if (server) rec.handle = handle // collectSnapshot() serializes through it
|
|
68
|
+
return rec
|
|
69
|
+
}
|
|
70
|
+
const resolve = () => resolveRecord(name, create, hydrateRecord)
|
|
71
|
+
|
|
72
|
+
const handle = (() => resolve().instance) as StoreHandle<S> & S
|
|
73
|
+
Object.defineProperty(handle, 'name', { value: name })
|
|
74
|
+
handle.subscribe = (cb) => {
|
|
75
|
+
const rec = resolve()
|
|
76
|
+
rec.subs.add(cb)
|
|
77
|
+
return () => {
|
|
78
|
+
rec.subs.delete(cb)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
handle.snapshot = () => {
|
|
82
|
+
const rec = resolve()
|
|
83
|
+
return computeSnapshot(rec) as Snapshot<S>
|
|
84
|
+
}
|
|
85
|
+
// The snapshot the SSR HTML was rendered from (browser); React islands hydrate against it. Absent on the server.
|
|
86
|
+
;(handle as unknown as Record<symbol, unknown>)[Symbol.for('brust.ssrSnapshot')] = () => resolve().ssr ?? handle.snapshot()
|
|
87
|
+
handle.serialize = () => {
|
|
88
|
+
const out: Record<string, unknown> = {}
|
|
89
|
+
for (const [k, s] of Object.entries(resolve().instance)) {
|
|
90
|
+
if (!isSignal(s)) continue
|
|
91
|
+
const v = s.peek()
|
|
92
|
+
if (v === undefined || typeof v === 'function') continue // spec §4
|
|
93
|
+
if (typeof v === 'bigint') throw new TypeError(`store '${name}': '${k}' holds a BigInt, which JSON cannot carry`)
|
|
94
|
+
out[k] = v
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
handle.hydrate = (state) => hydrateRecord(resolve(), state)
|
|
99
|
+
|
|
100
|
+
return new Proxy(handle, {
|
|
101
|
+
get(target, prop, recv) {
|
|
102
|
+
if (typeof prop === 'symbol' || RESERVED.has(prop)) return Reflect.get(target, prop, recv)
|
|
103
|
+
return (resolve().instance as Record<string, unknown>)[prop]
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The D1 surface, nothing else (aliasable to the runtime URL, C6)
|
|
2
|
+
export { signal, computed, effect, batch, untracked, isSignal, isComputed } from '../signal'
|
|
3
|
+
export type { Signal, Computed, Dispose } from '../signal'
|
|
4
|
+
export { defineStore } from './define-store'
|
|
5
|
+
export type { StoreHandle, Snapshot } from './define-store'
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Server and later-wave plumbing; never documented, never aliased to the runtime URL.
|
|
2
|
+
export { __setServerResolver, applyStoreSnapshot } from './registry'
|
|
3
|
+
export type { ServerResolver, StoreInstanceRecord } from './registry'
|
|
4
|
+
export { hydrateRecord } from './define-store'
|
|
5
|
+
export { toScriptJson, storeScriptTag, parseStoreScript } from './serialize'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// React bridge (M3-F D8). React is a PEER of @brust/runtime-dom, imported only here; src/index.ts never
|
|
2
|
+
// reaches this file, so the runtime bundle stays react-free (scripts/build.ts). Type-only import from
|
|
3
|
+
// the core: bundling this file into an island adds no second core (ruling R2).
|
|
4
|
+
import { useSyncExternalStore } from 'react'
|
|
5
|
+
import type { Snapshot, StoreHandle } from './define-store'
|
|
6
|
+
|
|
7
|
+
export function useStore<S extends object>(store: StoreHandle<S> & S): Snapshot<S> {
|
|
8
|
+
// An ssr island can render (renderToString) on the server OUTSIDE a request store scope; there
|
|
9
|
+
// store.snapshot() throws. Degrade to an empty snapshot for the server render so the island SSRs its
|
|
10
|
+
// factory-default markup; the client resolves the real registry instance and hydrates.
|
|
11
|
+
const serverSnapshot = (): Snapshot<S> => {
|
|
12
|
+
try {
|
|
13
|
+
// Hydration must see the markup the server rendered, not a value a native mount effect wrote since (#418).
|
|
14
|
+
const ssr = (store as unknown as Record<symbol, (() => Snapshot<S>) | undefined>)[Symbol.for('brust.ssrSnapshot')]
|
|
15
|
+
return ssr ? ssr() : store.snapshot()
|
|
16
|
+
} catch {
|
|
17
|
+
return {} as Snapshot<S>
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return useSyncExternalStore(store.subscribe, store.snapshot, serverSnapshot)
|
|
21
|
+
}
|