@brust/runtime-dom 2.0.0-alpha.1 → 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 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
@@ -92,3 +95,13 @@ tracking state lives on `globalThis[Symbol.for('brust.reactive.ctx')]`).
92
95
  React-free.
93
96
  - `@brust/runtime-dom/store/internal`: the server-resolver hook and serialization helpers the Bun worker uses.
94
97
  Not part of the public surface.
98
+
99
+ ## Feature modules (L33, F139)
100
+
101
+ The runtime is a core plus feature modules, so a page loads only what its components use and the client JS does not grow as the compiler learns more of React.
102
+
103
+ - `@brust/runtime-dom/core` (`src/core.ts`): signals, `defineBehavior`/`setChunkLoader`, `mount`/`unmount`, the binder registry (`BINDERS`) and the internals features need (`src/core-api.ts`). It knows no directive by name.
104
+ - `@brust/runtime-dom/features/<name>` (`src/features/*.ts`): `basics` (`x-text`, `x-show`, `x-bind-*`, `x-on-*`, `x-ref`), `if`, `for`, `model`, `html`, `island` (React island hydration glue). A feature registers itself with the core when imported and imports the core only through `core-api`; in the browser build `core-api` is the shared core file, so there is one signal graph and one binder registry per page.
105
+ - `@brust/runtime-dom` (`src/index.ts`) is core + every feature, for tests and non-split consumers. The browser build never uses it.
106
+
107
+ **The rule for a new feature.** The micro-directives nearly every interactive page uses ship together in `basics`. Anything else gets its OWN module when it is at least about 1 KB gzip OR used by a minority of pages: `store`, `island`, `if`/`for`/`model`/`html` as measured, and every future feature (context, portal, focus trap, ...). Do not add a feature to `basics` or the core to save a file; add it as a module the compiler requests only for routes that use it. The CI byte budget (core + `basics`) holds the line.
package/package.json CHANGED
@@ -1,12 +1,20 @@
1
1
  {
2
2
  "name": "@brust/runtime-dom",
3
- "version": "2.0.0-alpha.1",
3
+ "version": "2.0.0-alpha.10",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./src/index.ts",
8
8
  "default": "./src/index.ts"
9
9
  },
10
+ "./core": {
11
+ "types": "./src/core.ts",
12
+ "default": "./src/core.ts"
13
+ },
14
+ "./features/*": {
15
+ "types": "./src/features/*.ts",
16
+ "default": "./src/features/*.ts"
17
+ },
10
18
  "./store": {
11
19
  "types": "./src/store/index.ts",
12
20
  "default": "./src/store/index.ts"
@@ -49,5 +57,14 @@
49
57
  ],
50
58
  "publishConfig": {
51
59
  "access": "public"
60
+ },
61
+ "homepage": "https://brust.dev",
62
+ "repository": {
63
+ "type": "git",
64
+ "url": "git+https://github.com/AssetsArt/brust.git",
65
+ "directory": "packages/runtime-dom"
66
+ },
67
+ "bugs": {
68
+ "url": "https://github.com/AssetsArt/brust/issues"
52
69
  }
53
70
  }
@@ -0,0 +1,10 @@
1
+ // What a feature module may import from the core, and nothing else. In the browser build the core is one shared
2
+ // ESM file and every feature is a separate file that imports these names from it (bundle.ts maps this module to the
3
+ // core URL), so a feature registers on the page's single binder registry and signal graph.
4
+ export { BINDERS, bindHost, bindElement, walkChildren } from './directives/index'
5
+ export type { Binder } from './directives/index'
6
+ export { parsed, read, member, mismatch, insideRaw, RAW_SEL } from './directives/common'
7
+ export { hooks, islandHooks } from './instance'
8
+ export { warnOnce } from './warn'
9
+ export { signal, computed, effect, batch, untracked, isSignal, isComputed } from './signal'
10
+ export type { Signal, Computed, Dispose } from './signal'
package/src/core.ts ADDED
@@ -0,0 +1,8 @@
1
+ // The always-loaded core: signals, the behavior registry, mount/unmount and the binder registry. Features
2
+ // (./features/*) register their binders on import; nothing here knows a directive by name.
3
+ export { signal, computed, effect, batch, untracked, isSignal, isComputed } from './signal'
4
+ export type { Signal, Computed, Dispose } from './signal'
5
+ export { defineBehavior, setChunkLoader } from './registry'
6
+ export type { BehaviorCtx, BehaviorFactory, BehaviorInstance } from './registry'
7
+ export { mount, unmount } from './mount'
8
+ export * from './core-api'
@@ -1,6 +1,4 @@
1
- import { BINDERS } from './index'
2
- import { parsed, read } from './common'
3
- import { warnOnce } from '../warn'
1
+ import { BINDERS, parsed, read, warnOnce } from '../core-api'
4
2
 
