@abide/abide 0.40.0 → 0.40.1
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/AGENTS.md +318 -146
- package/CHANGELOG.md +42 -0
- package/README.md +77 -79
- package/package.json +1 -1
- package/src/lib/shared/assertExhaustive.ts +14 -0
- package/src/lib/ui/compile/TS_PRINTER.ts +7 -0
- package/src/lib/ui/compile/analyzeComponent.ts +15 -16
- package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +66 -0
- package/src/lib/ui/compile/assertTranspiles.ts +19 -0
- package/src/lib/ui/compile/compileModule.ts +56 -34
- package/src/lib/ui/compile/compileSSR.ts +1 -3
- package/src/lib/ui/compile/desugarSignals.ts +168 -107
- package/src/lib/ui/compile/generateBuild.ts +8 -1
- package/src/lib/ui/compile/generateSSR.ts +7 -0
- package/src/lib/ui/compile/hoistCells.ts +2 -2
- package/src/lib/ui/compile/lowerContext.ts +23 -10
- package/src/lib/ui/compile/lowerDocAccess.ts +29 -3
- package/src/lib/ui/compile/lowerScript.ts +64 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +160 -102
- package/src/lib/ui/compile/stripEffects.ts +22 -17
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
- package/src/lib/ui/dom/awaitBlock.ts +8 -1
- package/src/lib/ui/dom/each.ts +4 -0
- package/src/lib/ui/dom/switchBlock.ts +4 -0
- package/src/lib/ui/dom/when.ts +4 -0
- package/src/lib/ui/installInspectorBridge.ts +3 -1
- package/src/lib/ui/router.ts +107 -14
- package/src/lib/ui/runtime/NODE_STATE.ts +22 -0
- package/src/lib/ui/runtime/clientPage.ts +114 -9
- package/src/lib/ui/runtime/createComputedNode.ts +5 -3
- package/src/lib/ui/runtime/createEffectNode.ts +3 -1
- package/src/lib/ui/runtime/createSignalNode.ts +4 -2
- package/src/lib/ui/runtime/flushEffects.ts +8 -5
- package/src/lib/ui/runtime/historyEntries.ts +39 -1
- package/src/lib/ui/runtime/readNode.ts +8 -7
- package/src/lib/ui/runtime/runNode.ts +18 -2
- package/src/lib/ui/runtime/scope.ts +12 -1
- package/src/lib/ui/runtime/trigger.ts +40 -24
- package/src/lib/ui/runtime/types/ReactiveNode.ts +4 -1
- package/src/lib/ui/runtime/updateIfNecessary.ts +40 -0
|
@@ -1,16 +1,121 @@
|
|
|
1
1
|
import type { PageSnapshot } from '../../shared/types/PageSnapshot.ts'
|
|
2
2
|
import { state } from '../state.ts'
|
|
3
|
+
import { flushEffects } from './flushEffects.ts'
|
|
4
|
+
import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
|
|
5
|
+
import type { State } from './types/State.ts'
|
|
3
6
|
|
|
4
7
|
/*
|
|
5
8
|
The client-side page snapshot the `page` proxy reads (startClient registers
|
|
6
|
-
`() => clientPage.value` as the page resolver).
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
`() => clientPage.value` as the page resolver). Server renders never touch this —
|
|
10
|
+
there the resolver reads the per-request store instead.
|
|
11
|
+
|
|
12
|
+
GRANULAR by field: rather than one signal holding the whole snapshot (which woke
|
|
13
|
+
every `page.*` reader on any navigation), each field is its own cell and each param
|
|
14
|
+
key its own lazily-created cell. So a reader of `page.params.id` subscribes to the id
|
|
15
|
+
cell alone and is NOT woken when `page.params.rest` changes (stepping between episodes
|
|
16
|
+
on one detail page) — no manual `computed` memo needed at the call site.
|
|
17
|
+
|
|
18
|
+
The `.value` get/set API is unchanged, so the router and tests still read
|
|
19
|
+
`clientPage.value.url` and write `clientPage.value = {…}`: the getter returns a STABLE
|
|
20
|
+
snapshot whose field accessors do the granular reads (returning a stable object is what
|
|
21
|
+
stops `clientPage.value` itself from subscribing to everything — a subscription happens
|
|
22
|
+
only when a field is read), and the setter reconciles each field cell (an Object.is-equal
|
|
23
|
+
write is a no-op, so an unchanged id never fires).
|
|
10
24
|
*/
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
25
|
+
|
|
26
|
+
const routeCell = state<string>('')
|
|
27
|
+
const navigatingCell = state<boolean>(false)
|
|
28
|
+
const urlCell = state<URL>(
|
|
29
|
+
typeof location === 'undefined' ? new URL('http://localhost/') : new URL(location.href),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
/* Per-param-key cells, created on first read/write of a key. A page that reads
|
|
33
|
+
`page.params.id` mints the id cell and subscribes to it alone. Keys persist across
|
|
34
|
+
navigations (the Map is bounded by the app's param vocabulary); a route that drops a
|
|
35
|
+
key sets its cell to undefined so its readers wake to the absence. */
|
|
36
|
+
const paramCells = new Map<string, State<string | undefined>>()
|
|
37
|
+
function paramCell(key: string): State<string | undefined> {
|
|
38
|
+
let cell = paramCells.get(key)
|
|
39
|
+
if (cell === undefined) {
|
|
40
|
+
cell = state<string>()
|
|
41
|
+
paramCells.set(key, cell)
|
|
42
|
+
}
|
|
43
|
+
return cell
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* The surface `page.params` exposes: each key access is a granular cell read, so it
|
|
47
|
+
tracks per-key like the underlying object would, but reactively. */
|
|
48
|
+
const paramsProxy = new Proxy({} as Record<string, string>, {
|
|
49
|
+
get: (_target, key) => (typeof key === 'string' ? paramCell(key).value : undefined),
|
|
50
|
+
has: (_target, key) => typeof key === 'string' && paramCell(key).value !== undefined,
|
|
51
|
+
ownKeys: () =>
|
|
52
|
+
[...paramCells].filter(([, cell]) => cell.value !== undefined).map(([key]) => key),
|
|
53
|
+
getOwnPropertyDescriptor: (_target, key) => {
|
|
54
|
+
if (typeof key !== 'string') {
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
const value = paramCell(key).value
|
|
58
|
+
if (value === undefined) {
|
|
59
|
+
return undefined
|
|
60
|
+
}
|
|
61
|
+
return { enumerable: true, configurable: true, value }
|
|
62
|
+
},
|
|
16
63
|
})
|
|
64
|
+
|
|
65
|
+
/* The stable snapshot `clientPage.value` returns — reading a field does the granular
|
|
66
|
+
cell read, so a reader subscribes to that field alone. */
|
|
67
|
+
const snapshot: PageSnapshot = {
|
|
68
|
+
get route(): string {
|
|
69
|
+
return routeCell.value
|
|
70
|
+
},
|
|
71
|
+
get params(): Record<string, string> {
|
|
72
|
+
return paramsProxy
|
|
73
|
+
},
|
|
74
|
+
get url(): URL {
|
|
75
|
+
return urlCell.value
|
|
76
|
+
},
|
|
77
|
+
get navigating(): boolean {
|
|
78
|
+
return navigatingCell.value
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* Reconcile the param cells to `next`: write each key (Object.is in the cell skips a
|
|
83
|
+
no-op, so an unchanged id stays asleep), clear any key the new route dropped. The
|
|
84
|
+
spread-and-rewrite paths (`{ ...clientPage.value, navigating }`) hand our own proxy
|
|
85
|
+
straight back — a no-op, recognised by identity. */
|
|
86
|
+
function reconcileParams(next: Record<string, string>): void {
|
|
87
|
+
if (next === paramsProxy) {
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
for (const key of Object.keys(next)) {
|
|
91
|
+
paramCell(key).value = next[key]
|
|
92
|
+
}
|
|
93
|
+
for (const [key, cell] of paramCells) {
|
|
94
|
+
if (!(key in next)) {
|
|
95
|
+
cell.value = undefined
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const clientPage: { value: PageSnapshot } = {
|
|
101
|
+
get value(): PageSnapshot {
|
|
102
|
+
return snapshot
|
|
103
|
+
},
|
|
104
|
+
set value(next: PageSnapshot) {
|
|
105
|
+
/* Batch the field writes so a navigation publishes the whole snapshot atomically:
|
|
106
|
+
without it each cell write flushes effects separately, so a reader of two fields
|
|
107
|
+
(e.g. `page.url` + `page.params.id`) re-runs once per field and transiently
|
|
108
|
+
observes a half-updated snapshot (new url, stale id). Same batch idiom as
|
|
109
|
+
`createDoc` — flush once, after every cell is reconciled. */
|
|
110
|
+
REACTIVE_CONTEXT.batchDepth += 1
|
|
111
|
+
try {
|
|
112
|
+
routeCell.value = next.route
|
|
113
|
+
urlCell.value = next.url
|
|
114
|
+
navigatingCell.value = next.navigating
|
|
115
|
+
reconcileParams(next.params)
|
|
116
|
+
} finally {
|
|
117
|
+
REACTIVE_CONTEXT.batchDepth -= 1
|
|
118
|
+
}
|
|
119
|
+
flushEffects()
|
|
120
|
+
},
|
|
121
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { NODE_STATE } from './NODE_STATE.ts'
|
|
1
2
|
import type { ReactiveNode } from './types/ReactiveNode.ts'
|
|
2
3
|
|
|
3
|
-
/* Creates a lazy computed node. Born
|
|
4
|
-
it
|
|
4
|
+
/* Creates a lazy computed node. Born DIRTY so its first read computes; thereafter a
|
|
5
|
+
read settles it — recomputing only when the check walk finds a dependency whose
|
|
6
|
+
value actually changed. */
|
|
5
7
|
export function createComputedNode(compute: () => unknown): ReactiveNode {
|
|
6
8
|
return {
|
|
7
9
|
value: undefined,
|
|
@@ -10,7 +12,7 @@ export function createComputedNode(compute: () => unknown): ReactiveNode {
|
|
|
10
12
|
depsTail: undefined,
|
|
11
13
|
subsHead: undefined,
|
|
12
14
|
subsTail: undefined,
|
|
13
|
-
|
|
15
|
+
status: NODE_STATE.DIRTY,
|
|
14
16
|
isEffect: false,
|
|
15
17
|
}
|
|
16
18
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { NODE_STATE } from './NODE_STATE.ts'
|
|
1
2
|
import { OWNER } from './OWNER.ts'
|
|
2
3
|
import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
|
|
3
4
|
import { runNode } from './runNode.ts'
|
|
@@ -42,7 +43,8 @@ export function createEffectNode(fn: () => EffectResult): () => void {
|
|
|
42
43
|
depsTail: undefined,
|
|
43
44
|
subsHead: undefined,
|
|
44
45
|
subsTail: undefined,
|
|
45
|
-
|
|
46
|
+
/* Born DIRTY; the immediate `runNode` below captures deps and settles it CLEAN. */
|
|
47
|
+
status: NODE_STATE.DIRTY,
|
|
46
48
|
isEffect: true,
|
|
47
49
|
}
|
|
48
50
|
runNode(node)
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { NODE_STATE } from './NODE_STATE.ts'
|
|
1
2
|
import type { ReactiveNode } from './types/ReactiveNode.ts'
|
|
2
3
|
|
|
3
4
|
/* Creates a writable leaf node holding `value` with no compute — the source a
|
|
4
|
-
document path or a `state()` cell is backed by.
|
|
5
|
+
document path or a `state()` cell is backed by. Always CLEAN: a signal has no
|
|
6
|
+
dependencies to settle; its value is whatever was last written. */
|
|
5
7
|
export function createSignalNode(value: unknown): ReactiveNode {
|
|
6
8
|
return {
|
|
7
9
|
value,
|
|
@@ -10,7 +12,7 @@ export function createSignalNode(value: unknown): ReactiveNode {
|
|
|
10
12
|
depsTail: undefined,
|
|
11
13
|
subsHead: undefined,
|
|
12
14
|
subsTail: undefined,
|
|
13
|
-
|
|
15
|
+
status: NODE_STATE.CLEAN,
|
|
14
16
|
isEffect: false,
|
|
15
17
|
}
|
|
16
18
|
}
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
|
|
2
|
-
import {
|
|
2
|
+
import { updateIfNecessary } from './updateIfNecessary.ts'
|
|
3
3
|
|
|
4
4
|
/*
|
|
5
|
-
Drains queued effects synchronously.
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
Drains queued effects synchronously. Each is queued when it first leaves CLEAN, but
|
|
6
|
+
runs only if `updateIfNecessary` finds a dependency that truly changed — a CHECK
|
|
7
|
+
effect whose deps all memoised back to equal values settles to CLEAN without
|
|
8
|
+
running its body. Snapshots and clears the queue each pass so an effect that dirties
|
|
9
|
+
further effects re-queues them for the next pass rather than mutating the set
|
|
10
|
+
mid-iteration; loops until the graph settles.
|
|
8
11
|
*/
|
|
9
12
|
export function flushEffects(): void {
|
|
10
13
|
while (REACTIVE_CONTEXT.pendingEffects.size > 0) {
|
|
11
14
|
const batch = [...REACTIVE_CONTEXT.pendingEffects]
|
|
12
15
|
REACTIVE_CONTEXT.pendingEffects.clear()
|
|
13
16
|
for (const node of batch) {
|
|
14
|
-
|
|
17
|
+
updateIfNecessary(node)
|
|
15
18
|
}
|
|
16
19
|
}
|
|
17
20
|
}
|
|
@@ -51,6 +51,39 @@ const offsets = new Map<number, [number, number]>()
|
|
|
51
51
|
let current = 0
|
|
52
52
|
let seq = 0
|
|
53
53
|
|
|
54
|
+
/* Generation token for an in-flight `restore` retry chain (below); a newer restore
|
|
55
|
+
bumps it so a stale chain — from a superseded navigation — stops re-applying. */
|
|
56
|
+
let restoreToken = 0
|
|
57
|
+
/* Frame budget for re-applying a saved offset while the page is still filling in.
|
|
58
|
+
~half a second at 60fps — long enough for an async page's content to settle, short
|
|
59
|
+
enough not to fight a user who starts scrolling. */
|
|
60
|
+
const MAX_RESTORE_FRAMES = 30
|
|
61
|
+
|
|
62
|
+
/* Re-apply a saved offset until it sticks. A restore can land before the page's async
|
|
63
|
+
content has materialised — `disposeFrom` empties the document, `buildFrom` mounts a
|
|
64
|
+
page whose blocking `<template await>` is still pending, so the document is momentarily
|
|
65
|
+
short and the browser clamps the requested offset to its tiny max (an in-page episode
|
|
66
|
+
swap reset to the top this way). Each frame re-applies; once `scrollTo` is no longer
|
|
67
|
+
clamped (the content has grown tall enough to honour the offset) the chain stops, so
|
|
68
|
+
the common case — a page already tall — applies exactly once and schedules no frame. */
|
|
69
|
+
function reapplyOffset(offset: [number, number], token: number): void {
|
|
70
|
+
const apply = (framesLeft: number): void => {
|
|
71
|
+
// A newer restore superseded this chain, or the scroll surface vanished.
|
|
72
|
+
if (token !== restoreToken || typeof view.scrollTo !== 'function') {
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
view.scrollTo(offset[0], offset[1])
|
|
76
|
+
// The browser clamps to the current max; an honoured offset means the page is
|
|
77
|
+
// tall enough now — stop. Otherwise retry next frame as the content fills in.
|
|
78
|
+
const reached = (view.scrollY ?? 0) >= offset[1] && (view.scrollX ?? 0) >= offset[0]
|
|
79
|
+
if (reached || framesLeft <= 0 || typeof view.requestAnimationFrame !== 'function') {
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
view.requestAnimationFrame(() => apply(framesLeft - 1))
|
|
83
|
+
}
|
|
84
|
+
apply(MAX_RESTORE_FRAMES)
|
|
85
|
+
}
|
|
86
|
+
|
|
54
87
|
export const historyEntries = {
|
|
55
88
|
/* The active history entry's id — stamped into history.state by `navigate`. */
|
|
56
89
|
get current(): number {
|
|
@@ -98,9 +131,14 @@ export const historyEntries = {
|
|
|
98
131
|
if (typeof view.scrollTo !== 'function') {
|
|
99
132
|
return
|
|
100
133
|
}
|
|
134
|
+
/* Any restore supersedes a pending retry chain — including this top/anchor path,
|
|
135
|
+
so a later navigation to a fresh page can't have an earlier keepScroll swap's
|
|
136
|
+
re-apply fire its stale offset over it. */
|
|
137
|
+
restoreToken += 1
|
|
138
|
+
const token = restoreToken
|
|
101
139
|
const offset = offsets.get(current) ?? persistedOffset()
|
|
102
140
|
if (offset !== undefined) {
|
|
103
|
-
|
|
141
|
+
reapplyOffset(offset, token)
|
|
104
142
|
return
|
|
105
143
|
}
|
|
106
144
|
const anchor = anchorFor(hash)
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
import { runNode } from './runNode.ts'
|
|
2
1
|
import { track } from './track.ts'
|
|
3
2
|
import type { ReactiveNode } from './types/ReactiveNode.ts'
|
|
3
|
+
import { updateIfNecessary } from './updateIfNecessary.ts'
|
|
4
4
|
|
|
5
5
|
/*
|
|
6
|
-
Reads a node's current value and subscribes the running observer to it. A
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
Reads a node's current value and subscribes the running observer to it. A computed
|
|
7
|
+
settles first (the lazy pull — `updateIfNecessary` refreshes only the deps that
|
|
8
|
+
changed, recomputing if any did); a signal has no compute and returns its stored
|
|
9
|
+
value directly. Tracking happens after the settle so the reader links to the
|
|
10
|
+
computed itself, not its transitive deps.
|
|
10
11
|
*/
|
|
11
12
|
export function readNode(node: ReactiveNode): unknown {
|
|
12
|
-
if (node.compute !== undefined
|
|
13
|
-
|
|
13
|
+
if (node.compute !== undefined) {
|
|
14
|
+
updateIfNecessary(node)
|
|
14
15
|
}
|
|
15
16
|
track(node)
|
|
16
17
|
return node.value
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { abortNode } from './abortNode.ts'
|
|
2
2
|
import { endTracking } from './endTracking.ts'
|
|
3
|
+
import { NODE_STATE } from './NODE_STATE.ts'
|
|
3
4
|
import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
|
|
4
5
|
import { reactiveAbortState } from './reactiveAbortState.ts'
|
|
5
6
|
import type { ReactiveNode } from './types/ReactiveNode.ts'
|
|
@@ -26,8 +27,23 @@ export function runNode(node: ReactiveNode): unknown {
|
|
|
26
27
|
const previous = REACTIVE_CONTEXT.observer
|
|
27
28
|
REACTIVE_CONTEXT.observer = node
|
|
28
29
|
try {
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
const next = node.compute?.()
|
|
31
|
+
/* Value memoisation: only a computed whose result actually changed marks its
|
|
32
|
+
subscribers DIRTY, so the in-progress check walk recomputes them. An
|
|
33
|
+
Object.is-equal recompute leaves them at CHECK — they settle back to CLEAN
|
|
34
|
+
on read without re-running, never waking downstream. An effect has no value
|
|
35
|
+
worth comparing and no subscribers, so this is a no-op for it (its body ran
|
|
36
|
+
inside compute above). The subscriber list isn't re-linked here — only its
|
|
37
|
+
members' `status` is bumped — so walking it live is safe. */
|
|
38
|
+
if (!Object.is(node.value, next)) {
|
|
39
|
+
node.value = next
|
|
40
|
+
let link = node.subsHead
|
|
41
|
+
while (link !== undefined) {
|
|
42
|
+
link.sub.status = NODE_STATE.DIRTY
|
|
43
|
+
link = link.nextSub
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
node.status = NODE_STATE.CLEAN
|
|
31
47
|
return node.value
|
|
32
48
|
} finally {
|
|
33
49
|
REACTIVE_CONTEXT.observer = previous
|
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
import { OWNER } from './OWNER.ts'
|
|
2
|
+
import { untrack } from './untrack.ts'
|
|
2
3
|
|
|
3
4
|
/*
|
|
4
5
|
Runs `build` under a fresh ownership scope so every effect and listener created
|
|
5
6
|
inside is collected, and returns a disposer that tears them all down in reverse
|
|
6
7
|
order (children before parents). Save/restore of the previous owner makes scopes
|
|
7
8
|
nest — a list row's scope sits inside its component's scope.
|
|
9
|
+
|
|
10
|
+
The build runs UNTRACKED. A detached subtree is built synchronously, and when that
|
|
11
|
+
build happens inside a swap effect (a control-flow block re-running `each`/`when`/
|
|
12
|
+
`switch`/`await`) its top-level reads would otherwise subscribe THAT effect, so any
|
|
13
|
+
in-content state change would re-run the block and rebuild the whole subtree. The
|
|
14
|
+
content's own interpolations still track normally — each wraps its read in its own
|
|
15
|
+
effect, which re-installs itself as the observer. Untracking here is the framework's
|
|
16
|
+
invariant (a build never subscribes its builder) stated once, so no control-flow
|
|
17
|
+
block has to remember it; it's a harmless no-op for top-level builds (mount/hydrate),
|
|
18
|
+
where there is no surrounding observer to leak into.
|
|
8
19
|
*/
|
|
9
20
|
export function scope(build: () => void): () => void {
|
|
10
21
|
const previous = OWNER.current
|
|
11
22
|
const disposers: Array<() => void> = []
|
|
12
23
|
OWNER.current = disposers
|
|
13
24
|
try {
|
|
14
|
-
build
|
|
25
|
+
untrack(build)
|
|
15
26
|
} finally {
|
|
16
27
|
OWNER.current = previous
|
|
17
28
|
}
|
|
@@ -1,43 +1,59 @@
|
|
|
1
1
|
import { flushEffects } from './flushEffects.ts'
|
|
2
|
+
import { NODE_STATE } from './NODE_STATE.ts'
|
|
2
3
|
import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
|
|
3
4
|
import type { ReactiveNode } from './types/ReactiveNode.ts'
|
|
4
5
|
|
|
5
6
|
/*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
`
|
|
14
|
-
|
|
15
|
-
defends with its own snapshot. `nextSub` is read before recursing, so the walk
|
|
16
|
-
holds no reference a downstream pass could invalidate.
|
|
7
|
+
Raises `node` to at least `status` and propagates the news up its subscriber cone.
|
|
8
|
+
An effect crossing out of CLEAN is queued (whether it became CHECK or DIRTY — the
|
|
9
|
+
flush decides whether it really runs). A computed crossing out of CLEAN propagates
|
|
10
|
+
CHECK to *its* subscribers, so the whole cone learns a dependency *might* have
|
|
11
|
+
changed; it does so exactly once (a later CHECK→DIRTY upgrade re-notifies nobody —
|
|
12
|
+
its subscribers are already CHECK). No compute runs here, so no subscriber list is
|
|
13
|
+
re-linked mid-walk (`track`/`runNode` run inside compute, which only the flush
|
|
14
|
+
reaches). `nextSub` is read before recursing so the walk holds no edge a deeper
|
|
15
|
+
pass could detach.
|
|
17
16
|
*/
|
|
18
|
-
function
|
|
17
|
+
function mark(node: ReactiveNode, status: number): void {
|
|
18
|
+
if (node.status >= status) {
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
const wasClean = node.status === NODE_STATE.CLEAN
|
|
22
|
+
node.status = status
|
|
23
|
+
if (node.isEffect) {
|
|
24
|
+
if (wasClean) {
|
|
25
|
+
REACTIVE_CONTEXT.pendingEffects.add(node)
|
|
26
|
+
}
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
/* A computed propagates CHECK to its subscribers only on its first move out of
|
|
30
|
+
CLEAN — they are already CHECK on any later upgrade, so re-walking is wasted. */
|
|
31
|
+
if (!wasClean) {
|
|
32
|
+
return
|
|
33
|
+
}
|
|
19
34
|
let link = node.subsHead
|
|
20
35
|
while (link !== undefined) {
|
|
21
|
-
const observer = link.sub
|
|
22
36
|
const next = link.nextSub
|
|
23
|
-
|
|
24
|
-
REACTIVE_CONTEXT.pendingEffects.add(observer)
|
|
25
|
-
} else if (!observer.dirty) {
|
|
26
|
-
observer.dirty = true
|
|
27
|
-
invalidate(observer)
|
|
28
|
-
}
|
|
37
|
+
mark(link.sub, NODE_STATE.CHECK)
|
|
29
38
|
link = next
|
|
30
39
|
}
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
/*
|
|
34
|
-
Propagates a change forward from a just-written
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
Propagates a change forward from a just-written signal: its direct subscribers read
|
|
44
|
+
a value that actually changed, so they are DIRTY; the rest of their cone is CHECK
|
|
45
|
+
(a transitive dependency *may* have changed — `updateIfNecessary` will verify on
|
|
46
|
+
read). Recompute is lazy. The queued effects flush once, at the outermost trigger
|
|
47
|
+
(or, inside a batch, when the batch owner flushes) — never mid-propagation, so an
|
|
48
|
+
effect never runs against a half-marked graph.
|
|
38
49
|
*/
|
|
39
50
|
export function trigger(node: ReactiveNode): void {
|
|
40
|
-
|
|
51
|
+
let link = node.subsHead
|
|
52
|
+
while (link !== undefined) {
|
|
53
|
+
const next = link.nextSub
|
|
54
|
+
mark(link.sub, NODE_STATE.DIRTY)
|
|
55
|
+
link = next
|
|
56
|
+
}
|
|
41
57
|
if (REACTIVE_CONTEXT.batchDepth === 0) {
|
|
42
58
|
flushEffects()
|
|
43
59
|
}
|
|
@@ -20,6 +20,9 @@ export type ReactiveNode = {
|
|
|
20
20
|
depsTail: ReactiveLink | undefined
|
|
21
21
|
subsHead: ReactiveLink | undefined
|
|
22
22
|
subsTail: ReactiveLink | undefined
|
|
23
|
-
|
|
23
|
+
/* The node's settle-state for push-pull propagation: CLEAN / CHECK / DIRTY (see
|
|
24
|
+
NODE_STATE). A signal is always CLEAN (no compute); a computed is born DIRTY and
|
|
25
|
+
cycles CLEAN→CHECK/DIRTY→CLEAN as deps change and reads settle it. */
|
|
26
|
+
status: number
|
|
24
27
|
isEffect: boolean
|
|
25
28
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { NODE_STATE } from './NODE_STATE.ts'
|
|
2
|
+
import { runNode } from './runNode.ts'
|
|
3
|
+
import type { ReactiveNode } from './types/ReactiveNode.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Settles a node so its value is current before it is read (or, for an effect, before
|
|
7
|
+
it runs at flush). CLEAN: nothing to do. CHECK: a transitive dependency *might* have
|
|
8
|
+
changed — refresh each direct dependency in turn; refreshing one that truly changed
|
|
9
|
+
marks this node DIRTY (`runNode` does the marking), so stop the moment that happens.
|
|
10
|
+
DIRTY (set directly by a write, or by the check walk): recompute.
|
|
11
|
+
|
|
12
|
+
A CHECK node whose dependencies all recompute to equal values ends CLEAN without
|
|
13
|
+
recomputing — the value memoisation that stops an unchanged computed from waking its
|
|
14
|
+
readers. Refreshing deps top-down *before* a recompute reads them is what keeps the
|
|
15
|
+
pull glitch-free: a reader never observes a stale intermediate, because by the time
|
|
16
|
+
it recomputes, every dependency it reads has already settled this pass.
|
|
17
|
+
*/
|
|
18
|
+
export function updateIfNecessary(node: ReactiveNode): void {
|
|
19
|
+
if (node.status === NODE_STATE.CLEAN) {
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
if (node.status === NODE_STATE.CHECK) {
|
|
23
|
+
let link = node.depsHead
|
|
24
|
+
while (link !== undefined) {
|
|
25
|
+
updateIfNecessary(link.dep)
|
|
26
|
+
/* A dep recomputed to a changed value and marked us DIRTY — no need to
|
|
27
|
+
check the rest, we already know we must recompute. */
|
|
28
|
+
if (node.status === NODE_STATE.DIRTY) {
|
|
29
|
+
break
|
|
30
|
+
}
|
|
31
|
+
link = link.nextDep
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (node.status === NODE_STATE.DIRTY) {
|
|
35
|
+
runNode(node)
|
|
36
|
+
} else {
|
|
37
|
+
/* CHECK with no changed dependency — the cached value still holds. */
|
|
38
|
+
node.status = NODE_STATE.CLEAN
|
|
39
|
+
}
|
|
40
|
+
}
|