@brust/runtime-dom 2.0.0-alpha.2 → 2.0.0-alpha.20

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.2",
3
+ "version": "2.0.0-alpha.20",
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,6 @@
1
+ // GENERATED from crates/brust-jinja/src/lib.rs (BOOLEAN_ATTRS, OVERLOADED_ATTRS, STRINGIFY_ATTRS); do not edit.
2
+ // Regenerate: bun scripts/gen-attr-kinds.ts. test/attr-kinds.test.ts fails when this drifts.
3
+ const set = (s: string): Set<string> => new Set(s.split(' '))
4
+ export const BOOLEAN = set('allowfullscreen async autofocus autoplay checked controls default defer disabled disablepictureinpicture disableremoteplayback formnovalidate hidden inert itemscope loop multiple muted nomodule novalidate open playsinline readonly required reversed scoped seamless selected')
5
+ export const OVERLOADED = set('capture download')
6
+ export const STRINGIFY = set('autoreverse contenteditable draggable externalresourcesrequired focusable preservealpha spellcheck value')
@@ -0,0 +1,11 @@
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, collectionHooks } from './instance'
8
+ export type { CollectionItem } from './instance'
9
+ export { warnOnce } from './warn'
10
+ export { signal, computed, effect, batch, untracked, isSignal, isComputed } from './signal'
11
+ 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,12 +1,35 @@
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'
2
+ import { BOOLEAN, OVERLOADED, STRINGIFY } from '../attr-kinds'
3
+ import type { Instance } from '../instance'
4
4
 
5
5
  // Attributes that execute or load code from a string; a bound value must never reach them as-is.
6
6
  const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'poster', 'data', 'xlink:href', 'ping'])
7
7
  // Scheme allowlist on the parsed URL (relative URLs resolve to http): new URL() applies the browser's tab/newline/control-char stripping.
8
8
  const safeUrl = (v: string): boolean => { try { return ['http:', 'https:', 'mailto:', 'tel:'].includes(new URL(v, 'http://x').protocol) } catch { return false } }
