brustjs 0.1.20-alpha → 0.1.22-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/README.md +12 -1
- package/example/pokedex/components/AddToTeamButton.tsx +59 -65
- package/example/pokedex/components/TeamBuilder.tsx +20 -10
- package/example/pokedex/lib/loaders.ts +13 -3
- package/example/pokedex/lib/types.ts +6 -3
- package/example/pokedex/pages/DetailPage.tsx +1 -2
- package/example/pokedex/stores/team.ts +14 -0
- package/package.json +10 -8
- package/runtime/cli/build.ts +41 -0
- package/runtime/cli/native-routes-emit.ts +28 -4
- package/runtime/client/index.ts +6 -0
- package/runtime/index.js +52 -52
- package/runtime/index.ts +32 -1
- package/runtime/islands/bootstrap.ts +7 -1
- package/runtime/islands/importmap.ts +6 -0
- package/runtime/native/build.ts +147 -0
- package/runtime/native/index.ts +3 -0
- package/runtime/native/runtime.ts +324 -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 +170 -0
- package/example/pokedex/components/team-bus.ts +0 -25
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { type Computed, type Signal, isComputed, isSignal } from './signal.ts'
|
|
2
|
+
import { parseStoreScript } from './serialize.ts'
|
|
3
|
+
|
|
4
|
+
// The resolved per-scope store record, shared by the client singleton registry
|
|
5
|
+
// and the server per-request map. `version` bumps on every signal write so
|
|
6
|
+
// `snapshot()` returns a referentially-stable object (useSyncExternalStore
|
|
7
|
+
// contract) until a change; `snap` memoizes it. `handle` is set only on the
|
|
8
|
+
// server record so collectSnapshot() can serialize a touched store.
|
|
9
|
+
export interface StoreInstanceRecord {
|
|
10
|
+
instance: object
|
|
11
|
+
subs: Set<() => void>
|
|
12
|
+
version: { n: number }
|
|
13
|
+
snap: { value: Record<string, unknown>; version: number } | null
|
|
14
|
+
handle?: { serialize(): Record<string, unknown> }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Server per-request resolver, injected by server-context.ts (which imports
|
|
18
|
+
// node:async_hooks) via __setServerResolver. This keeps define-store — reachable
|
|
19
|
+
// from browser/island bundles via brustjs/store and brustjs/client — free of any
|
|
20
|
+
// static Node-builtin import. On the client the resolver stays null and the
|
|
21
|
+
// window-singleton branch is used.
|
|
22
|
+
type ServerResolver = (name: string, create: () => StoreInstanceRecord) => StoreInstanceRecord
|
|
23
|
+
let serverResolver: ServerResolver | null = null
|
|
24
|
+
export function __setServerResolver(fn: ServerResolver): void {
|
|
25
|
+
serverResolver = fn
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Unwrap a Signal/Computed to its value type; `never` if it is neither. Uses
|
|
29
|
+
// `infer` (not `extends Signal<unknown>`): `Signal<T>.set(next: T)` is
|
|
30
|
+
// contravariant in T, so `Signal<TeamMember[]>` is NOT assignable to
|
|
31
|
+
// `Signal<unknown>` — a concrete-`unknown` check wrongly drops every typed signal
|
|
32
|
+
// key from the snapshot. `infer` sidesteps that.
|
|
33
|
+
type StoreValue<X> = X extends Signal<infer T> ? T : X extends Computed<infer T> ? T : never
|
|
34
|
+
|
|
35
|
+
// Snapshot = plain-value view of a store: Signal<T>/Computed<T> → T, plain values
|
|
36
|
+
// kept as-is, action functions dropped. The `[…] extends [never]` tuple wrap stops
|
|
37
|
+
// `never` from distributing the conditional to `never`.
|
|
38
|
+
export type Snapshot<S> = {
|
|
39
|
+
[K in keyof S as [StoreValue<S[K]>] extends [never]
|
|
40
|
+
? S[K] extends (...a: never[]) => unknown
|
|
41
|
+
? never
|
|
42
|
+
: K
|
|
43
|
+
: K]: [StoreValue<S[K]>] extends [never] ? S[K] : StoreValue<S[K]>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// `then` is reserved so a store with a signal named `then` can't make the proxy
|
|
47
|
+
// an accidental thenable (await/Promise.resolve(handle) misbehaving).
|
|
48
|
+
const RESERVED = new Set(['name', 'subscribe', 'snapshot', 'serialize', 'hydrate', 'then'])
|
|
49
|
+
|
|
50
|
+
export interface StoreHandle<S extends object> {
|
|
51
|
+
(): S
|
|
52
|
+
readonly name: string
|
|
53
|
+
subscribe(cb: () => void): () => void
|
|
54
|
+
snapshot(): Snapshot<S>
|
|
55
|
+
serialize(): Record<string, unknown>
|
|
56
|
+
hydrate(state: Record<string, unknown>): void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface ClientRegistry {
|
|
60
|
+
[name: string]: StoreInstanceRecord
|
|
61
|
+
}
|
|
62
|
+
function clientRegistry(): ClientRegistry {
|
|
63
|
+
const w = window as unknown as { __BRUST_STORES__?: ClientRegistry }
|
|
64
|
+
if (!w.__BRUST_STORES__) w.__BRUST_STORES__ = {}
|
|
65
|
+
return w.__BRUST_STORES__
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// We need each instance's signals to notify the store's subscriber set on write.
|
|
69
|
+
// signal.ts subscribers are internal; to bridge to React's subscribe, defineStore
|
|
70
|
+
// wraps the instance: after factory(), for every signal property we wrap .set to
|
|
71
|
+
// also bump the version and fire the store-level subscriber set. (computed
|
|
72
|
+
// downstream of those signals recomputes lazily; React re-reads snapshot.)
|
|
73
|
+
function bridgeSubscribers(
|
|
74
|
+
instance: Record<string, unknown>,
|
|
75
|
+
subs: Set<() => void>,
|
|
76
|
+
version: { n: number },
|
|
77
|
+
): void {
|
|
78
|
+
for (const key of Object.keys(instance)) {
|
|
79
|
+
const v = instance[key]
|
|
80
|
+
if (isSignal(v)) {
|
|
81
|
+
const sig = v as Signal<unknown>
|
|
82
|
+
const origSet = sig.set.bind(sig)
|
|
83
|
+
sig.set = (next) => {
|
|
84
|
+
// origSet is a no-op when Object.is(prev,next). Reading sig() here is
|
|
85
|
+
// outside any active consumer, so it registers no dependency — we can
|
|
86
|
+
// compare before/after and only bump+notify on a real change, avoiding
|
|
87
|
+
// spurious React re-renders.
|
|
88
|
+
const before = sig()
|
|
89
|
+
origSet(next as never)
|
|
90
|
+
const after = sig()
|
|
91
|
+
if (Object.is(before, after)) return
|
|
92
|
+
version.n += 1
|
|
93
|
+
for (const cb of [...subs]) cb()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function defineStore<S extends object>(name: string, factory: () => S): StoreHandle<S> & S {
|
|
100
|
+
function createRecord(): StoreInstanceRecord {
|
|
101
|
+
const instance = factory() as object
|
|
102
|
+
const subs = new Set<() => void>()
|
|
103
|
+
const version = { n: 0 }
|
|
104
|
+
bridgeSubscribers(instance as Record<string, unknown>, subs, version)
|
|
105
|
+
return { instance, subs, version, snap: null }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resolve(): StoreInstanceRecord {
|
|
109
|
+
if (typeof window !== 'undefined') {
|
|
110
|
+
const reg = clientRegistry()
|
|
111
|
+
if (!reg[name]) {
|
|
112
|
+
reg[name] = createRecord()
|
|
113
|
+
// hydrate from server-injected <script> if present (first access only).
|
|
114
|
+
const el = document.querySelector(`script[data-brust-store="${name}"]`)
|
|
115
|
+
if (el) hydrateRecord(reg[name], parseStoreScript(el))
|
|
116
|
+
}
|
|
117
|
+
return reg[name]
|
|
118
|
+
}
|
|
119
|
+
// server: per-request via the resolver injected by server-context.ts.
|
|
120
|
+
if (!serverResolver) {
|
|
121
|
+
throw new Error(`store '${name}' accessed on the server without a request scope`)
|
|
122
|
+
}
|
|
123
|
+
return serverResolver(name, () => ({
|
|
124
|
+
...createRecord(),
|
|
125
|
+
handle: handle as StoreHandle<object>,
|
|
126
|
+
}))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function hydrateRecord(rec: StoreInstanceRecord, state: Record<string, unknown>): void {
|
|
130
|
+
const inst = rec.instance as Record<string, unknown>
|
|
131
|
+
for (const key of Object.keys(state)) {
|
|
132
|
+
const v = inst[key]
|
|
133
|
+
if (isSignal(v)) (v as Signal<unknown>).set(state[key])
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const handle = (() => resolve().instance) as StoreHandle<S> & S
|
|
138
|
+
Object.defineProperty(handle, 'name', { value: name })
|
|
139
|
+
handle.subscribe = (cb) => {
|
|
140
|
+
const rec = resolve()
|
|
141
|
+
rec.subs.add(cb)
|
|
142
|
+
return () => rec.subs.delete(cb)
|
|
143
|
+
}
|
|
144
|
+
handle.snapshot = () => {
|
|
145
|
+
const rec = resolve()
|
|
146
|
+
if (rec.snap && rec.snap.version === rec.version.n) {
|
|
147
|
+
return rec.snap.value as Snapshot<S>
|
|
148
|
+
}
|
|
149
|
+
const out: Record<string, unknown> = {}
|
|
150
|
+
for (const key of Object.keys(rec.instance as object)) {
|
|
151
|
+
const v = (rec.instance as Record<string, unknown>)[key]
|
|
152
|
+
if (isSignal(v) || isComputed(v)) out[key] = (v as () => unknown)()
|
|
153
|
+
}
|
|
154
|
+
rec.snap = { value: out, version: rec.version.n }
|
|
155
|
+
return out as Snapshot<S>
|
|
156
|
+
}
|
|
157
|
+
handle.serialize = () => {
|
|
158
|
+
const rec = resolve()
|
|
159
|
+
const out: Record<string, unknown> = {}
|
|
160
|
+
for (const key of Object.keys(rec.instance as object)) {
|
|
161
|
+
const v = (rec.instance as Record<string, unknown>)[key]
|
|
162
|
+
if (isSignal(v)) out[key] = (v as () => unknown)()
|
|
163
|
+
}
|
|
164
|
+
return out
|
|
165
|
+
}
|
|
166
|
+
handle.hydrate = (state) => {
|
|
167
|
+
hydrateRecord(resolve(), state)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return new Proxy(handle, {
|
|
171
|
+
get(target, prop, recv) {
|
|
172
|
+
if (typeof prop === 'symbol' || RESERVED.has(prop)) {
|
|
173
|
+
return Reflect.get(target, prop, recv)
|
|
174
|
+
}
|
|
175
|
+
const rec = resolve()
|
|
176
|
+
return (rec.instance as Record<string | symbol, unknown>)[prop]
|
|
177
|
+
},
|
|
178
|
+
}) as StoreHandle<S> & S
|
|
179
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// runtime/store/index.ts — brustjs/store. Isomorphic, framework-free, dom-free.
|
|
2
|
+
// No UI-framework adapter is re-exported here (the view-layer binding lives
|
|
3
|
+
// separately and is reachable only from the brustjs main entry).
|
|
4
|
+
export { signal, computed, effect, batch, isSignal, isComputed } from './signal.ts'
|
|
5
|
+
export type { Signal, Computed } from './signal.ts'
|
|
6
|
+
export { defineStore } from './define-store.ts'
|
|
7
|
+
export type { StoreHandle, Snapshot } from './define-store.ts'
|
|
8
|
+
export { toScriptJson, parseStoreScript, storeScriptTag } from './serialize.ts'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// runtime/store/react.ts — React adapter. Exported from the brustjs MAIN entry,
|
|
2
|
+
// never from ./store (which must stay react-free).
|
|
3
|
+
import { useSyncExternalStore } from 'react'
|
|
4
|
+
import type { Snapshot, StoreHandle } from './define-store.ts'
|
|
5
|
+
|
|
6
|
+
export function useStore<S extends object>(store: StoreHandle<S> & S): Snapshot<S> {
|
|
7
|
+
// An `ssr` island can render (renderToString) on the server OUTSIDE a request
|
|
8
|
+
// store scope — e.g. an ssr island on a native-jinja page, whose SSR runs after
|
|
9
|
+
// the loader's `runInStoreContext` scope has closed. `store.snapshot()` throws
|
|
10
|
+
// there (the S6 out-of-scope guard). Degrade to an empty snapshot for the
|
|
11
|
+
// server render so the island SSRs its factory-default markup; the client
|
|
12
|
+
// resolves the real window singleton and hydrates. Components that need a
|
|
13
|
+
// specific initial on first paint should drive it from props until mounted
|
|
14
|
+
// (Spec A does not server-seed native client state — that is Spec B).
|
|
15
|
+
const serverSnapshot = (): Snapshot<S> => {
|
|
16
|
+
try {
|
|
17
|
+
return store.snapshot()
|
|
18
|
+
} catch {
|
|
19
|
+
return {} as Snapshot<S>
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return useSyncExternalStore(store.subscribe, store.snapshot, serverSnapshot)
|
|
23
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// JSON for embedding in a <script> TEXT node (not an attribute). brust runs
|
|
2
|
+
// AutoEscape::None and a request-derived value can reach a serialized signal, so
|
|
3
|
+
// escape against </script> / <!-- breakout. See memory brust-jinja-autoescape-none.
|
|
4
|
+
|
|
5
|
+
const ESC: Record<string, string> = {
|
|
6
|
+
'<': '\\u003c',
|
|
7
|
+
'>': '\\u003e',
|
|
8
|
+
'&': '\\u0026',
|
|
9
|
+
'\u2028': '\\u2028',
|
|
10
|
+
'\u2029': '\\u2029',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function toScriptJson(value: unknown): string {
|
|
14
|
+
return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (c) => ESC[c])
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function storeScriptTag(name: string, state: unknown): string {
|
|
18
|
+
// name comes from defineStore (developer literal), not request data; still
|
|
19
|
+
// guard the attribute against quote breakout by rejecting unexpected chars.
|
|
20
|
+
const safeName = String(name).replace(/[^a-zA-Z0-9_.:-]/g, '')
|
|
21
|
+
return `<script type="application/json" data-brust-store="${safeName}">${toScriptJson(state)}</script>`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseStoreScript(el: { textContent: string | null }): Record<string, unknown> {
|
|
25
|
+
const text = el.textContent ?? '{}'
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(text) as Record<string, unknown>
|
|
28
|
+
} catch (e) {
|
|
29
|
+
console.warn('[brust] store: invalid snapshot JSON', e)
|
|
30
|
+
return {}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// This module is the ONLY one in runtime/store that imports a Node builtin
|
|
2
|
+
// (node:async_hooks). define-store.ts must NOT import it statically — it is
|
|
3
|
+
// reachable from browser/island bundles (brustjs/store, brustjs/client). Instead
|
|
4
|
+
// we register the per-request resolver into define-store via __setServerResolver
|
|
5
|
+
// at module load; the server pulls this module in via routes.ts (runInStoreContext
|
|
6
|
+
// / collectSnapshot), so the resolver is installed before any request runs.
|
|
7
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
8
|
+
import { __setServerResolver, type StoreInstanceRecord } from './define-store.ts'
|
|
9
|
+
|
|
10
|
+
const storeContext = new AsyncLocalStorage<Map<string, StoreInstanceRecord>>()
|
|
11
|
+
|
|
12
|
+
export function runInStoreContext<T>(fn: () => T): T {
|
|
13
|
+
return storeContext.run(new Map(), fn)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Resolve (create-once) the per-request instance for `name` in the active scope.
|
|
17
|
+
// Exported for direct unit testing; production code reaches it via the resolver
|
|
18
|
+
// registered into define-store.ts below.
|
|
19
|
+
export function getServerInstance(
|
|
20
|
+
name: string,
|
|
21
|
+
create: () => StoreInstanceRecord,
|
|
22
|
+
): StoreInstanceRecord {
|
|
23
|
+
const map = storeContext.getStore()
|
|
24
|
+
if (!map) {
|
|
25
|
+
throw new Error(`store '${name}' accessed outside a request scope`)
|
|
26
|
+
}
|
|
27
|
+
let rec = map.get(name)
|
|
28
|
+
if (!rec) {
|
|
29
|
+
rec = create()
|
|
30
|
+
map.set(name, rec)
|
|
31
|
+
}
|
|
32
|
+
return rec
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function collectSnapshot(): Record<string, Record<string, unknown>> | null {
|
|
36
|
+
const map = storeContext.getStore()
|
|
37
|
+
if (!map || map.size === 0) return null
|
|
38
|
+
const out: Record<string, Record<string, unknown>> = {}
|
|
39
|
+
for (const [name, rec] of map) out[name] = rec.handle ? rec.handle.serialize() : {}
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
__setServerResolver(getServerInstance)
|
|
@@ -0,0 +1,170 @@
|
|
|
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
|
+
// The dependency-tracking state (the "currently running consumer", the batch
|
|
39
|
+
// depth, the pending-notify queue) MUST be shared across chunks, for the SAME
|
|
40
|
+
// reason the brands use Symbol.for: every island / the directive runtime is a
|
|
41
|
+
// SEPARATE Bun.build that inlines its own copy of THIS module. If `activeConsumer`
|
|
42
|
+
// were a module-local `let`, each chunk would have its own — so an `effect` in
|
|
43
|
+
// chunk B reading a `signal` created in chunk A would register against chunk A's
|
|
44
|
+
// (always-null) activeConsumer and never subscribe. Concretely: a native directive
|
|
45
|
+
// button's effect (its own chunk) would never re-run when a React island (another
|
|
46
|
+
// chunk) mutated the shared store, even though the store value changed. Holding the
|
|
47
|
+
// context on `globalThis` under a Symbol.for key makes all chunks share ONE tracker.
|
|
48
|
+
interface ReactiveCtx {
|
|
49
|
+
activeConsumer: Consumer | null
|
|
50
|
+
batchDepth: number
|
|
51
|
+
pendingNotify: Set<Consumer>
|
|
52
|
+
}
|
|
53
|
+
const CTX_KEY = Symbol.for('brust.reactive.ctx')
|
|
54
|
+
const ctxHolder = globalThis as { [CTX_KEY]?: ReactiveCtx }
|
|
55
|
+
if (!ctxHolder[CTX_KEY]) {
|
|
56
|
+
ctxHolder[CTX_KEY] = { activeConsumer: null, batchDepth: 0, pendingNotify: new Set<Consumer>() }
|
|
57
|
+
}
|
|
58
|
+
const ctx: ReactiveCtx = ctxHolder[CTX_KEY]
|
|
59
|
+
|
|
60
|
+
function track(subscribers: Set<Consumer>): void {
|
|
61
|
+
if (ctx.activeConsumer) {
|
|
62
|
+
subscribers.add(ctx.activeConsumer)
|
|
63
|
+
ctx.activeConsumer.deps.add(subscribers)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function notify(subscribers: Set<Consumer>): void {
|
|
68
|
+
// Snapshot — a consumer re-running mutates the set.
|
|
69
|
+
for (const c of [...subscribers]) {
|
|
70
|
+
if (ctx.batchDepth > 0) ctx.pendingNotify.add(c)
|
|
71
|
+
else c.run()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function flush(): void {
|
|
76
|
+
const queued = [...ctx.pendingNotify]
|
|
77
|
+
ctx.pendingNotify.clear()
|
|
78
|
+
for (const c of queued) c.run()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function batch(fn: () => void): void {
|
|
82
|
+
ctx.batchDepth++
|
|
83
|
+
try {
|
|
84
|
+
fn()
|
|
85
|
+
} finally {
|
|
86
|
+
ctx.batchDepth--
|
|
87
|
+
if (ctx.batchDepth === 0) flush()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function signal<T>(initial: T): Signal<T> {
|
|
92
|
+
let value = initial
|
|
93
|
+
const subscribers = new Set<Consumer>()
|
|
94
|
+
const read = (() => {
|
|
95
|
+
track(subscribers)
|
|
96
|
+
return value
|
|
97
|
+
}) as Signal<T>
|
|
98
|
+
read.set = (next: T | ((prev: T) => T)) => {
|
|
99
|
+
const v = typeof next === 'function' ? (next as (p: T) => T)(value) : next
|
|
100
|
+
if (Object.is(v, value)) return
|
|
101
|
+
value = v
|
|
102
|
+
notify(subscribers)
|
|
103
|
+
}
|
|
104
|
+
Object.defineProperty(read, SIGNAL, { value: true })
|
|
105
|
+
return read
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function clearDeps(c: Consumer): void {
|
|
109
|
+
for (const dep of c.deps) dep.delete(c)
|
|
110
|
+
c.deps.clear()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function computed<T>(fn: () => T): Computed<T> {
|
|
114
|
+
let cached: T
|
|
115
|
+
let dirty = true
|
|
116
|
+
const subscribers = new Set<Consumer>()
|
|
117
|
+
const self: Consumer = {
|
|
118
|
+
deps: new Set(),
|
|
119
|
+
running: false,
|
|
120
|
+
run() {
|
|
121
|
+
if (self.running) return
|
|
122
|
+
self.running = true
|
|
123
|
+
try {
|
|
124
|
+
dirty = true
|
|
125
|
+
notify(subscribers) // downstream recomputes lazily on next read
|
|
126
|
+
} finally {
|
|
127
|
+
self.running = false
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
const read = (() => {
|
|
132
|
+
track(subscribers)
|
|
133
|
+
if (dirty) {
|
|
134
|
+
clearDeps(self)
|
|
135
|
+
const prev = ctx.activeConsumer
|
|
136
|
+
ctx.activeConsumer = self
|
|
137
|
+
try {
|
|
138
|
+
cached = fn()
|
|
139
|
+
dirty = false
|
|
140
|
+
} finally {
|
|
141
|
+
ctx.activeConsumer = prev
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return cached
|
|
145
|
+
}) as Computed<T>
|
|
146
|
+
Object.defineProperty(read, COMPUTED, { value: true })
|
|
147
|
+
return read
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function effect(fn: () => void): () => void {
|
|
151
|
+
const self: Consumer = {
|
|
152
|
+
deps: new Set(),
|
|
153
|
+
running: false,
|
|
154
|
+
run() {
|
|
155
|
+
if (self.running) return
|
|
156
|
+
self.running = true
|
|
157
|
+
clearDeps(self)
|
|
158
|
+
const prev = ctx.activeConsumer
|
|
159
|
+
ctx.activeConsumer = self
|
|
160
|
+
try {
|
|
161
|
+
fn()
|
|
162
|
+
} finally {
|
|
163
|
+
ctx.activeConsumer = prev
|
|
164
|
+
self.running = false
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
self.run()
|
|
169
|
+
return () => clearDeps(self)
|
|
170
|
+
}
|
|
@@ -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
|
-
}
|