brustjs 0.1.19-alpha → 0.1.21-alpha
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/example/pokedex/components/AddToTeamButton.tsx +11 -8
- package/example/pokedex/components/PageLayout.tsx +6 -1
- package/example/pokedex/components/TeamBuilder.tsx +20 -10
- package/example/pokedex/stores/team.ts +14 -0
- package/package.json +9 -8
- package/runtime/cli/templates/minimal/pages/Home.tsx.tmpl +7 -1
- package/runtime/client/index.ts +6 -0
- package/runtime/index.js +52 -52
- package/runtime/index.ts +9 -1
- package/runtime/islands/bootstrap.ts +7 -1
- package/runtime/islands/brust-page.tsx +44 -0
- package/runtime/render/inject-store.ts +63 -0
- package/runtime/render/stream.ts +14 -2
- package/runtime/routes.ts +66 -38
- package/runtime/store/client-hydrate.ts +16 -0
- package/runtime/store/define-store.ts +179 -0
- package/runtime/store/index.ts +8 -0
- package/runtime/store/react.ts +23 -0
- package/runtime/store/serialize.ts +32 -0
- package/runtime/store/server-context.ts +43 -0
- package/runtime/store/signal.ts +152 -0
- package/example/pokedex/components/team-bus.ts +0 -25
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Minimal pull-based reactive core: push-on-write, pull-on-read, synchronous notify.
|
|
2
|
+
// Framework-agnostic — no react, no dom. Foundation for defineStore (Spec A) and
|
|
3
|
+
// the Alpine-style client runtime (Spec B).
|
|
4
|
+
|
|
5
|
+
// Symbol.for (GLOBAL registry), NOT Symbol(): every island is a SEPARATE Bun.build
|
|
6
|
+
// chunk that inlines its own copy of this module, so a plain `Symbol()` brand would
|
|
7
|
+
// be a DIFFERENT value per chunk — `isSignal` from chunk B then fails to recognize a
|
|
8
|
+
// signal created in chunk A. That poisons the shared store snapshot (a cross-chunk
|
|
9
|
+
// reader computes `{}` and caches it), so e.g. the team dock reads empty after a SPA
|
|
10
|
+
// nav loads a new island chunk. A global registry symbol is identical across chunks.
|
|
11
|
+
const SIGNAL = Symbol.for('brust.signal')
|
|
12
|
+
const COMPUTED = Symbol.for('brust.computed')
|
|
13
|
+
|
|
14
|
+
export interface Signal<T> {
|
|
15
|
+
(): T
|
|
16
|
+
set(next: T | ((prev: T) => T)): void
|
|
17
|
+
readonly [SIGNAL]: true
|
|
18
|
+
}
|
|
19
|
+
export interface Computed<T> {
|
|
20
|
+
(): T
|
|
21
|
+
readonly [COMPUTED]: true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isSignal(v: unknown): v is Signal<unknown> {
|
|
25
|
+
return typeof v === 'function' && (v as { [SIGNAL]?: true })[SIGNAL] === true
|
|
26
|
+
}
|
|
27
|
+
export function isComputed(v: unknown): v is Computed<unknown> {
|
|
28
|
+
return typeof v === 'function' && (v as { [COMPUTED]?: true })[COMPUTED] === true
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A reactive consumer (effect or computed) tracking its dependencies.
|
|
32
|
+
interface Consumer {
|
|
33
|
+
run(): void
|
|
34
|
+
deps: Set<Set<Consumer>>
|
|
35
|
+
running: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let activeConsumer: Consumer | null = null
|
|
39
|
+
let batchDepth = 0
|
|
40
|
+
const pendingNotify = new Set<Consumer>()
|
|
41
|
+
|
|
42
|
+
function track(subscribers: Set<Consumer>): void {
|
|
43
|
+
if (activeConsumer) {
|
|
44
|
+
subscribers.add(activeConsumer)
|
|
45
|
+
activeConsumer.deps.add(subscribers)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function notify(subscribers: Set<Consumer>): void {
|
|
50
|
+
// Snapshot — a consumer re-running mutates the set.
|
|
51
|
+
for (const c of [...subscribers]) {
|
|
52
|
+
if (batchDepth > 0) pendingNotify.add(c)
|
|
53
|
+
else c.run()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function flush(): void {
|
|
58
|
+
const queued = [...pendingNotify]
|
|
59
|
+
pendingNotify.clear()
|
|
60
|
+
for (const c of queued) c.run()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function batch(fn: () => void): void {
|
|
64
|
+
batchDepth++
|
|
65
|
+
try {
|
|
66
|
+
fn()
|
|
67
|
+
} finally {
|
|
68
|
+
batchDepth--
|
|
69
|
+
if (batchDepth === 0) flush()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function signal<T>(initial: T): Signal<T> {
|
|
74
|
+
let value = initial
|
|
75
|
+
const subscribers = new Set<Consumer>()
|
|
76
|
+
const read = (() => {
|
|
77
|
+
track(subscribers)
|
|
78
|
+
return value
|
|
79
|
+
}) as Signal<T>
|
|
80
|
+
read.set = (next: T | ((prev: T) => T)) => {
|
|
81
|
+
const v = typeof next === 'function' ? (next as (p: T) => T)(value) : next
|
|
82
|
+
if (Object.is(v, value)) return
|
|
83
|
+
value = v
|
|
84
|
+
notify(subscribers)
|
|
85
|
+
}
|
|
86
|
+
Object.defineProperty(read, SIGNAL, { value: true })
|
|
87
|
+
return read
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function clearDeps(c: Consumer): void {
|
|
91
|
+
for (const dep of c.deps) dep.delete(c)
|
|
92
|
+
c.deps.clear()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function computed<T>(fn: () => T): Computed<T> {
|
|
96
|
+
let cached: T
|
|
97
|
+
let dirty = true
|
|
98
|
+
const subscribers = new Set<Consumer>()
|
|
99
|
+
const self: Consumer = {
|
|
100
|
+
deps: new Set(),
|
|
101
|
+
running: false,
|
|
102
|
+
run() {
|
|
103
|
+
if (self.running) return
|
|
104
|
+
self.running = true
|
|
105
|
+
try {
|
|
106
|
+
dirty = true
|
|
107
|
+
notify(subscribers) // downstream recomputes lazily on next read
|
|
108
|
+
} finally {
|
|
109
|
+
self.running = false
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
const read = (() => {
|
|
114
|
+
track(subscribers)
|
|
115
|
+
if (dirty) {
|
|
116
|
+
clearDeps(self)
|
|
117
|
+
const prev = activeConsumer
|
|
118
|
+
activeConsumer = self
|
|
119
|
+
try {
|
|
120
|
+
cached = fn()
|
|
121
|
+
dirty = false
|
|
122
|
+
} finally {
|
|
123
|
+
activeConsumer = prev
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return cached
|
|
127
|
+
}) as Computed<T>
|
|
128
|
+
Object.defineProperty(read, COMPUTED, { value: true })
|
|
129
|
+
return read
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function effect(fn: () => void): () => void {
|
|
133
|
+
const self: Consumer = {
|
|
134
|
+
deps: new Set(),
|
|
135
|
+
running: false,
|
|
136
|
+
run() {
|
|
137
|
+
if (self.running) return
|
|
138
|
+
self.running = true
|
|
139
|
+
clearDeps(self)
|
|
140
|
+
const prev = activeConsumer
|
|
141
|
+
activeConsumer = self
|
|
142
|
+
try {
|
|
143
|
+
fn()
|
|
144
|
+
} finally {
|
|
145
|
+
activeConsumer = prev
|
|
146
|
+
self.running = false
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
self.run()
|
|
151
|
+
return () => clearDeps(self)
|
|
152
|
+
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
// Cross-island sync bus.
|
|
2
|
-
//
|
|
3
|
-
// GAP S4: brust has no cross-island shared-state primitive. AddToTeamButton and
|
|
4
|
-
// TeamBuilder are two SEPARATE island chunks (each its own Bun.build bundle), so
|
|
5
|
-
// a module-scope store imported by both would be DUPLICATED — two instances that
|
|
6
|
-
// never see each other. The one thing both chunks genuinely share is the
|
|
7
|
-
// `window` object, so we coordinate through a window CustomEvent. This works,
|
|
8
|
-
// but it's a hand-rolled workaround for a pattern (cart / team / selection) that
|
|
9
|
-
// most apps need. See ../FRAMEWORK-GAPS.md S4.
|
|
10
|
-
|
|
11
|
-
import type { TeamMember } from '../lib/types'
|
|
12
|
-
|
|
13
|
-
export const TEAM_EVENT = 'brust-pokedex:team'
|
|
14
|
-
|
|
15
|
-
export function emitTeam(team: TeamMember[]): void {
|
|
16
|
-
if (typeof window === 'undefined') return
|
|
17
|
-
window.dispatchEvent(new CustomEvent<TeamMember[]>(TEAM_EVENT, { detail: team }))
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function onTeam(fn: (team: TeamMember[]) => void): () => void {
|
|
21
|
-
if (typeof window === 'undefined') return () => {}
|
|
22
|
-
const handler = (e: Event) => fn((e as CustomEvent<TeamMember[]>).detail)
|
|
23
|
-
window.addEventListener(TEAM_EVENT, handler)
|
|
24
|
-
return () => window.removeEventListener(TEAM_EVENT, handler)
|
|
25
|
-
}
|