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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brust/runtime-dom",
3
- "version": "2.0.0-alpha.20",
3
+ "version": "2.0.0-alpha.22",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/src/core-api.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // What a feature module may import from the core, and nothing else. In the browser build the core is one shared
2
2
  // ESM file and every feature is a separate file that imports these names from it (bundle.ts maps this module to the
3
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'
4
+ export { BINDERS, bindHost, bindElement, bindRef, walkChildren } from './directives/index'
5
5
  export type { Binder } from './directives/index'
6
6
  export { parsed, read, member, mismatch, insideRaw, RAW_SEL } from './directives/common'
7
7
  export { hooks, islandHooks, collectionHooks } from './instance'
@@ -4,6 +4,11 @@ type RowScope = Record<string, unknown> & { __own: Signal<number> }
4
4
 
5
5
  const SYNTAX = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*))?\s+in\s+([A-Za-z_$][\w$.]*(?::[A-Za-z_$][\w$]*(?:\s*,\s*[A-Za-z_$][\w$]*)*)?)\s+by\s+([A-Za-z_$][\w$.]*)\s*$/
6
6
  const done = new WeakSet<Element>()
7
+ /** The comments this binder inserted: the server's own `<!--x-for-->` marker is not among them. */
8
+ const anchors = new WeakSet<Node>()
9
+ /** F396: the zero-row template is the element the server prints right after its `<!--x-for-->` marker. A row is not
10
+ * told apart by `hidden`: a row whose own root is `hidden={cond}` is server-rendered hidden and is still a row. */
11
+ const isTemplate = (s: Element) => { const p = s.previousSibling; return p instanceof Comment && p.data === 'x-for' && !anchors.has(p) }
7
12
 