9
- const BOOLEAN = new Set(['disabled', 'checked', 'selected', 'readonly', 'required', 'hidden', 'open', 'multiple'])
9
+ /** Writes one bound attribute value with React's rules (shared by `x-bind-*` and `x-prop-*`, A2: one print path). */
10
+ function writeAttr(inst: Instance, attr: string, el: Element, name: string, v: unknown): void {
11
+ // setAttribute: `className` is a getter-only SVGAnimatedString on SVG elements (F96a).
12
+ // F311: undefined/null removes `class` (React does; `className=""` keeps an empty one), and an empty `style` is no attribute (the first paint omits it)
13
+ if (name === 'class') { if (v == null) el.removeAttribute('class'); else el.setAttribute('class', `${v}`); return }
14
+ if (name === 'value' && 'value' in el) { (el as HTMLInputElement).value = v == null ? '' : String(v); return }
15
+ // React passes every attribute of a custom element (a tag with `-`) through as given: `true` is ``, `false` omits it.
16
+ const custom = el.localName.includes('-')
17
+ if (!custom && BOOLEAN.has(name)) {
18
+ const on = Boolean(v)
19
+ if (name in el) (el as unknown as Record<string, unknown>)[name] = on
20
+ if (on) el.setAttribute(name, '') ; else el.removeAttribute(name)
21
+ return
22
+ }
23
+ // aria-*, data-* and the booleanish strings print `true`/`false`; download/capture print `true` as ``; on any other attribute React omits a boolean.
24
+ if (!custom && (name.startsWith('aria-') || name.startsWith('data-') || STRINGIFY.has(name))) { if (v == null) el.removeAttribute(name); else el.setAttribute(name, String(v)); return }
25
+ if ((custom || OVERLOADED.has(name)) && typeof v === 'boolean') { if (v) el.setAttribute(name, ''); else el.removeAttribute(name); return }
26
+ if (v == null || typeof v === 'boolean' || (name === 'style' && !v)) { el.removeAttribute(name); return }
27
+ // React writes no `src=""` / `href=""` (it would load the page again); an anchor keeps its empty href.
28
+ if (v === '' && (name === 'src' || name === 'href') && el.localName !== 'a' && !custom) { el.removeAttribute(name); return }
29
+ if (URL_ATTRS.has(name) && !safeUrl(String(v))) { warnOnce(`b:${inst.name}:${name}`, `${attr} in ${inst.name}: unsafe URL refused`); return }
30
+ el.setAttribute(name, String(v))
31
+ }
32
+
10
33
  BINDERS.push({
11
34
  match: (a) => a.startsWith('x-bind-'),
12
35
  bind: ({ inst, el, attr, raw, scope }) => {
@@ -15,18 +38,25 @@ BINDERS.push({
15
38
  const p = parsed(inst, attr, raw); if (!p) return
16
39
  inst.effect(() => {
17
40
  const r = read(inst, p, scope, attr); if (!r.ok) return
18
- const v = r.value
19
- if (name === 'class') { (el as HTMLElement).className = v == null ? '' : String(v); return }
20
- if (name === 'value' && 'value' in el) { (el as HTMLInputElement).value = v == null ? '' : String(v); return }
21
- if (BOOLEAN.has(name)) {
22
- const on = Boolean(v)
23
- if (name in el) (el as unknown as Record<string, unknown>)[name] = on
24
- if (on) el.setAttribute(name, '') ; else el.removeAttribute(name)
25
- return
26
- }
27
- if (v == null || v === false) { el.removeAttribute(name); return }
28
- if (URL_ATTRS.has(name) && !safeUrl(String(v))) { warnOnce(`b:${inst.name}:${name}`, `${attr} in ${inst.name}: unsafe URL refused`); return }
29
- el.setAttribute(name, String(v))
41
+ writeAttr(inst, attr, el, name, r.value)
30
42
  })
31
43
  },
32
44
  })
45
+
46
+ // F329b: `x-prop-<attribute>="<prop key>"` binds the attribute to a prop of THIS instance (`inst.props()`), the key being
47
+ // the VALUE (a prop key is not a member path: `aria-checked`, `tabIndex`). The key set is a property of one call site, so it is
48
+ // printed in the template, never as a chunk member (the chunk stays shared). The attribute name is the lowercase DOM name the
49
+ // server print used (the shared React -> attribute table), so both ends agree.
50
+ // Refused, as `x-bind-*` refuses them and more: event handlers, `srcdoc`, `x-*`, and the keys the server print never
51
+ // binds through a spread (class, style, value, checked, selected, URL-bearing attributes).
52
+ const PROP_REFUSED = new Set(['srcdoc', 'class', 'style', 'value', 'checked', 'selected', ...URL_ATTRS])
53
+ BINDERS.push({
54
+ match: (a) => a.startsWith('x-prop-'),
55
+ bind: ({ inst, el, attr, raw }) => {
56
+ const name = attr.slice('x-prop-'.length)
57
+ if (!name || !raw || name.startsWith('on') || name.startsWith('x-') || PROP_REFUSED.has(name)) {
58
+ warnOnce(`pb:${inst.name}:${name}`, `${attr} in ${inst.name} refused`); return
59
+ }
60
+ inst.effect(() => { const ps = inst.props(); writeAttr(inst, attr, el, name, Object.hasOwn(ps, raw) ? ps[raw] : undefined) })
61
+ },
62
+ })
@@ -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`)
@@ -19,5 +23,6 @@ export function member(inst: Instance, p: ParsedValue, attr: string): unknown {
19
23
  }
20
24
  export function mismatch(inst: Instance, attr: string, _el: Element, server: unknown, client: unknown): void {
21
25
  if (inst.booted) return // element created after mount (new x-for row / x-if clone): nothing was server-rendered
26
+ if (inst.reseeded) return // F277: a host created later, started from its parent's current props, not the printed ones
22
27
  warnOnce(`x:${inst.name}:${attr}`, `first-paint mismatch ${attr} in ${inst.name}: ${JSON.stringify(server)} vs ${JSON.stringify(client)}`)
23
28
  }
@@ -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,11 +1,22 @@
1
- import { BINDERS } from './index'
2
- import { parsed, member } from './common'
1
+ import { BINDERS, parsed, member } from '../core-api'
2
+
3
+ // React's SyntheticEvent has five members a DOM event lacks; code written for React calls them on the event it is handed
4
+ // (Radix: `event.isPropagationStopped()`), and on a DOM event that is a TypeError (F195). Added to the event the handler
5
+ // receives, never to Event.prototype, so nothing else on the page changes.
6
+ const SYNTHETIC: PropertyDescriptorMap = {
7
+ isPropagationStopped: { value(this: Event) { return this.cancelBubble } },
8
+ isDefaultPrevented: { value(this: Event) { return this.defaultPrevented } },
9
+ persist: { value() {} },
10
+ isPersistent: { value: () => true },
11
+ nativeEvent: { get(this: Event) { return this } },
12
+ }
3
13
  BINDERS.push({
4
14
  match: (a) => a.startsWith('x-on-'),
5
15
  bind: ({ inst, el, attr, raw, scope }) => {
6
16
  const event = attr.slice('x-on-'.length)
7
17
  const p = parsed(inst, attr, raw); if (!p) return
8
18
  const handler = (e: Event) => {
19
+ if (!('isPropagationStopped' in e)) Object.defineProperties(e, SYNTHETIC)
9
20
  const fn = member(inst, p, attr)
10
21
  if (typeof fn !== 'function') return
11
22
  const args = p.bindings.map((b) => scope[b])
@@ -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,40 @@
1
+ // The collection feature (R34.140-R34.141): the items a Radix `createCollection` tracks, as the DOM already has them.
2
+ // A root is an element with `x-collection-root`; an item is an element with `data-radix-collection-item` whose NEAREST root
3
+ // above it is that root (a collection nested inside another keeps its items to itself). Document order is the DOM's own,
4
+ // so there is no registry to sort. An item's data (`id`, `focusable`, ...) is what its host's `x-collection-item` member
5
+ // evaluates to when asked: read at call time, so it is never stale.
6
+ import { BINDERS, collectionHooks, parsed, read, RAW_SEL } from '../core-api'
7
+ import type { CollectionItem } from '../core-api'
8
+
9
+ const ROOT = '[x-collection-root]'
10
+ const ITEM = '[data-radix-collection-item]'
11
+ const DATA = new WeakMap<Element, () => unknown>()
12
+
13
+ collectionHooks.items = (from) => {
14
+ const root = from.closest(ROOT)
15
+ if (!root) return []
16
+ const out: CollectionItem[] = []
17
+ for (const el of Array.from(root.querySelectorAll(ITEM))) {
18
+ // the nearest root ABOVE the item (not the item itself, which may be a root of its own)
19
+ if (el.parentElement?.closest(ROOT) !== root) continue
20
+ // an island or raw markup (x-html / x-raw) between the root and the item is inert: its items are not this collection's
21
+ // (Radix keeps only the nodes registered through its own context, never what a foreign widget or CMS markup holds)
22
+ const raw = el.parentElement?.closest(RAW_SEL)
23
+ if (raw && root.contains(raw)) continue
24
+ const d = DATA.get(el)?.()
25
+ out.push({ ...(d && typeof d === 'object' ? (d as object) : {}), ref: { current: el }, element: el })
26
+ }
27
+ return out
28
+ }
29
+
30
+ // markers: a root, and a host that reads `ctx.items()` (so the module loads for it); neither binds anything
31
+ BINDERS.push({ match: (a) => a === 'x-collection-root' || a === 'x-collection-use', bind: () => {} })
32
+ BINDERS.push({
33
+ match: (a) => a === 'x-collection-item',
34
+ bind: ({ inst, el, attr, raw, scope }) => {
35
+ const p = parsed(inst, attr, raw)
36
+ if (!p) return
37
+ DATA.set(el, () => { const r = read(inst, p, scope, attr); return r.ok ? r.value : undefined })
38
+ inst.onCleanup(() => DATA.delete(el))
39
+ },
40
+ })
@@ -0,0 +1 @@
1
+ import '../directives/for'
@@ -0,0 +1 @@
1
+ import '../directives/html'
@@ -0,0 +1 @@
1
+ import '../directives/if'
@@ -0,0 +1,9 @@
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'
9
+ import './collection'
@@ -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,23 +21,56 @@ 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
+ /** Mounted after the page booted: nothing of it was in the server markup (`BehaviorCtx.late`). */
25
+ late = false
26
+ /** F277: started from props other than the ones its markup was printed with (a host an `x-if` or a row created after
27
+ * first paint, linked to its parent's current value): its first bind is not a first paint, so no tripwire. */
28
+ reseeded = false
29
+ /** Effects the component declared while its factory ran: they start once the instance is bound (x-ref, x-on), like React's commit (F97). */
30
+ private pending: Array<[() => void | (() => void), Dispose?]> | null = []
14
31
 
15
32
  constructor(public host: HTMLElement, public name: string, public parent: Instance | null, initialProps: Record<string, unknown>) {
16
33
  this.props = signal(initialProps)
17
34
  parent?.children.add(this)
35
+ if (typeof __BRUST_DEV__ !== 'undefined' && __BRUST_DEV__) devTrack(this, true)
18
36
  }
19
37
 
20
38
  effect(fn: () => void | (() => void)): Dispose {
21
39
  const d = coreEffect(fn); this.disposers.push(d); return d
22
40
  }
41
+ /** `ctx.effect`: deferred until `start()` while the factory runs, immediate afterwards. */
42
+ private componentEffect(fn: () => void | (() => void)): Dispose {
43
+ if (!this.pending) return this.effect(fn)
44
+ const q: [() => void | (() => void), Dispose?] = [fn]
45
+ this.pending.push(q)
46
+ return () => { q[0] = () => {}; q[1]?.() }
47
+ }
48
+ /**
49
+ * Runs the queued component effects, in declaration order. Call after the initial bind pass.
50
+ * Order across hosts: a static nested host mounts after its parent (document order), so a
51
+ * parent's effects start before the child is bound; hosts created by x-if / x-for during the
52
+ * parent's bind pass start their own effects first. A throwing effect is handled like a
53
+ * throwing factory: logged, the instance disposed; returns false then.
54
+ */
55
+ start(): boolean {
56
+ const queue = this.pending
57
+ this.pending = null
58
+ for (const q of queue ?? []) {
59
+ if (this.disposed) break
60
+ try { q[1] = this.effect(q[0]) }
61
+ catch (e) { console.error(`[brust] effect threw: ${this.name}`, e); this.dispose(); instances.delete(this.host); return false }
62
+ }
63
+ return true
64
+ }
23
65
  onCleanup(fn: () => void): void { this.cleanups.push(fn) }
24
- ref<E extends Element = HTMLElement>(name: string): { current: E | null } {
66
+ ref<E extends Element = HTMLElement>(name: string, init?: unknown): { current: E | null } {
25
67
  let r = this.refs.get(name)
26
- if (!r) { r = { current: null }; this.refs.set(name, r) }
68
+ // `useRef(init)`: the cell starts at `init`; an `x-ref` element replaces it when it binds (React attaches the same way).
69
+ if (!r) { r = { current: (init ?? null) as Element | null }; this.refs.set(name, r) }
27
70
  return r as { current: E | null }
28
71
  }
29
72
  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) }
73
+ return { el: this.host, props: this.props, effect: (fn) => this.componentEffect(fn), onCleanup: (fn) => this.onCleanup(fn), ref: (n, init) => this.ref(n, init), late: this.late, items: () => collectionHooks.items(this.host) }
31
74
  }
32
75
  init(factory: BehaviorFactory): void {
33
76
  const m = factory(this.ctx())
@@ -37,6 +80,8 @@ export class Instance {
37
80
  dispose(): void {
38
81
  if (this.disposed) return
39
82
  this.disposed = true
83
+ if (typeof __BRUST_DEV__ !== 'undefined' && __BRUST_DEV__) devTrack(this, false)
84
+ this.pending = null // effects not yet started never run
40
85
  for (const c of Array.from(this.children)) c.dispose()
41
86
  this.parent?.children.delete(this)
42
87
  for (const d of this.disposers.splice(0)) d()
@@ -54,3 +99,11 @@ export function nearestInstance(el: Element): Instance | null {
54
99
 
55
100
  /** Late-bound by mount.ts so binders (x-if, x-for) can mount nested hosts synchronously without an import cycle. */
56
101
  export const hooks: { mountTree: (root: ParentNode) => void } = { mountTree: () => {} }
102
+
103
+ /** One item of a collection (R34.141): the data its host handed over, the element, and `ref.current` (Radix's item shape). */
104
+ export type CollectionItem = { ref: { current: Element }; element: Element } & Record<string, unknown>
105
+ /** Late-bound by the `collection` feature (features/collection.ts): `ctx.items()` is a no-op list until it loads. */
106
+ export const collectionHooks: { items: (from: Element) => CollectionItem[] } = { items: () => [] }
107
+
108
+ /** Late-bound by the `island` feature (features/island.ts) so a page without React islands never loads the hydration glue. */
109
+ 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,13 +1,16 @@
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
- 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)
7
+ import { bindPropsFromParent, initialProps } from './props-bind'
9
8
 
10
9
  let observer: MutationObserver | null = null
10
+ /** True once the first `mount()` pass has run: a host that mounts after it is `late`. */
11
+ let pageBooted = false
12
+ /** > 0 while a binder (x-if, x-for) mounts the hosts of a clone it just created: nothing of them was server-rendered. */
13
+ let cloning = 0
11
14
 
12
15
  function readProps(host: HTMLElement): Record<string, unknown> {
13
16
  const raw = host.getAttribute('x-props')
@@ -16,17 +19,31 @@ function readProps(host: HTMLElement): Record<string, unknown> {
16
19
  catch { warnOnce(`j:${host.outerHTML.slice(0, 80)}`, `bad x-props JSON in ${host.getAttribute('x-data')}`); return {} }
17
20
  }
18
21
 
22
+ function sameJson(a: unknown, b: unknown): boolean {
23
+ try { return JSON.stringify(a) === JSON.stringify(b) } catch { return false }
24
+ }
25
+
19
26
  function mountHost(host: HTMLElement): void {
20
27
  if (instances.has(host) || !host.isConnected) return
21
28
  const name = host.getAttribute('x-data')!
29
+ // decided now, not when a lazy chunk arrives: a server-rendered host stays hydrated however late its code loads
30
+ const late = pageBooted
31
+ const cloned = cloning > 0
22
32
  whenBehavior(name, (factory) => {
23
33
  if (instances.has(host) || !host.isConnected) return
24
34
  const parent = nearestInstance(host)
25
- const inst = new Instance(host, name, parent, readProps(host))
35
+ const printed = readProps(host)
36
+ const props = initialProps(host, parent, printed)
37
+ const inst = new Instance(host, name, parent, props)
38
+ inst.reseeded = props !== printed && !sameJson(props, printed)
39
+ inst.late = late
40
+ // a clone made by a binder has no server markup to compare its first paint with (a navigation's content has)
41
+ inst.booted = cloned
26
42
  instances.set(host, inst)
27
43
  try { inst.init(factory) } catch (e) { console.error(`[brust] init threw: ${name}`, e); inst.dispose(); instances.delete(host); return }
28
44
  bindHost(inst, host, {})
29
45
  inst.booted = true
46
+ if (!inst.start()) return // F97: refs and handlers are bound; now the component's effects
30
47
  bindPropsFromParent(inst)
31
48
  // Children that mounted before this instance existed (parent chunk arrived late) link up now.
32
49
  host.querySelectorAll<HTMLElement>('[x-data]').forEach((h) => {
@@ -41,14 +58,18 @@ function mountTree(root: ParentNode): void {
41
58
  if (root instanceof HTMLElement && root.hasAttribute('x-data')) hosts.push(root)
42
59
  root.querySelectorAll<HTMLElement>('[x-data]').forEach((h) => hosts.push(h))
43
60
  // document order = parents before children, so nearestInstance finds a mounted parent
44
- for (const h of hosts) mountHost(h)
45
- scheduleIslands(root)
61
+ for (const h of hosts) if (!insideRaw(h)) mountHost(h)
62
+ islandHooks.schedule(root)
46
63
  }
47
- hooks.mountTree = (root) => untracked(() => mountTree(root))
64
+ hooks.mountTree = (root) =>
65
+ untracked(() => {
66
+ cloning++
67
+ try { mountTree(root) } finally { cloning-- }
68
+ })
48
69
 
49
70
  function disposeTree(root: Node): void {
50
71
  if (!(root instanceof Element)) return
51
- cancelIslands(root)
72
+ islandHooks.cancel(root)
52
73
  const hosts: Element[] = root.hasAttribute('x-data') ? [root] : []
53
74
  root.querySelectorAll('[x-data]').forEach((h) => hosts.push(h))
54
75
  for (const h of hosts) { const i = instances.get(h); if (i) { i.dispose(); instances.delete(h) } }
@@ -58,6 +79,7 @@ export function mount(root: ParentNode = document.body): void {
58
79
  // Observe first: binders (x-if, x-for) insert nested hosts during the initial pass.
59
80
  if (!observer) startObserver(root); else if (root !== observedRoot) observer.observe(root, { childList: true, subtree: true })
60
81
  mountTree(root)
82
+ pageBooted = true
61
83
  }
62
84
  let observedRoot: ParentNode | null = null
63
85
  function startObserver(root: ParentNode): void {
package/src/props-bind.ts CHANGED
@@ -9,6 +9,16 @@ function scopeOf(el: Element): Record<string, unknown> {
9
9
  return {}
10
10
  }
11
11
 
12
+ /** F277: the props a linked host starts with: its parent's current `x-props-bind` value when the parent is mounted (a
13
+ * host an `x-if` or a row creates after first paint gets what the parent passes NOW, as React mounts with the current
14
+ * props: a `useState(prop)` starts from it), else its printed `x-props`. */
15
+ export function initialProps(host: HTMLElement, parent: Instance | null, printed: Record<string, unknown>): Record<string, unknown> {
16
+ const raw = host.getAttribute('x-props-bind'); if (!raw || !parent) return printed
17
+ const p = parseValue(raw); if (!p) return printed
18
+ const r = resolve(parent.members, p, scopeOf(host))
19
+ return r.ok && r.value && typeof r.value === 'object' ? (r.value as Record<string, unknown>) : printed
20
+ }
21
+
12
22
  export function bindPropsFromParent(inst: Instance): void {
13
23
  const raw = inst.host.getAttribute('x-props-bind'); if (!raw) return
14
24
  const p = parseValue(raw)
package/src/registry.ts CHANGED
@@ -5,7 +5,11 @@ export interface BehaviorCtx {
5
5
  props: Signal<Record<string, unknown>>
6
6
  effect: (fn: () => void | (() => void)) => Dispose
7
7
  onCleanup: (fn: () => void) => void
8
- ref: <E extends Element = HTMLElement>(name: string) => { current: E | null }
8
+ ref: <E extends Element = HTMLElement>(name: string, init?: unknown) => { current: E | null }
9
+ /** True when this instance mounted after the page booted (an x-if clone, an x-for row, a navigation): nothing of it was server-rendered. */
10
+ late: boolean
11
+ /** The collection items under the nearest collection root of this instance, in document order (R34.141). */
12
+ items: () => Array<{ ref: { current: Element }; element: Element } & Record<string, unknown>>
9
13
  }
10
14
  export type BehaviorInstance = Record<string, unknown>
11
15
  export type BehaviorFactory = (ctx: BehaviorCtx) => BehaviorInstance | void
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'