5
3
  // Attributes that execute or load code from a string; a bound value must never reach them as-is.
6
4
  const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'poster', 'data', 'xlink:href', 'ping'])
@@ -16,7 +14,8 @@ BINDERS.push({
16
14
  inst.effect(() => {
17
15
  const r = read(inst, p, scope, attr); if (!r.ok) return
18
16
  const v = r.value
19
- if (name === 'class') { (el as HTMLElement).className = v == null ? '' : String(v); return }
17
+ // setAttribute: `className` is a getter-only SVGAnimatedString on SVG elements (F96a).
18
+ if (name === 'class') { el.setAttribute('class', v == null ? '' : String(v)); return }
20
19
  if (name === 'value' && 'value' in el) { (el as HTMLInputElement).value = v == null ? '' : String(v); return }
21
20
  if (BOOLEAN.has(name)) {
22
21
  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`)
@@ -1,8 +1,4 @@
1
- import { BINDERS, bindElement, walkChildren } from './index'
2
- import { parsed, read, member } from './common'
3
- import { warnOnce } from '../warn'
4
- import { computed, signal, type Signal } from '../signal'
5
- import { hooks } from '../instance'
1
+ import { BINDERS, bindElement, walkChildren, parsed, read, member, warnOnce, computed, signal, hooks, type Signal } from '../core-api'
6
2
 
7
3
  type RowScope = Record<string, unknown> & { __own: Signal<number> }
8
4
 
@@ -44,7 +40,10 @@ BINDERS.push({
44
40
  const seen = new Set<unknown>()
45
41
  list.forEach((item, i) => {
46
42
  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}` }
43
+ // `undefined` is a row whose element arm is null (`it.link && <li key={it.link.id}/>`): it
44
+ // carries no key in React either, so it is positional and never a duplicate.
45
+ if (key === undefined) key = `__nil_${i}`
46
+ else if (seen.has(key)) { warnOnce(`fd:${inst.name}:${raw}`, `x-for in ${inst.name}: duplicate key ${JSON.stringify(key)}`); key = `__dup_${i}` }
48
47
  seen.add(key)
49
48
  const pool = rows.get(key)
50
49
  let node = pool?.shift()
@@ -0,0 +1,20 @@
1
+ import { BINDERS, parsed, read, mismatch } from '../core-api'
2
+ /** Both sides through the HTML parser (inert `<template>`), so `<br/>` vs `<br>` or entity spelling is not a mismatch. */
3
+ function sameMarkup(el: Element, next: string): boolean {
4
+ const t = document.createElement('template'); t.innerHTML = next
5
+ return t.innerHTML === el.innerHTML
6
+ }
7
+ // `x-html` is emitted only by the compiler, for a `dangerouslySetInnerHTML` whose value changes after first paint.
8
+ BINDERS.push({
9
+ match: (a) => a === 'x-html',
10
+ bind: ({ inst, el, attr, raw, scope }) => {
11
+ const p = parsed(inst, attr, raw); if (!p) return
12
+ let first = true
13
+ inst.effect(() => {
14
+ const r = read(inst, p, scope, attr); if (!r.ok) return
15
+ const next = r.value == null ? '' : String(r.value)
16
+ if (first) { first = false; if (!sameMarkup(el, next)) { mismatch(inst, attr, el, el.innerHTML, next); el.innerHTML = next }; return }
17
+ el.innerHTML = next
18
+ })
19
+ },
20
+ })
@@ -1,6 +1,4 @@
1
- import { BINDERS, bindElement, walkChildren } from './index'
2
- import { parsed, read } from './common'
3
- import { hooks } from '../instance'
1
+ import { BINDERS, bindElement, walkChildren, parsed, read, hooks } from '../core-api'
4
2
  BINDERS.push({
5
3
  match: (a) => a === 'x-if',
6
4
  bind: ({ inst, el, attr, raw, scope }) => {
@@ -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,7 +1,4 @@
1
- import { BINDERS } from './index'
2
- import { parsed, member } from './common'
3
- import { isSignal, type Signal } from '../signal'
4
- import { warnOnce } from '../warn'
1
+ import { BINDERS, parsed, member, isSignal, warnOnce, type Signal } from '../core-api'
5
2
  BINDERS.push({
6
3
  match: (a) => a === 'x-model',
7
4
  bind: ({ inst, el, attr, raw }) => {
@@ -1,5 +1,4 @@
1
- import { BINDERS } from './index'
2
- import { parsed, member } from './common'
1
+ import { BINDERS, parsed, member } from '../core-api'
3
2
  BINDERS.push({
4
3
  match: (a) => a.startsWith('x-on-'),
5
4
  bind: ({ inst, el, attr, raw, scope }) => {
@@ -1,4 +1,4 @@
1
- import { BINDERS } from './index'
1
+ import { BINDERS } from '../core-api'
2
2
  BINDERS.push({
3
3
  match: (a) => a === 'x-ref',
4
4
  bind: ({ inst, el, raw }) => {
@@ -1,5 +1,4 @@
1
- import { BINDERS } from './index'
2
- import { parsed, read } from './common'
1
+ import { BINDERS, parsed, read } from '../core-api'
3
2
  BINDERS.push({
4
3
  match: (a) => a === 'x-show',
5
4
  bind: ({ inst, el, attr, raw, scope }) => {
@@ -1,5 +1,4 @@
1
- import { BINDERS } from './index'
2
- import { parsed, read, mismatch } from './common'
1
+ import { BINDERS, parsed, read, mismatch } from '../core-api'
3
2
  BINDERS.push({
4
3
  match: (a) => a === 'x-text',
5
4
  bind: ({ inst, el, attr, raw, scope }) => {
@@ -0,0 +1,6 @@
1
+ // The directives nearly every interactive page uses, shipped as ONE module (see README "Feature modules").
2
+ import '../directives/text'
3
+ import '../directives/show'
4
+ import '../directives/bind'
5
+ import '../directives/on'
6
+ import '../directives/ref'
@@ -0,0 +1 @@
1
+ import '../directives/for'
@@ -0,0 +1 @@
1
+ import '../directives/html'
@@ -0,0 +1 @@
1
+ import '../directives/if'
@@ -0,0 +1,8 @@
1
+ // Every feature at once: what `@brust/runtime-dom` (the full entry) loads. The browser build never uses this file;
2
+ // it imports exactly the features a route needs.
3
+ import './basics'
4
+ import './if'
5
+ import './for'
6
+ import './model'
7
+ import './html'
8
+ import './island'
@@ -0,0 +1,9 @@
1
+ // React island hydration glue. Registers on the core's island hooks; hosts already in the document when this module
2
+ // arrives (the core mounts first) are scheduled right away.
3
+ import { islandHooks } from '../core-api'
4
+ import { scheduleIslands, cancelIslands } from '../island'
5
+ islandHooks.schedule = scheduleIslands
6
+ islandHooks.cancel = cancelIslands
7
+ if (typeof document !== 'undefined') scheduleIslands(document.documentElement)
8
+ export { defineIsland } from '../island'
9
+ export type { IslandHydrate } from '../island'
@@ -0,0 +1 @@
1
+ import '../directives/model'
@@ -0,0 +1,6 @@
1
+ // The store module: `defineStore` plus the reactive primitives a store's code imports (`@brust/core/store` aliases here),
2
+ // re-exported from the core so there is one signal graph per page.
3
+ export { signal, computed, effect, batch, untracked, isSignal, isComputed } from '../core-api'
4
+ export type { Signal, Computed, Dispose } from '../core-api'
5
+ export { defineStore } from '../store/define-store'
6
+ export type { StoreHandle, Snapshot } from '../store/define-store'
package/src/index.ts CHANGED
@@ -1,9 +1,8 @@
1
- export { signal, computed, effect, batch, untracked, isSignal, isComputed } from './signal'
2
- export type { Signal, Computed, Dispose } from './signal'
1
+ // The full runtime: the core plus every feature. Tests and non-split consumers import this; the browser build
2
+ // loads `./core` and only the `./features/*` a route needs.
3
+ export * from './core'
3
4
  export { defineStore } from './store/define-store'
4
5
  export type { StoreHandle, Snapshot } from './store/define-store'
5
- export { defineBehavior, setChunkLoader } from './registry'
6
- export type { BehaviorCtx, BehaviorFactory, BehaviorInstance } from './registry'
7
- export { mount, unmount } from './mount'
8
6
  export { defineIsland } from './island'
9
7
  export type { IslandHydrate } from './island'
8
+ import './features/index'
package/src/instance.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  import { effect as coreEffect, signal, type Dispose, type Signal } from './signal'
2
2
  import type { BehaviorCtx, BehaviorFactory, BehaviorInstance } from './registry'
3
3
 
4
+ /** Defined by `brust build --dev` (true) and every other build (false): the dev code below is removed from production bundles. */
5
+ declare const __BRUST_DEV__: boolean | undefined
6
+ /** Dev only (L14): the live instances, for the devtools (`globalThis.__brustDev.instances`). */
7
+ function devTrack(i: Instance, on: boolean): void {
8
+ const g = globalThis as { __brustDev?: { instances: Set<Instance> } }
9
+ const dev = (g.__brustDev ??= { instances: new Set<Instance>() })
10
+ if (on) dev.instances.add(i)
11
+ else dev.instances.delete(i)
12
+ }
13
+
4
14
  export class Instance {
5
15
  members: BehaviorInstance = {}
6
16
  props: Signal<Record<string, unknown>>
@@ -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()
@@ -54,3 +93,6 @@ export function nearestInstance(el: Element): Instance | null {
54
93
 
55
94
  /** Late-bound by mount.ts so binders (x-if, x-for) can mount nested hosts synchronously without an import cycle. */
56
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 CHANGED
@@ -1,7 +1,7 @@
1
1
  // React islands: the server paints <brust-island data-id x-props>HTML</brust-island>; the island's
2
2
  // chunk registers a hydrate function through a global queue (no import of the runtime); the runtime
3
3
  // hydrates each host once, when idle (spec S12). Load order between runtime and chunks is free.
4
- import { warnOnce } from './warn'
4
+ import { warnOnce, insideRaw } from './core-api'
5
5
 
6
6
  export type IslandHydrate = (host: HTMLElement, props: Record<string, unknown>) => void
7
7
  type QueueEntry = [string, IslandHydrate]
@@ -33,9 +33,13 @@ export function whenIsland(id: string, cb: (h: IslandHydrate) => void): void {
33
33
 
34
34
  // ---- scheduler ----------------------------------------------------------------------------
35
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
36
40
  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)
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)
39
43
  }
40
44
  let idle: Idle = defaultIdle
41
45
  export function _setIdle(fn: Idle | null): void { idle = fn ?? defaultIdle }
@@ -57,6 +61,7 @@ function hydrateHost(host: HTMLElement, id: string, hydrate: IslandHydrate): voi
57
61
  }
58
62
 
59
63
  function scheduleHost(host: HTMLElement): void {
64
+ if (insideRaw(host)) return // inside dangerouslySetInnerHTML markup: never hydrated
60
65
  if (pending.has(host) || host.hasAttribute('data-hydrated')) return
61
66
  const id = host.getAttribute('data-id')!
62
67
  pending.add(host)
package/src/mount.ts CHANGED
@@ -1,11 +1,10 @@
1
- import { Instance, hooks, instances, nearestInstance } from './instance'
1
+ import { Instance, hooks, instances, islandHooks, nearestInstance } from './instance'
2
2
  import { untracked } from './signal'
3
3
  import { whenBehavior } from './registry'
4
4
  import { bindHost } from './directives/index'
5
+ import { insideRaw } from './directives/common'
5
6
  import { warnOnce } from './warn'
6
7
  import { bindPropsFromParent } from './props-bind'
7
- import { scheduleIslands, cancelIslands } from './island'
8
- import './directives/register' // Tasks 3–6 add binders here (file created in Task 3; create it empty now)
9
8
 
10
9
  let observer: MutationObserver | null = null
11
10
 
@@ -27,6 +26,7 @@ function mountHost(host: HTMLElement): void {
27
26
  try { inst.init(factory) } catch (e) { console.error(`[brust] init threw: ${name}`, e); inst.dispose(); instances.delete(host); return }
28
27
  bindHost(inst, host, {})
29
28
  inst.booted = true
29
+ if (!inst.start()) return // F97: refs and handlers are bound; now the component's effects
30
30
  bindPropsFromParent(inst)
31
31
  // Children that mounted before this instance existed (parent chunk arrived late) link up now.
32
32
  host.querySelectorAll<HTMLElement>('[x-data]').forEach((h) => {
@@ -41,14 +41,14 @@ function mountTree(root: ParentNode): void {
41
41
  if (root instanceof HTMLElement && root.hasAttribute('x-data')) hosts.push(root)
42
42
  root.querySelectorAll<HTMLElement>('[x-data]').forEach((h) => hosts.push(h))
43
43
  // document order = parents before children, so nearestInstance finds a mounted parent
44
- for (const h of hosts) mountHost(h)
45
- scheduleIslands(root)
44
+ for (const h of hosts) if (!insideRaw(h)) mountHost(h)
45
+ islandHooks.schedule(root)
46
46
  }
47
47
  hooks.mountTree = (root) => untracked(() => mountTree(root))
48
48
 
49
49
  function disposeTree(root: Node): void {
50
50
  if (!(root instanceof Element)) return
51
- cancelIslands(root)
51
+ islandHooks.cancel(root)
52
52
  const hosts: Element[] = root.hasAttribute('x-data') ? [root] : []
53
53
  root.querySelectorAll('[x-data]').forEach((h) => hosts.push(h))
54
54
  for (const h of hosts) { const i = instances.get(h); if (i) { i.dispose(); instances.delete(h) } }
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 '../core-api'
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 '../core-api'
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
 
@@ -1,8 +0,0 @@
1
- import './text'
2
- import './show'
3
- import './bind'
4
- import './on'
5
- import './if'
6
- import './model'
7
- import './for'
8
- import './ref'