@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.
@@ -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
+ }
@@ -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
+ }