8
13
  BINDERS.push({
9
14
  match: (a) => a === 'x-for',
@@ -17,13 +22,14 @@ BINDERS.push({
17
22
  // All server-rendered siblings carrying the same x-for are rows of this list; the first is the template.
18
23
  const parent = el.parentElement!
19
24
  const siblings = Array.from(parent.children).filter((c) => c.getAttribute('x-for') === raw)
25
+ const templates = new Set(siblings.filter(isTemplate))
20
26
  siblings.forEach((s) => done.add(s))
21
27
  const template = el.cloneNode(true) as Element
22
28
  template.removeAttribute('x-for'); template.removeAttribute('hidden')
23
- const anchor = document.createComment('x-for')
29
+ const anchor = document.createComment('x-for'); anchors.add(anchor)
24
30
  parent.insertBefore(anchor, el)
25
- // Server-rendered rows (not the hidden zero-row template) are adopted in order on the first run.
26
- const adopt = siblings.filter((s) => !s.hasAttribute('hidden'))
31
+ // Server-rendered rows (not the zero-row template) are adopted in order on the first run.
32
+ const adopt = siblings.filter((s) => !templates.has(s))
27
33
  if (!adopt.includes(el)) el.remove()
28
34
  let firstRun = true
29
35
 
@@ -16,11 +16,21 @@ export function walkChildren(inst: Instance, el: Element, scope: Scope): void {
16
16
  for (const child of Array.from(el.children)) {
17
17
  if ('__scope' in child) continue // x-for row: already bound with its own scope
18
18
  if (child.hasAttribute('x-for')) { bindElement(inst, child, scope); continue } // x-for owns the element (even when it is also a host)
19
- if (child.hasAttribute(HOST_ATTR)) continue // nested host: its own instance binds it
19
+ if (child.hasAttribute(HOST_ATTR)) { // nested host: its own instance binds it,
20
+ const pref = child.getAttribute('x-pref') // but the ref its caller passed is the caller's (F267/F288)
21
+ if (pref) bindRef(inst, child, pref)
22
+ continue
23
+ }
20
24
  bindElement(inst, child, scope)
21
25
  if (!child.hasAttribute('x-if')) walkChildren(inst, child, scope) // x-if owns its subtree
22
26
  }
23
27
  }
28
+ /** `x-ref` / `x-pref`: the instance's ref cell `name` holds `el` while it is bound. */
29
+ export function bindRef(inst: Instance, el: Element, name: string): void {
30
+ const r = inst.ref(name.trim())
31
+ r.current = el as HTMLElement
32
+ inst.onCleanup(() => { if (r.current === el) r.current = null })
33
+ }
24
34
  export function bindElement(inst: Instance, el: Element, scope: Scope): void {
25
35
  // Structural directives own the element: x-for/x-if re-create it from a template, so the
26
36
  // remaining attributes are bound on the clones (with the right scope), never on this original.
@@ -1,9 +1,5 @@
1
- import { BINDERS } from '../core-api'
1
+ import { BINDERS, bindRef } from '../core-api'
2
2
  BINDERS.push({
3
3
  match: (a) => a === 'x-ref',
4
- bind: ({ inst, el, raw }) => {
5
- const r = inst.ref(raw.trim())
6
- r.current = el as HTMLElement
7
- inst.onCleanup(() => { if (r.current === el) r.current = null })
8
- },
4
+ bind: ({ inst, el, raw }) => bindRef(inst, el, raw),
9
5
  })
package/src/instance.ts CHANGED
@@ -27,7 +27,7 @@ export class Instance {
27
27
  * first paint, linked to its parent's current value): its first bind is not a first paint, so no tripwire. */
28
28
  reseeded = false
29
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 = []
30
+ private pending: Array<[() => void | (() => void), Dispose?, number?]> | null = []
31
31
 
32
32
  constructor(public host: HTMLElement, public name: string, public parent: Instance | null, initialProps: Record<string, unknown>) {
33
33
  this.props = signal(initialProps)
@@ -35,13 +35,15 @@ export class Instance {
35
35
  if (typeof __BRUST_DEV__ !== 'undefined' && __BRUST_DEV__) devTrack(this, true)
36
36
  }
37
37
 
38
- effect(fn: () => void | (() => void)): Dispose {
39
- const d = coreEffect(fn); this.disposers.push(d); return d
38
+ effect(fn: () => void | (() => void), tier = 0): Dispose {
39
+ const d = coreEffect(fn, tier); this.disposers.push(d); return d
40
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]
41
+ /** `ctx.effect`: deferred until `start()` while the factory runs, immediate afterwards. A compiled effect re-runs after
42
+ * the bindings: layout (`layout` true) before passive (F288, ruling 94404307 amended). */
43
+ private componentEffect(fn: () => void | (() => void), layout = false): Dispose {
44
+ const tier = layout ? 1 : 2
45
+ if (!this.pending) return this.effect(fn, tier)
46
+ const q: [() => void | (() => void), Dispose?, number?] = [fn, undefined, tier]
45
47
  this.pending.push(q)
46
48
  return () => { q[0] = () => {}; q[1]?.() }
47
49
  }
@@ -57,7 +59,7 @@ export class Instance {
57
59
  this.pending = null
58
60
  for (const q of queue ?? []) {
59
61
  if (this.disposed) break
60
- try { q[1] = this.effect(q[0]) }
62
+ try { q[1] = this.effect(q[0], q[2]) }
61
63
  catch (e) { console.error(`[brust] effect threw: ${this.name}`, e); this.dispose(); instances.delete(this.host); return false }
62
64
  }
63
65
  return true
@@ -70,7 +72,7 @@ export class Instance {
70
72
  return r as { current: E | null }
71
73
  }
72
74
  ctx(): BehaviorCtx {
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) }
75
+ return { el: this.host, props: this.props, effect: (fn, layout) => this.componentEffect(fn, layout), onCleanup: (fn) => this.onCleanup(fn), ref: (n, init) => this.ref(n, init), late: this.late, items: () => collectionHooks.items(this.host) }
74
76
  }
75
77
  init(factory: BehaviorFactory): void {
76
78
  const m = factory(this.ctx())
package/src/registry.ts CHANGED
@@ -3,7 +3,7 @@ import type { Dispose, Signal } from './signal'
3
3
  export interface BehaviorCtx {
4
4
  el: HTMLElement
5
5
  props: Signal<Record<string, unknown>>
6
- effect: (fn: () => void | (() => void)) => Dispose
6
+ effect: (fn: () => void | (() => void), layout?: boolean) => Dispose
7
7
  onCleanup: (fn: () => void) => void
8
8
  ref: <E extends Element = HTMLElement>(name: string, init?: unknown) => { current: E | null }
9
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. */
package/src/signal.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // A small push-pull reactive core. Signals push "dirty" to subscribers; computeds pull lazily.
2
2
  type Subscriber = { mark(): void; deps: Set<Node> }
3
3
  type Node = { subs: Set<Subscriber> }
4
- type Runnable = { disposed: boolean; run(): void }
4
+ type Runnable = { disposed: boolean; run(): void; tier?: number; id?: number }
5
5
  interface ReactiveCtx { currentSub: Subscriber | null; batchDepth: number; pending: Set<Runnable> }
6
6
 
7
7
  const CTX = Symbol.for('brust.reactive.ctx') // M3-F C7
@@ -23,8 +23,13 @@ function flush() {
23
23
  if (ctx.batchDepth > 0) return
24
24
  const pending = ctx.pending
25
25
  while (pending.size) {
26
- const batch = Array.from(pending); pending.clear()
27
- for (const e of batch) if (!e.disposed) e.run()
26
+ // F288 (ruling 94404307 amended): a re-run follows React's commit order: every binding of the change (tier 0, in
27
+ // notify order), then compiled layout effects (1), then compiled passive effects (2), each in declaration order.
28
+ const all = Array.from(pending)
29
+ const t = Math.min(...all.map((e) => e.tier || 0))
30
+ // a stable sort: bindings (t = 0) keep their notify order
31
+ const run = all.filter((e) => (e.tier || 0) === t).sort((a, b) => t && a.id! - b.id!)
32
+ for (const e of run) pending.delete(e), e.disposed || e.run()
28
33
  }
29
34
  }
30
35
 
@@ -67,9 +72,11 @@ export function computed<T>(fn: () => T): Computed<T> {
67
72
  // Cleanups run untracked: a read inside one must not subscribe whichever effect is running around it.
68
73
  function runCleanup(c: () => void) { const prev = ctx.currentSub; ctx.currentSub = null; try { c() } finally { ctx.currentSub = prev } }
69
74
 
75
+ let ids = 0
70
76
  class EffectImpl implements Subscriber {
71
77
  deps = new Set<Node>(); disposed = false; cleanup: void | (() => void) = undefined
72
- constructor(private fn: () => void | (() => void)) {}
78
+ readonly id = ++ids
79
+ constructor(private fn: () => void | (() => void), readonly tier = 0) {}
73
80
  mark() { ctx.pending.add(this) }
74
81
  run() {
75
82
  if (this.disposed) return
@@ -86,8 +93,9 @@ class EffectImpl implements Subscriber {
86
93
  }
87
94
  }
88
95
 
89
- export function effect(fn: () => void | (() => void)): Dispose {
90
- const e = new EffectImpl(fn)
96
+ /** `tier`: 0 a binding, 1 a compiled layout effect, 2 a compiled passive effect (the order of re-runs). */
97
+ export function effect(fn: () => void | (() => void), tier = 0): Dispose {
98
+ const e = new EffectImpl(fn, tier)
91
99
  try { e.run() } catch (x) { e.dispose(); throw x } // F98: a first run that throws must not stay subscribed
92
100
  return () => e.dispose()
93
101
  }