@brust/runtime-dom 2.0.0-alpha.3 → 2.0.0-alpha.4
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 +5 -2
- package/package.json +1 -1
- package/src/directives/bind.ts +2 -1
- package/src/directives/common.ts +4 -0
- package/src/directives/for.ts +4 -1
- package/src/directives/html.ts +21 -0
- package/src/directives/index.ts +1 -0
- package/src/directives/register.ts +1 -0
- package/src/instance.ts +40 -1
- package/src/island.ts +8 -2
- package/src/mount.ts +3 -1
- package/src/signal.ts +1 -1
- package/src/store/define-store.ts +5 -10
- package/src/store/react.ts +3 -1
- package/src/store/registry.ts +19 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
React-free browser runtime that binds a compiled client chunk (`defineBehavior`) to server-rendered HTML
|
|
4
4
|
through `x-*` attributes. **This README is the output format the M1c client backend prints against**; every
|
|
5
|
-
behaviour below is pinned by a test in `test/`. Zero dependencies, eval-free, bundle <
|
|
5
|
+
behaviour below is pinned by a test in `test/`. Zero dependencies, eval-free, bundle < 18 KB minified
|
|
6
6
|
(`bun scripts/build.ts`).
|
|
7
7
|
|
|
8
8
|
## Public surface
|
|
@@ -33,12 +33,15 @@ Anything else (`x + 1`, `fn()`) is not parsed: a warning is logged once and the
|
|
|
33
33
|
|
|
34
34
|
## Attributes
|
|
35
35
|
|
|
36
|
+
Directives come only from compiler output: the compiler never prints an `x-*` attribute written in component JSX (warning `reserved-attr`), and the runtime does not bind inside `dangerouslySetInnerHTML` markup (`x-html` / `x-raw`) or a React island (`<brust-island>`).
|
|
37
|
+
|
|
36
38
|
- **`x-data="name"`**: one instance per host. Nested hosts are bound by their own instance, not the parent's walker.
|
|
37
39
|
- **`x-props='{"json":1}'`**: seeds `ctx.props`. Invalid JSON warns and seeds `{}`.
|
|
38
40
|
- **`x-props-bind="member[:binding]"`**: on a child host, rebinds the child's `props` signal reactively to the nearest ancestor instance's member
|
|
39
41
|
(an object, typically a `computed`; function values pass through). Inside `x-for` the row's scope supplies the bindings. If the parent is not mounted yet
|
|
40
42
|
(chunk still loading) the child links up when the parent mounts. No ancestor: warns, keeps the `x-props` seed.
|
|
41
43
|
- **`x-text="value"`**: sets `textContent`; `null`/`undefined` → `''`.
|
|
44
|
+
- **`x-html="value"`**: sets `innerHTML` (raw markup, unescaped); `null`/`undefined` → `''`. Emitted only by the compiler, for a `dangerouslySetInnerHTML` whose value changes after first paint; the author sanitises the markup (same contract as React). The first-paint check compares parsed markup, so equivalent spellings do not warn.
|
|
42
45
|
- **`x-show="value"`**: `style.display = ''` when truthy, `'none'` otherwise.
|
|
43
46
|
- **`x-if="value"`**: the element is replaced by a `<!--x-if-->` comment anchor; a clone is inserted after it while truthy and removed otherwise.
|
|
44
47
|
Nested `x-data` hosts in the clone mount/dispose with it. Server HTML omits the element when false.
|
|
@@ -55,7 +58,7 @@ Anything else (`x + 1`, `fn()`) is not parsed: a warning is logged once and the
|
|
|
55
58
|
|
|
56
59
|
## First-paint rule
|
|
57
60
|
|
|
58
|
-
Nothing re-renders on mount: server HTML is the initial state. A binder writes the DOM only when its effect re-runs after a change, **or** when the initial `x-text` value differs from the server text,
|
|
61
|
+
Nothing re-renders on mount: server HTML is the initial state. A binder writes the DOM only when its effect re-runs after a change, **or** when the initial `x-text` / `x-html` value differs from the server text,
|
|
59
62
|
in which case it corrects it and warns once (`first-paint mismatch …`): a mismatch is a compiler bug the runtime must not hide. Elements created after mount (new rows, `x-if` clones) never trip this check.
|
|
60
63
|
|
|
61
64
|
## Gates
|
package/package.json
CHANGED
package/src/directives/bind.ts
CHANGED
|
@@ -16,7 +16,8 @@ BINDERS.push({
|
|
|
16
16
|
inst.effect(() => {
|
|
17
17
|
const r = read(inst, p, scope, attr); if (!r.ok) return
|
|
18
18
|
const v = r.value
|
|
19
|
-
|
|
19
|
+
// setAttribute: `className` is a getter-only SVGAnimatedString on SVG elements (F96a).
|
|
20
|
+
if (name === 'class') { el.setAttribute('class', v == null ? '' : String(v)); return }
|
|
20
21
|
if (name === 'value' && 'value' in el) { (el as HTMLInputElement).value = v == null ? '' : String(v); return }
|
|
21
22
|
if (BOOLEAN.has(name)) {
|
|
22
23
|
const on = Boolean(v)
|
package/src/directives/common.ts
CHANGED
|
@@ -2,6 +2,10 @@ import { parseValue, resolve, resolveMember, type ParsedValue, type Scope } from
|
|
|
2
2
|
import { warnOnce } from '../warn'
|
|
3
3
|
import type { Instance } from '../instance'
|
|
4
4
|
|
|
5
|
+
/** Markup inside a `dangerouslySetInnerHTML` element (`x-html` / `x-raw`, compiler-emitted) or a React island is inert: nothing under it is bound, mounted or hydrated. */
|
|
6
|
+
export const RAW_SEL = '[x-html],[x-raw],brust-island'
|
|
7
|
+
export const insideRaw = (el: Element): boolean => !!el.parentElement?.closest(RAW_SEL)
|
|
8
|
+
|
|
5
9
|
export function parsed(inst: Instance, attr: string, raw: string): ParsedValue | null {
|
|
6
10
|
const p = parseValue(raw)
|
|
7
11
|
if (!p) warnOnce(`p:${inst.name}:${attr}:${raw}`, `${attr}="${raw}" in ${inst.name}: not a member path`)
|
package/src/directives/for.ts
CHANGED
|
@@ -44,7 +44,10 @@ BINDERS.push({
|
|
|
44
44
|
const seen = new Set<unknown>()
|
|
45
45
|
list.forEach((item, i) => {
|
|
46
46
|
let key = (keyFn as (x: unknown) => unknown)(item)
|
|
47
|
-
|
|
47
|
+
// `undefined` is a row whose element arm is null (`it.link && <li key={it.link.id}/>`): it
|
|
48
|
+
// carries no key in React either, so it is positional and never a duplicate.
|
|
49
|
+
if (key === undefined) key = `__nil_${i}`
|
|
50
|
+
else if (seen.has(key)) { warnOnce(`fd:${inst.name}:${raw}`, `x-for in ${inst.name}: duplicate key ${JSON.stringify(key)}`); key = `__dup_${i}` }
|
|
48
51
|
seen.add(key)
|
|
49
52
|
const pool = rows.get(key)
|
|
50
53
|
let node = pool?.shift()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { BINDERS } from './index'
|
|
2
|
+
import { parsed, read, mismatch } from './common'
|
|
3
|
+
/** Both sides through the HTML parser (inert `<template>`), so `<br/>` vs `<br>` or entity spelling is not a mismatch. */
|
|
4
|
+
function sameMarkup(el: Element, next: string): boolean {
|
|
5
|
+
const t = document.createElement('template'); t.innerHTML = next
|
|
6
|
+
return t.innerHTML === el.innerHTML
|
|
7
|
+
}
|
|
8
|
+
// `x-html` is emitted only by the compiler, for a `dangerouslySetInnerHTML` whose value changes after first paint.
|
|
9
|
+
BINDERS.push({
|
|
10
|
+
match: (a) => a === 'x-html',
|
|
11
|
+
bind: ({ inst, el, attr, raw, scope }) => {
|
|
12
|
+
const p = parsed(inst, attr, raw); if (!p) return
|
|
13
|
+
let first = true
|
|
14
|
+
inst.effect(() => {
|
|
15
|
+
const r = read(inst, p, scope, attr); if (!r.ok) return
|
|
16
|
+
const next = r.value == null ? '' : String(r.value)
|
|
17
|
+
if (first) { first = false; if (!sameMarkup(el, next)) { mismatch(inst, attr, el, el.innerHTML, next); el.innerHTML = next }; return }
|
|
18
|
+
el.innerHTML = next
|
|
19
|
+
})
|
|
20
|
+
},
|
|
21
|
+
})
|
package/src/directives/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export function bindHost(inst: Instance, el: Element, scope: Scope): void {
|
|
|
12
12
|
walkChildren(inst, el, scope)
|
|
13
13
|
}
|
|
14
14
|
export function walkChildren(inst: Instance, el: Element, scope: Scope): void {
|
|
15
|
+
if (el.hasAttribute('x-html') || el.hasAttribute('x-raw') || el.localName === 'brust-island') return // raw markup / React island: inert
|
|
15
16
|
for (const child of Array.from(el.children)) {
|
|
16
17
|
if ('__scope' in child) continue // x-for row: already bound with its own scope
|
|
17
18
|
if (child.hasAttribute('x-for')) { bindElement(inst, child, scope); continue } // x-for owns the element (even when it is also a host)
|
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>>
|
|
@@ -11,15 +21,42 @@ export class Instance {
|
|
|
11
21
|
disposed = false
|
|
12
22
|
/** false until the initial bind pass finishes; the first-paint tripwire only applies before then. */
|
|
13
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 = []
|
|
14
26
|
|
|
15
27
|
constructor(public host: HTMLElement, public name: string, public parent: Instance | null, initialProps: Record<string, unknown>) {
|
|
16
28
|
this.props = signal(initialProps)
|
|
17
29
|
parent?.children.add(this)
|
|
30
|
+
if (typeof __BRUST_DEV__ !== 'undefined' && __BRUST_DEV__) devTrack(this, true)
|
|
18
31
|
}
|
|
19
32
|
|
|
20
33
|
effect(fn: () => void | (() => void)): Dispose {
|
|
21
34
|
const d = coreEffect(fn); this.disposers.push(d); return d
|
|
22
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
|
+
}
|
|
23
60
|
onCleanup(fn: () => void): void { this.cleanups.push(fn) }
|
|
24
61
|
ref<E extends Element = HTMLElement>(name: string): { current: E | null } {
|
|
25
62
|
let r = this.refs.get(name)
|
|
@@ -27,7 +64,7 @@ export class Instance {
|
|
|
27
64
|
return r as { current: E | null }
|
|
28
65
|
}
|
|
29
66
|
ctx(): BehaviorCtx {
|
|
30
|
-
return { el: this.host, props: this.props, effect: (fn) => this.
|
|
67
|
+
return { el: this.host, props: this.props, effect: (fn) => this.componentEffect(fn), onCleanup: (fn) => this.onCleanup(fn), ref: (n) => this.ref(n) }
|
|
31
68
|
}
|
|
32
69
|
init(factory: BehaviorFactory): void {
|
|
33
70
|
const m = factory(this.ctx())
|
|
@@ -37,6 +74,8 @@ export class Instance {
|
|
|
37
74
|
dispose(): void {
|
|
38
75
|
if (this.disposed) return
|
|
39
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
|
|
40
79
|
for (const c of Array.from(this.children)) c.dispose()
|
|
41
80
|
this.parent?.children.delete(this)
|
|
42
81
|
for (const d of this.disposers.splice(0)) d()
|
package/src/island.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
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
4
|
import { warnOnce } from './warn'
|
|
5
|
+
import { insideRaw } from './directives/common'
|
|
5
6
|
|
|
6
7
|
export type IslandHydrate = (host: HTMLElement, props: Record<string, unknown>) => void
|
|
7
8
|
type QueueEntry = [string, IslandHydrate]
|
|
@@ -33,9 +34,13 @@ export function whenIsland(id: string, cb: (h: IslandHydrate) => void): void {
|
|
|
33
34
|
|
|
34
35
|
// ---- scheduler ----------------------------------------------------------------------------
|
|
35
36
|
type Idle = (cb: () => void) => void
|
|
37
|
+
/** Longest an island waits for an idle period. Without a timeout `requestIdleCallback` never fires while
|
|
38
|
+
* the main thread has no idle gap (a loaded runner, a throttled or hidden tab), and an island whose host
|
|
39
|
+
* is already painted would stay server HTML indefinitely. */
|
|
40
|
+
export const IDLE_TIMEOUT_MS = 250
|
|
36
41
|
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)
|
|
42
|
+
const ric = (globalThis as { requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number }).requestIdleCallback
|
|
43
|
+
if (typeof ric === 'function') ric(cb, { timeout: IDLE_TIMEOUT_MS }); else setTimeout(cb, 1)
|
|
39
44
|
}
|
|
40
45
|
let idle: Idle = defaultIdle
|
|
41
46
|
export function _setIdle(fn: Idle | null): void { idle = fn ?? defaultIdle }
|
|
@@ -57,6 +62,7 @@ function hydrateHost(host: HTMLElement, id: string, hydrate: IslandHydrate): voi
|
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
function scheduleHost(host: HTMLElement): void {
|
|
65
|
+
if (insideRaw(host)) return // inside dangerouslySetInnerHTML markup: never hydrated
|
|
60
66
|
if (pending.has(host) || host.hasAttribute('data-hydrated')) return
|
|
61
67
|
const id = host.getAttribute('data-id')!
|
|
62
68
|
pending.add(host)
|
package/src/mount.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Instance, hooks, instances, 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
7
|
import { bindPropsFromParent } from './props-bind'
|
|
7
8
|
import { scheduleIslands, cancelIslands } from './island'
|
|
@@ -27,6 +28,7 @@ function mountHost(host: HTMLElement): void {
|
|
|
27
28
|
try { inst.init(factory) } catch (e) { console.error(`[brust] init threw: ${name}`, e); inst.dispose(); instances.delete(host); return }
|
|
28
29
|
bindHost(inst, host, {})
|
|
29
30
|
inst.booted = true
|
|
31
|
+
if (!inst.start()) return // F97: refs and handlers are bound; now the component's effects
|
|
30
32
|
bindPropsFromParent(inst)
|
|
31
33
|
// Children that mounted before this instance existed (parent chunk arrived late) link up now.
|
|
32
34
|
host.querySelectorAll<HTMLElement>('[x-data]').forEach((h) => {
|
|
@@ -41,7 +43,7 @@ function mountTree(root: ParentNode): void {
|
|
|
41
43
|
if (root instanceof HTMLElement && root.hasAttribute('x-data')) hosts.push(root)
|
|
42
44
|
root.querySelectorAll<HTMLElement>('[x-data]').forEach((h) => hosts.push(h))
|
|
43
45
|
// document order = parents before children, so nearestInstance finds a mounted parent
|
|
44
|
-
for (const h of hosts) mountHost(h)
|
|
46
|
+
for (const h of hosts) if (!insideRaw(h)) mountHost(h)
|
|
45
47
|
scheduleIslands(root)
|
|
46
48
|
}
|
|
47
49
|
hooks.mountTree = (root) => untracked(() => mountTree(root))
|
package/src/signal.ts
CHANGED
|
@@ -88,7 +88,7 @@ class EffectImpl implements Subscriber {
|
|
|
88
88
|
|
|
89
89
|
export function effect(fn: () => void | (() => void)): Dispose {
|
|
90
90
|
const e = new EffectImpl(fn)
|
|
91
|
-
e.run()
|
|
91
|
+
try { e.run() } catch (x) { e.dispose(); throw x } // F98: a first run that throws must not stay subscribed
|
|
92
92
|
return () => e.dispose()
|
|
93
93
|
}
|
|
94
94
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// defineStore (M3-F D1): the 0.1.x `brustjs/store` handle on the shared reactive core and the
|
|
2
2
|
// globalThis registry. Resolution (browser registry / server resolver) lives in ./registry.
|
|
3
|
-
import { type Computed, type Signal,
|
|
4
|
-
import { type StoreInstanceRecord, resolveRecord } from './registry'
|
|
3
|
+
import { type Computed, type Signal, isSignal } from '../signal'
|
|
4
|
+
import { type StoreInstanceRecord, computeSnapshot, resolveRecord } from './registry'
|
|
5
5
|
|
|
6
6
|
// Unwrap a Signal/Computed to its value type; `never` if it is neither. Uses `infer` (not
|
|
7
7
|
// `extends Signal<unknown>`): `Signal<T>.set(next: T)` is contravariant in T, so
|
|
@@ -80,15 +80,10 @@ export function defineStore<S extends object>(name: string, factory: () => S): S
|
|
|
80
80
|
}
|
|
81
81
|
handle.snapshot = () => {
|
|
82
82
|
const rec = resolve()
|
|
83
|
-
|
|
84
|
-
const out: Record<string, unknown> = {}
|
|
85
|
-
for (const key of Object.keys(rec.instance)) {
|
|
86
|
-
const v = (rec.instance as Record<string, unknown>)[key]
|
|
87
|
-
if (isSignal(v) || isComputed(v)) out[key] = v()
|
|
88
|
-
}
|
|
89
|
-
rec.snap = { value: out, version: rec.version.n }
|
|
90
|
-
return out as Snapshot<S>
|
|
83
|
+
return computeSnapshot(rec) as Snapshot<S>
|
|
91
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()
|
|
92
87
|
handle.serialize = () => {
|
|
93
88
|
const out: Record<string, unknown> = {}
|
|
94
89
|
for (const [k, s] of Object.entries(resolve().instance)) {
|
package/src/store/react.ts
CHANGED
|
@@ -10,7 +10,9 @@ export function useStore<S extends object>(store: StoreHandle<S> & S): Snapshot<
|
|
|
10
10
|
// factory-default markup; the client resolves the real registry instance and hydrates.
|
|
11
11
|
const serverSnapshot = (): Snapshot<S> => {
|
|
12
12
|
try {
|
|
13
|
-
|
|
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()
|
|
14
16
|
} catch {
|
|
15
17
|
return {} as Snapshot<S>
|
|
16
18
|
}
|
package/src/store/registry.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Browser store registry + the server-resolver hook (M3-F D3/D6/C7).
|
|
2
2
|
// The registry lives on globalThis (C7) so two copies of this module share one instance per name.
|
|
3
|
+
import { isComputed, isSignal, untracked } from '../signal'
|
|
3
4
|
import { parseStoreScript } from './serialize'
|
|
4
5
|
|
|
5
6
|
// The resolved per-scope store record, shared by the browser registry and the server per-request
|
|
@@ -11,9 +12,23 @@ export interface StoreInstanceRecord {
|
|
|
11
12
|
subs: Set<() => void>
|
|
12
13
|
version: { n: number }
|
|
13
14
|
snap: { value: Record<string, unknown>; version: number } | null
|
|
15
|
+
/** Browser only: the snapshot the SSR HTML was rendered from (script seed applied, nothing written since). Islands hydrate against it (G13). */
|
|
16
|
+
ssr?: Record<string, unknown>
|
|
14
17
|
handle?: { serialize(): Record<string, unknown> }
|
|
15
18
|
}
|
|
16
19
|
|
|
20
|
+
/** Plain-value view of a record (signals and computeds read, actions dropped), memoized in `rec.snap` per version. */
|
|
21
|
+
export function computeSnapshot(rec: StoreInstanceRecord): Record<string, unknown> {
|
|
22
|
+
if (rec.snap && rec.snap.version === rec.version.n) return rec.snap.value
|
|
23
|
+
const out: Record<string, unknown> = {}
|
|
24
|
+
for (const key of Object.keys(rec.instance)) {
|
|
25
|
+
const v = (rec.instance as Record<string, unknown>)[key]
|
|
26
|
+
if (isSignal(v) || isComputed(v)) out[key] = v()
|
|
27
|
+
}
|
|
28
|
+
rec.snap = { value: out, version: rec.version.n }
|
|
29
|
+
return out
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
export type ServerResolver = (name: string, create: () => StoreInstanceRecord) => StoreInstanceRecord
|
|
18
33
|
|
|
19
34
|
const STORES = Symbol.for('brust.stores') // C7
|
|
@@ -58,9 +73,11 @@ export function resolveRecord(
|
|
|
58
73
|
const reg = browserRegistry()
|
|
59
74
|
let rec = reg[name]
|
|
60
75
|
if (!rec) {
|
|
61
|
-
rec = reg[name] = create(false)
|
|
76
|
+
const made = (rec = reg[name] = create(false))
|
|
62
77
|
const el = seedScript(name)
|
|
63
78
|
if (el) seed(rec, parseStoreScript(el))
|
|
79
|
+
// Identity-stable: the same object stays `rec.snap` until the first write.
|
|
80
|
+
rec.ssr = untracked(() => computeSnapshot(made)) // a first access inside an effect must not subscribe it
|
|
64
81
|
}
|
|
65
82
|
return rec
|
|
66
83
|
}
|
|
@@ -77,6 +94,7 @@ export function applyStoreSnapshot(snap: Record<string, Record<string, unknown>>
|
|
|
77
94
|
const v = inst[key]
|
|
78
95
|
if (v && typeof v.set === 'function') v.set(state[key])
|
|
79
96
|
}
|
|
97
|
+
entry.ssr = untracked(() => computeSnapshot(entry)) // R11.1: the payload is the new server truth for islands hydrating later
|
|
80
98
|
}
|
|
81
99
|
}
|
|
82
100
|
|