@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 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 < 17 KB minified
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brust/runtime-dom",
3
- "version": "2.0.0-alpha.3",
3
+ "version": "2.0.0-alpha.4",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -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
- if (name === 'class') { (el as HTMLElement).className = v == null ? '' : String(v); return }
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)
@@ -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`)
@@ -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
- if (seen.has(key)) { warnOnce(`fd:${inst.name}:${raw}`, `x-for in ${inst.name}: duplicate key ${JSON.stringify(key)}`); key = `__dup_${i}` }
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
+ })
@@ -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)
@@ -1,4 +1,5 @@
1
1
  import './text'
2
+ import './html'
2
3
  import './show'
3
4
  import './bind'
4
5
  import './on'
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.effect(fn), onCleanup: (fn) => this.onCleanup(fn), ref: (n) => this.ref(n) }
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, isComputed, isSignal } from '../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
- if (rec.snap && rec.snap.version === rec.version.n) return rec.snap.value as Snapshot<S>
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)) {
@@ -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
- return store.snapshot()
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
  }
@@ -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