@abide/abide 0.33.1 → 0.34.0
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 +39 -34
- package/CHANGELOG.md +24 -0
- package/README.md +21 -21
- package/package.json +5 -5
- package/src/abideResolverPlugin.ts +1 -1
- package/src/build.ts +1 -1
- package/src/compile.ts +1 -1
- package/src/lib/bundle/BundleMenu.ts +1 -1
- package/src/lib/bundle/BundleMenuItem.ts +1 -1
- package/src/lib/bundle/BundleWindow.ts +1 -1
- package/src/lib/bundle/bundled.ts +1 -1
- package/src/lib/bundle/disconnected.abide +105 -75
- package/src/lib/bundle/onMenu.ts +1 -1
- package/src/lib/mcp/createMcpServer.ts +1 -1
- package/src/lib/server/AppModule.ts +1 -1
- package/src/lib/server/DELETE.ts +1 -1
- package/src/lib/server/GET.ts +1 -1
- package/src/lib/server/HEAD.ts +1 -1
- package/src/lib/server/PATCH.ts +1 -1
- package/src/lib/server/POST.ts +1 -1
- package/src/lib/server/PUT.ts +1 -1
- package/src/lib/server/agent.ts +1 -1
- package/src/lib/server/appDataDir.ts +1 -1
- package/src/lib/server/cookies.ts +1 -1
- package/src/lib/server/env.ts +1 -1
- package/src/lib/server/error.ts +1 -1
- package/src/lib/server/json.ts +1 -1
- package/src/lib/server/jsonl.ts +1 -1
- package/src/lib/server/prompts/definePrompt.ts +1 -1
- package/src/lib/server/prompts/renderPromptTemplate.ts +1 -1
- package/src/lib/server/reachable.ts +1 -1
- package/src/lib/server/redirect.ts +1 -1
- package/src/lib/server/request.ts +1 -1
- package/src/lib/server/rpc/defineVerb.ts +1 -1
- package/src/lib/server/runtime/buildCacheSnapshot.ts +1 -1
- package/src/lib/server/runtime/types/InspectorCacheEntry.ts +2 -2
- package/src/lib/server/runtime/types/InspectorContext.ts +2 -2
- package/src/lib/server/server.ts +1 -1
- package/src/lib/server/socket.ts +1 -1
- package/src/lib/server/sockets/defineSocket.ts +1 -1
- package/src/lib/server/sse.ts +1 -1
- package/src/lib/shared/HttpError.ts +1 -1
- package/src/lib/shared/cache.ts +18 -18
- package/src/lib/shared/createSubscriber.ts +1 -1
- package/src/lib/shared/health.ts +1 -1
- package/src/lib/shared/html.ts +1 -1
- package/src/lib/shared/isSubscribable.ts +1 -1
- package/src/lib/shared/log.ts +1 -1
- package/src/lib/shared/online.ts +1 -1
- package/src/lib/shared/page.ts +1 -1
- package/src/lib/shared/pending.ts +2 -2
- package/src/lib/shared/probeRegistries.ts +2 -2
- package/src/lib/shared/refreshing.ts +2 -2
- package/src/lib/shared/selectorMatcher.ts +8 -8
- package/src/lib/shared/selectorPrefix.ts +1 -1
- package/src/lib/shared/snippet.ts +1 -1
- package/src/lib/shared/toTagSet.ts +4 -0
- package/src/lib/shared/trace.ts +1 -1
- package/src/lib/shared/types/CacheEntry.ts +3 -3
- package/src/lib/shared/types/CacheOptions.ts +3 -3
- package/src/lib/shared/types/CacheSelector.ts +2 -2
- package/src/lib/shared/url.ts +1 -1
- package/src/lib/shared/withJsonSchema.ts +1 -1
- package/src/lib/test/assertAgentFrameConformance.ts +1 -1
- package/src/lib/test/createScriptedSurface.ts +1 -1
- package/src/lib/test/createTestApp.ts +1 -1
- package/src/lib/ui/compile/REACTIVE_CALLEES.ts +7 -2
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +3 -4
- package/src/lib/ui/compile/abideUiPlugin.ts +1 -1
- package/src/lib/ui/compile/analyzeComponent.ts +2 -1
- package/src/lib/ui/compile/compileComponent.ts +3 -3
- package/src/lib/ui/compile/compileModule.ts +30 -2
- package/src/lib/ui/compile/compileSSR.ts +5 -5
- package/src/lib/ui/compile/compileShadow.ts +54 -36
- package/src/lib/ui/compile/desugarSignals.ts +147 -18
- package/src/lib/ui/compile/generateBuild.ts +15 -28
- package/src/lib/ui/compile/generateSSR.ts +20 -13
- package/src/lib/ui/compile/isControlFlow.ts +18 -0
- package/src/lib/ui/compile/isTextLeaf.ts +15 -0
- package/src/lib/ui/compile/lowerContext.ts +45 -5
- package/src/lib/ui/compile/prepareNestedScript.ts +12 -8
- package/src/lib/ui/compile/renameSignalRefs.ts +21 -4
- package/src/lib/ui/compile/skeletonable.ts +1 -14
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +1 -0
- package/src/lib/ui/computed.ts +32 -0
- package/src/lib/ui/createScope.ts +91 -0
- package/src/lib/ui/dom/anchorCursor.ts +1 -1
- package/src/lib/ui/dom/appendSnippet.ts +1 -1
- package/src/lib/ui/dom/appendStatic.ts +1 -1
- package/src/lib/ui/dom/appendText.ts +1 -1
- package/src/lib/ui/dom/appendTextAt.ts +1 -1
- package/src/lib/ui/dom/applyResolved.ts +1 -1
- package/src/lib/ui/dom/attach.ts +7 -2
- package/src/lib/ui/dom/attr.ts +1 -1
- package/src/lib/ui/dom/awaitBlock.ts +1 -1
- package/src/lib/ui/dom/cloneStatic.ts +1 -1
- package/src/lib/ui/dom/each.ts +4 -3
- package/src/lib/ui/dom/eachAsync.ts +1 -1
- package/src/lib/ui/dom/hydrate.ts +20 -6
- package/src/lib/ui/dom/mount.ts +12 -1
- package/src/lib/ui/dom/mountChild.ts +6 -2
- package/src/lib/ui/dom/mountSlot.ts +1 -1
- package/src/lib/ui/dom/on.ts +10 -4
- package/src/lib/ui/dom/openMarker.ts +3 -3
- package/src/lib/ui/dom/skeleton.ts +13 -5
- package/src/lib/ui/dom/switchBlock.ts +1 -1
- package/src/lib/ui/dom/text.ts +1 -1
- package/src/lib/ui/dom/tryBlock.ts +1 -1
- package/src/lib/ui/dom/when.ts +1 -1
- package/src/lib/ui/effect.ts +7 -2
- package/src/lib/ui/enterScope.ts +18 -0
- package/src/lib/ui/exitScope.ts +8 -0
- package/src/lib/ui/history.ts +102 -0
- package/src/lib/ui/installHotBridge.ts +6 -8
- package/src/lib/ui/linked.ts +0 -1
- package/src/lib/ui/navigate.ts +1 -1
- package/src/lib/ui/outbox.ts +115 -0
- package/src/lib/ui/persist.ts +112 -0
- package/src/lib/ui/probeNavigation.ts +1 -1
- package/src/lib/ui/remoteProxy.ts +1 -1
- package/src/lib/ui/renderToStream.ts +1 -1
- package/src/lib/ui/router.ts +1 -1
- package/src/lib/ui/runtime/CURRENT_SCOPE.ts +10 -0
- package/src/lib/ui/runtime/OUTLET_TAG.ts +1 -1
- package/src/lib/ui/runtime/PATCH_BUS.ts +28 -0
- package/src/lib/ui/runtime/RESUME.ts +1 -1
- package/src/lib/ui/runtime/captureModelDoc.ts +29 -0
- package/src/lib/ui/runtime/claimExpected.ts +26 -0
- package/src/lib/ui/runtime/createComputedNode.ts +1 -1
- package/src/lib/ui/runtime/createDoc.ts +73 -3
- package/src/lib/ui/runtime/enterRenderPass.ts +1 -1
- package/src/lib/ui/runtime/escapeKey.ts +13 -0
- package/src/lib/ui/runtime/exitRenderPass.ts +1 -1
- package/src/lib/ui/runtime/hotReplace.ts +17 -8
- package/src/lib/ui/runtime/inScope.ts +19 -0
- package/src/lib/ui/runtime/localStoragePersistence.ts +34 -0
- package/src/lib/ui/runtime/nextBlockId.ts +1 -1
- package/src/lib/ui/runtime/seedModelDoc.ts +26 -0
- package/src/lib/ui/runtime/types/{Derived.ts → Computed.ts} +1 -1
- package/src/lib/ui/runtime/types/Doc.ts +3 -0
- package/src/lib/ui/runtime/types/HotInstance.ts +6 -2
- package/src/lib/ui/runtime/types/PatchEvent.ts +16 -0
- package/src/lib/ui/runtime/unescapeKey.ts +12 -0
- package/src/lib/ui/runtime/walkPath.ts +3 -1
- package/src/lib/ui/scope.ts +19 -0
- package/src/lib/ui/socketProxy.ts +1 -1
- package/src/lib/ui/startClient.ts +1 -1
- package/src/lib/ui/state.ts +1 -2
- package/src/lib/ui/sync.ts +42 -0
- package/src/lib/ui/tail.ts +1 -1
- package/src/lib/ui/types/History.ts +14 -0
- package/src/lib/ui/types/Outbox.ts +14 -0
- package/src/lib/ui/types/PersistHandle.ts +11 -0
- package/src/lib/ui/types/PersistenceStore.ts +12 -0
- package/src/lib/ui/types/Scope.ts +57 -0
- package/src/lib/ui/types/SyncTransport.ts +13 -0
- package/src/preload.ts +1 -1
- package/src/lib/shared/toScopeSet.ts +0 -4
- package/src/lib/ui/derived.ts +0 -49
- package/src/lib/ui/doc.ts +0 -15
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { PATCH_BUS } from './PATCH_BUS.ts'
|
|
2
|
+
import type { Doc } from './types/Doc.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Runs a component `build` and returns its disposer alongside the component's own
|
|
6
|
+
`model` document — the serializable `state` doc, needed so a hot swap can carry
|
|
7
|
+
its value across (see `hotReplace`). The model is found, not threaded: a component
|
|
8
|
+
seeds its `model` first (the desugared `const model = doc({})` + its init patches
|
|
9
|
+
run before any child mounts or control-flow blocks), so the FIRST patch announced
|
|
10
|
+
on the bus during the build names it. A component with no `state` mints no model
|
|
11
|
+
and emits nothing first — `model` is then `undefined` and there is nothing to
|
|
12
|
+
preserve. Used only on the hot path; the subscription is torn down with the build.
|
|
13
|
+
*/
|
|
14
|
+
export function captureModelDoc(build: () => () => void): {
|
|
15
|
+
dispose: () => void
|
|
16
|
+
model: Doc | undefined
|
|
17
|
+
} {
|
|
18
|
+
let model: Doc | undefined
|
|
19
|
+
const unsubscribe = PATCH_BUS.subscribe((event) => {
|
|
20
|
+
model ??= event.doc
|
|
21
|
+
})
|
|
22
|
+
let dispose: () => void = () => undefined
|
|
23
|
+
try {
|
|
24
|
+
dispose = build()
|
|
25
|
+
} finally {
|
|
26
|
+
unsubscribe()
|
|
27
|
+
}
|
|
28
|
+
return { dispose, model }
|
|
29
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { claimChild } from './claimChild.ts'
|
|
2
|
+
import type { RENDER } from './RENDER.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
A hydration claim that MUST succeed. Like `claimChild`, but throws a structural error
|
|
6
|
+
when the cursor has no node to claim. A null here means the SSR markup and the client
|
|
7
|
+
build disagree about the tree at this position — a hydration desync. Cast away unguarded
|
|
8
|
+
(as the claim sites did), that null/undefined derefs several mounts later, far from the
|
|
9
|
+
cause; throwing names what was expected AT the divergence, turning the SSR == client DOM
|
|
10
|
+
invariant from a compile-time-only parity test into a runtime guard. Use only where the
|
|
11
|
+
compiled skeleton GUARANTEES a node — control-flow range markers, keyed-row boundaries —
|
|
12
|
+
never where an end-of-list null is legitimate (e.g. a trailing reactive text binding).
|
|
13
|
+
*/
|
|
14
|
+
export function claimExpected(
|
|
15
|
+
hydration: NonNullable<(typeof RENDER)['hydration']>,
|
|
16
|
+
parent: Node,
|
|
17
|
+
expected: string,
|
|
18
|
+
): Node {
|
|
19
|
+
const node = claimChild(hydration, parent)
|
|
20
|
+
if (node === null) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`[abide] hydration desync: expected ${expected} here, but the server DOM had no matching node — SSR markup and the client build disagree on structure at this position.`,
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
return node
|
|
26
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReactiveNode } from './types/ReactiveNode.ts'
|
|
2
2
|
|
|
3
|
-
/* Creates a lazy
|
|
3
|
+
/* Creates a lazy computed node. Born dirty so its first read computes; thereafter
|
|
4
4
|
it recomputes only when a dependency triggers it. */
|
|
5
5
|
export function createComputedNode(compute: () => unknown): ReactiveNode {
|
|
6
6
|
return {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { applyPatchToTree } from './applyPatchToTree.ts'
|
|
2
|
+
import { createComputedNode } from './createComputedNode.ts'
|
|
2
3
|
import { createSignalNode } from './createSignalNode.ts'
|
|
3
4
|
import { flushEffects } from './flushEffects.ts'
|
|
5
|
+
import { PATCH_BUS } from './PATCH_BUS.ts'
|
|
4
6
|
import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
|
|
5
7
|
import { readNode } from './readNode.ts'
|
|
6
8
|
import { trigger } from './trigger.ts'
|
|
@@ -8,6 +10,7 @@ import type { Cell } from './types/Cell.ts'
|
|
|
8
10
|
import type { Doc } from './types/Doc.ts'
|
|
9
11
|
import type { Patch } from './types/Patch.ts'
|
|
10
12
|
import type { ReactiveNode } from './types/ReactiveNode.ts'
|
|
13
|
+
import { unescapeKey } from './unescapeKey.ts'
|
|
11
14
|
import { walkPath } from './walkPath.ts'
|
|
12
15
|
import { writeNode } from './writeNode.ts'
|
|
13
16
|
|
|
@@ -34,6 +37,16 @@ the change.
|
|
|
34
37
|
export function createDoc(initial: unknown): Doc {
|
|
35
38
|
let tree = initial
|
|
36
39
|
const nodes = new Map<string, ReactiveNode>()
|
|
40
|
+
/* Computed slots: a path whose value is a function of other paths, not stored
|
|
41
|
+
truth. Held apart from `nodes` so the structural wake/eviction never touches
|
|
42
|
+
them; their dirtiness is driven entirely by the deps they read (the signal
|
|
43
|
+
graph), not by tree mutations. They are not in `tree`, so `snapshot` omits
|
|
44
|
+
them, and they never pass through `apply`, so they never hit the patch bus —
|
|
45
|
+
a recompute is a downstream reaction, not a change to journal/persist/sync. */
|
|
46
|
+
const computed = new Map<string, ReactiveNode>()
|
|
47
|
+
/* Set to the returned document before any apply runs, so a PATCH_BUS event can
|
|
48
|
+
name the document it came from (reference identity, the undo/persistence key). */
|
|
49
|
+
let self: Doc
|
|
37
50
|
|
|
38
51
|
function nodeFor(path: string): ReactiveNode {
|
|
39
52
|
let node = nodes.get(path)
|
|
@@ -45,9 +58,27 @@ export function createDoc(initial: unknown): Doc {
|
|
|
45
58
|
}
|
|
46
59
|
|
|
47
60
|
function read<T>(path: string): T {
|
|
61
|
+
/* Size-gated so a doc with no computed slots pays nothing on the stored hot
|
|
62
|
+
path — only one `.size` check, not a `.get` per read. */
|
|
63
|
+
if (computed.size > 0) {
|
|
64
|
+
const computedNode = computed.get(path)
|
|
65
|
+
if (computedNode !== undefined) {
|
|
66
|
+
return readNode(computedNode) as T
|
|
67
|
+
}
|
|
68
|
+
}
|
|
48
69
|
return readNode(nodeFor(path)) as T
|
|
49
70
|
}
|
|
50
71
|
|
|
72
|
+
/* Registers a computed slot at `path` and returns a string-free reader bound to
|
|
73
|
+
its node — the hoisted accessor the compiler would emit (the `computed` form),
|
|
74
|
+
so a hot read skips the path lookup. Reading the compute subscribes it to
|
|
75
|
+
whatever doc paths it touches; those deps then drive its recomputation. */
|
|
76
|
+
function derive<T>(path: string, compute: () => T): () => T {
|
|
77
|
+
const node = createComputedNode(compute as () => unknown)
|
|
78
|
+
computed.set(path, node)
|
|
79
|
+
return () => readNode(node) as T
|
|
80
|
+
}
|
|
81
|
+
|
|
51
82
|
/*
|
|
52
83
|
Wakes readers from `rootPath`. `force` notifies the root unconditionally (a
|
|
53
84
|
structural change keeps the container's identity, so there is no new value to
|
|
@@ -93,7 +124,13 @@ export function createDoc(initial: unknown): Doc {
|
|
|
93
124
|
}
|
|
94
125
|
|
|
95
126
|
function apply(patch: Patch): void {
|
|
96
|
-
|
|
127
|
+
/* Segments index the tree, so they carry the REAL keys (unescaped); the path
|
|
128
|
+
strings (parentPath, node-map keys) stay escaped, re-walked through walkPath. */
|
|
129
|
+
const segments = patch.path === '' ? [] : patch.path.split('/').map(unescapeKey)
|
|
130
|
+
/* Capture the pre-image only when a consumer is listening (the inverse's only
|
|
131
|
+
cost): a replace/remove inverts to the value it overwrote, an add to a
|
|
132
|
+
remove (computed post-apply, below, to resolve an array append's index). */
|
|
133
|
+
const before = PATCH_BUS.active ? walkPath(tree, patch.path) : undefined
|
|
97
134
|
tree = applyPatchToTree(tree, patch, segments)
|
|
98
135
|
/* parentPath is patch.path minus its last segment — the same string
|
|
99
136
|
`segments.slice(0, -1).join('/')` rebuilds, taken by one slice instead. */
|
|
@@ -140,9 +177,40 @@ export function createDoc(initial: unknown): Doc {
|
|
|
140
177
|
} finally {
|
|
141
178
|
REACTIVE_CONTEXT.batchDepth -= 1
|
|
142
179
|
}
|
|
180
|
+
/* Announce the change before flushing effects, so a patch an effect emits in
|
|
181
|
+
reaction lands AFTER this one on the bus — the journal stays chronological. */
|
|
182
|
+
if (PATCH_BUS.active) {
|
|
183
|
+
PATCH_BUS.emit({ doc: self, patch, inverse: inverseOf(patch, before) })
|
|
184
|
+
}
|
|
143
185
|
flushEffects()
|
|
144
186
|
}
|
|
145
187
|
|
|
188
|
+
/* The patch that undoes `patch`, from the pre-image `before` (a value the change
|
|
189
|
+
overwrote/removed) and the now-mutated tree. An add inverts to removing the slot
|
|
190
|
+
it created — resolving an array append (`-`) to the concrete last index it took.
|
|
191
|
+
A replace/remove of a path that held nothing inverts to remove/nothing. */
|
|
192
|
+
function inverseOf(
|
|
193
|
+
patch: Patch,
|
|
194
|
+
before: ReturnType<typeof walkPath> | undefined,
|
|
195
|
+
): Patch | undefined {
|
|
196
|
+
if (patch.op === 'add') {
|
|
197
|
+
const lastSlash = patch.path.lastIndexOf('/')
|
|
198
|
+
const parentPath = lastSlash === -1 ? '' : patch.path.slice(0, lastSlash)
|
|
199
|
+
const parent = walkPath(tree, parentPath).value
|
|
200
|
+
const resolved =
|
|
201
|
+
Array.isArray(parent) && patch.path.endsWith('/-')
|
|
202
|
+
? `${parentPath}/${parent.length - 1}`
|
|
203
|
+
: patch.path
|
|
204
|
+
return { op: 'remove', path: resolved }
|
|
205
|
+
}
|
|
206
|
+
if (patch.op === 'replace') {
|
|
207
|
+
return before?.exists
|
|
208
|
+
? { op: 'replace', path: patch.path, value: before.value }
|
|
209
|
+
: { op: 'remove', path: patch.path }
|
|
210
|
+
}
|
|
211
|
+
return before?.exists ? { op: 'add', path: patch.path, value: before.value } : undefined
|
|
212
|
+
}
|
|
213
|
+
|
|
146
214
|
/*
|
|
147
215
|
A stable accessor bound to one scalar leaf — what the compiler emits so a hot
|
|
148
216
|
loop has zero string work: the node, the parent container, and the leaf key
|
|
@@ -153,7 +221,7 @@ export function createDoc(initial: unknown): Doc {
|
|
|
153
221
|
*/
|
|
154
222
|
function cell<T>(path: string): Cell<T> {
|
|
155
223
|
const node = nodeFor(path)
|
|
156
|
-
const segments = path.split('/')
|
|
224
|
+
const segments = path.split('/').map(unescapeKey)
|
|
157
225
|
const leafKey = segments[segments.length - 1] as string
|
|
158
226
|
let parent = tree as Record<string, unknown>
|
|
159
227
|
for (const segment of segments.slice(0, -1)) {
|
|
@@ -168,13 +236,15 @@ export function createDoc(initial: unknown): Doc {
|
|
|
168
236
|
}
|
|
169
237
|
}
|
|
170
238
|
|
|
171
|
-
|
|
239
|
+
self = {
|
|
172
240
|
read,
|
|
173
241
|
cell,
|
|
242
|
+
derive,
|
|
174
243
|
apply,
|
|
175
244
|
replace: (path, value) => apply({ op: 'replace', path, value }),
|
|
176
245
|
add: (path, value) => apply({ op: 'add', path, value }),
|
|
177
246
|
remove: (path) => apply({ op: 'remove', path }),
|
|
178
247
|
snapshot: () => tree,
|
|
179
248
|
}
|
|
249
|
+
return self
|
|
180
250
|
}
|
|
@@ -3,7 +3,7 @@ import { RENDER } from './RENDER.ts'
|
|
|
3
3
|
/* Marks entry into a render/mount. The OUTERMOST one (depth 0) resets the block-id
|
|
4
4
|
counter so every render pass starts at 0; a child component's render/mount runs
|
|
5
5
|
at depth > 0 and continues the parent's counter. Pair with `exitRenderPass`. */
|
|
6
|
-
// @
|
|
6
|
+
// @documentation plumbing
|
|
7
7
|
export function enterRenderPass(): void {
|
|
8
8
|
if (RENDER.depth === 0) {
|
|
9
9
|
RENDER.blockId = 0
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Escapes one object key into a JSON Pointer reference token (RFC 6901): `~`→`~0`,
|
|
3
|
+
`/`→`~1`, so a key that itself contains `/` (a URL id, a date, a composite key)
|
|
4
|
+
survives a `/`-joined path instead of being mis-split into segments. `~` is
|
|
5
|
+
escaped first so a `/`→`~1` substitution isn't re-escaped. The common key (a
|
|
6
|
+
plain identifier) contains neither char, so the scan returns it untouched.
|
|
7
|
+
*/
|
|
8
|
+
export function escapeKey(key: string): string {
|
|
9
|
+
if (!key.includes('~') && !key.includes('/')) {
|
|
10
|
+
return key
|
|
11
|
+
}
|
|
12
|
+
return key.replace(/~/g, '~0').replace(/\//g, '~1')
|
|
13
|
+
}
|
|
@@ -1,15 +1,18 @@
|
|
|
1
|
+
import { captureModelDoc } from './captureModelDoc.ts'
|
|
1
2
|
import { hotInstances } from './hotInstances.ts'
|
|
3
|
+
import { seedModelDoc } from './seedModelDoc.ts'
|
|
2
4
|
import type { UiComponent } from './types/UiComponent.ts'
|
|
3
5
|
|
|
4
6
|
/*
|
|
5
7
|
Swaps every live instance of an edited component to its new factory. Per instance:
|
|
6
|
-
dispose the current scope and its DOM (the mount disposer clears
|
|
7
|
-
re-run `next` into the same host with the same props
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
snapshot its model, dispose the current scope and its DOM (the mount disposer clears
|
|
9
|
+
the host), re-run `next` into the same host with the same props, then re-seed the
|
|
10
|
+
fresh model from the snapshot — so the user's in-progress `state` survives the edit
|
|
11
|
+
(state above the boundary already survives: props are thunks re-reading the parent's
|
|
12
|
+
live signals). The hot module calls this on load with its freshly compiled `next`.
|
|
13
|
+
Returns whether it swapped at least one instance: false (the edited component has
|
|
14
|
+
none mounted — e.g. a router-mounted page, or a hidden branch) tells the caller to
|
|
15
|
+
fall back to a full reload, since nothing on screen would update.
|
|
13
16
|
*/
|
|
14
17
|
export function hotReplace(moduleId: string, next: UiComponent): boolean {
|
|
15
18
|
const set = hotInstances.get(moduleId)
|
|
@@ -17,9 +20,15 @@ export function hotReplace(moduleId: string, next: UiComponent): boolean {
|
|
|
17
20
|
return false
|
|
18
21
|
}
|
|
19
22
|
for (const instance of set) {
|
|
23
|
+
const saved = instance.model?.snapshot()
|
|
20
24
|
instance.dispose()
|
|
21
25
|
instance.factory = next
|
|
22
|
-
|
|
26
|
+
const { dispose, model } = captureModelDoc(() => next(instance.host, instance.props))
|
|
27
|
+
instance.dispose = dispose
|
|
28
|
+
instance.model = model
|
|
29
|
+
if (saved !== undefined && model !== undefined) {
|
|
30
|
+
seedModelDoc(model, saved)
|
|
31
|
+
}
|
|
23
32
|
}
|
|
24
33
|
return true
|
|
25
34
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Scope } from '../types/Scope.ts'
|
|
2
|
+
import { CURRENT_SCOPE } from './CURRENT_SCOPE.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Runs `fn` with `scope` as the ambient current scope, restoring the previous one
|
|
6
|
+
after. Deferred callbacks — an event handler, an effect re-run — fire after the
|
|
7
|
+
build that registered them, when `CURRENT_SCOPE` has moved on; wrapping them in
|
|
8
|
+
the scope captured at registration is what makes `scope()` inside them resolve the
|
|
9
|
+
scope they belong to, not whatever happens to be current when they fire.
|
|
10
|
+
*/
|
|
11
|
+
export function inScope<T>(scope: Scope | undefined, fn: () => T): T {
|
|
12
|
+
const previous = CURRENT_SCOPE.current
|
|
13
|
+
CURRENT_SCOPE.current = scope
|
|
14
|
+
try {
|
|
15
|
+
return fn()
|
|
16
|
+
} finally {
|
|
17
|
+
CURRENT_SCOPE.current = previous
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PersistenceStore } from '../types/PersistenceStore.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The default `persist` backend: `localStorage` keyed by the persistence key, with
|
|
5
|
+
JSON as the wire form (which also clones, so a stored snapshot can't alias the
|
|
6
|
+
live tree). Returns `undefined` where there is no `localStorage` — the server, or
|
|
7
|
+
a browser with storage disabled — which `persist` reads as "stay inert". A corrupt
|
|
8
|
+
or unparseable entry loads as `undefined` rather than throwing, so one bad write
|
|
9
|
+
can't wedge boot.
|
|
10
|
+
*/
|
|
11
|
+
export function localStoragePersistence(): PersistenceStore | undefined {
|
|
12
|
+
if (typeof localStorage === 'undefined') {
|
|
13
|
+
return undefined
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
load: (key) => {
|
|
17
|
+
const raw = localStorage.getItem(key)
|
|
18
|
+
if (raw === null) {
|
|
19
|
+
return undefined
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(raw)
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
save: (key, snapshot) => {
|
|
28
|
+
localStorage.setItem(key, JSON.stringify(snapshot))
|
|
29
|
+
},
|
|
30
|
+
remove: (key) => {
|
|
31
|
+
localStorage.removeItem(key)
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -3,7 +3,7 @@ import { RENDER } from './RENDER.ts'
|
|
|
3
3
|
/* The next block id in the current render pass. `await`/`try` blocks draw from it
|
|
4
4
|
in document order — shared across a component and the children it inlines — so a
|
|
5
5
|
page id and a child component's id never collide in the global `RESUME` manifest. */
|
|
6
|
-
// @
|
|
6
|
+
// @documentation plumbing
|
|
7
7
|
export function nextBlockId(): number {
|
|
8
8
|
const id = RENDER.blockId
|
|
9
9
|
RENDER.blockId += 1
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Doc } from './types/Doc.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Loads a `saved` snapshot (from the pre-swap instance) into a freshly-built `model`,
|
|
5
|
+
restoring the user's in-progress state across a hot swap. A shallow per-top-level-key
|
|
6
|
+
merge, not a wholesale root replace: a key present in both is restored from `saved`;
|
|
7
|
+
a key the edit ADDED keeps its fresh default; a key the edit REMOVED is not re-added.
|
|
8
|
+
So editing a component's state shape still loads cleanly instead of resurrecting a
|
|
9
|
+
stale tree. Each restored key is one patch, so only the changed slots re-render.
|
|
10
|
+
*/
|
|
11
|
+
export function seedModelDoc(model: Doc, saved: unknown): void {
|
|
12
|
+
const current = model.snapshot()
|
|
13
|
+
if (
|
|
14
|
+
saved === null ||
|
|
15
|
+
typeof saved !== 'object' ||
|
|
16
|
+
current === null ||
|
|
17
|
+
typeof current !== 'object'
|
|
18
|
+
) {
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
for (const key of Object.keys(saved)) {
|
|
22
|
+
if (key in current) {
|
|
23
|
+
model.replace(key, (saved as Record<string, unknown>)[key])
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -11,6 +11,9 @@ plain, serializable data, which is what makes the document resumable.
|
|
|
11
11
|
export type Doc = {
|
|
12
12
|
read: <T>(path: string) => T
|
|
13
13
|
cell: <T>(path: string) => Cell<T>
|
|
14
|
+
/* Registers a computed slot at `path` (a value computed from other paths, never
|
|
15
|
+
stored/serialized/journalled) and returns a string-free reader bound to it. */
|
|
16
|
+
derive: <T>(path: string, compute: () => T) => () => T
|
|
14
17
|
apply: (patch: Patch) => void
|
|
15
18
|
replace: (path: string, value: unknown) => void
|
|
16
19
|
add: (path: string, value: unknown) => void
|
|
@@ -1,14 +1,18 @@
|
|
|
1
|
+
import type { Doc } from './Doc.ts'
|
|
1
2
|
import type { UiComponent } from './UiComponent.ts'
|
|
2
3
|
|
|
3
4
|
/*
|
|
4
5
|
A live component instance the hot-reload registry tracks: its wrapper host, the
|
|
5
6
|
factory that built it, the props (thunks re-read on a swap so the parent's live
|
|
6
|
-
state still flows through),
|
|
7
|
-
|
|
7
|
+
state still flows through), the disposer for its current scope + DOM, and its own
|
|
8
|
+
`model` document (`undefined` when the component has no `state`) — snapshotted
|
|
9
|
+
before a swap and re-seeded after, so the user's in-progress state survives the
|
|
10
|
+
edit. A swap mutates `factory`/`dispose`/`model` in place (see `hotReplace`).
|
|
8
11
|
*/
|
|
9
12
|
export type HotInstance = {
|
|
10
13
|
host: Element
|
|
11
14
|
factory: UiComponent
|
|
12
15
|
props: Parameters<UiComponent>[1]
|
|
13
16
|
dispose: () => void
|
|
17
|
+
model: Doc | undefined
|
|
14
18
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Doc } from './Doc.ts'
|
|
2
|
+
import type { Patch } from './Patch.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
A single applied change announced on the `PATCH_BUS`: the `doc` it hit (reference
|
|
6
|
+
identity within the process — a serialization-stable id arrives with sync), the
|
|
7
|
+
forward `patch`, and its `inverse` — the patch that exactly undoes it, or
|
|
8
|
+
`undefined` for a no-op (e.g. removing an absent key). Consumers — undo history,
|
|
9
|
+
persistence, sync — read this one shape; the inverse is what makes a journal
|
|
10
|
+
reversible at O(size of the change), never a retained tree snapshot.
|
|
11
|
+
*/
|
|
12
|
+
export type PatchEvent = {
|
|
13
|
+
doc: Doc
|
|
14
|
+
patch: Patch
|
|
15
|
+
inverse: Patch | undefined
|
|
16
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Reverses `escapeKey` for one path segment (JSON Pointer RFC 6901): `~1`→`/`,
|
|
3
|
+
`~0`→`~`, in that order so a literal `~1` in the original key (escaped to `~01`)
|
|
4
|
+
round-trips. A segment with no `~` can't carry an escape, so it returns untouched
|
|
5
|
+
— the fast path for every plain-identifier segment on the hot read/split path.
|
|
6
|
+
*/
|
|
7
|
+
export function unescapeKey(segment: string): string {
|
|
8
|
+
if (!segment.includes('~')) {
|
|
9
|
+
return segment
|
|
10
|
+
}
|
|
11
|
+
return segment.replace(/~1/g, '/').replace(/~0/g, '~')
|
|
12
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PathWalk } from './types/PathWalk.ts'
|
|
2
|
+
import { unescapeKey } from './unescapeKey.ts'
|
|
2
3
|
|
|
3
4
|
/*
|
|
4
5
|
Walks a `/`-joined path through a plain tree in one pass, returning both whether
|
|
@@ -17,7 +18,8 @@ export function walkPath(tree: unknown, path: string): PathWalk {
|
|
|
17
18
|
return { exists: tree !== undefined, value: tree }
|
|
18
19
|
}
|
|
19
20
|
let current: unknown = tree
|
|
20
|
-
for (const
|
|
21
|
+
for (const rawSegment of path.split('/')) {
|
|
22
|
+
const segment = unescapeKey(rawSegment)
|
|
21
23
|
if (current === null || typeof current !== 'object' || !(segment in current)) {
|
|
22
24
|
return { exists: false, value: undefined }
|
|
23
25
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createScope } from './createScope.ts'
|
|
2
|
+
import { CURRENT_SCOPE } from './runtime/CURRENT_SCOPE.ts'
|
|
3
|
+
import type { Scope } from './types/Scope.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Resolves a lexical scope. `scope()` returns the current one — established per
|
|
7
|
+
lexical level by the compiler, so it reads "where you are" with no handle. Outside
|
|
8
|
+
any scope (boot, a script) it mints a detached root once and reuses it. `scope('/')`
|
|
9
|
+
returns the root of the current tree (the app-global scope). The returned value is
|
|
10
|
+
passable: hand it to a child or a helper and it can read/extend/undo that scope.
|
|
11
|
+
*/
|
|
12
|
+
// @documentation reactive-state
|
|
13
|
+
export function scope(address?: string): Scope {
|
|
14
|
+
if (!CURRENT_SCOPE.current) {
|
|
15
|
+
CURRENT_SCOPE.current = createScope()
|
|
16
|
+
}
|
|
17
|
+
const current = CURRENT_SCOPE.current
|
|
18
|
+
return address === '/' ? current.root() : current
|
|
19
|
+
}
|
|
@@ -19,7 +19,7 @@ Backpressure is unbounded — a slow consumer with a chatty socket will
|
|
|
19
19
|
grow the per-iterator buffer; bounded policies belong in a future
|
|
20
20
|
socketProxy API, not the wire layer.
|
|
21
21
|
*/
|
|
22
|
-
// @
|
|
22
|
+
// @documentation plumbing
|
|
23
23
|
export function socketProxy<T>(name: string): Socket<T> {
|
|
24
24
|
return buildSocketOverChannel<T>(name, getSocketChannel)
|
|
25
25
|
}
|
|
@@ -24,7 +24,7 @@ navigation — importing each further page's chunk on first visit and probing th
|
|
|
24
24
|
destination through the server's app.handle so auth/redirect gating still applies.
|
|
25
25
|
Returns a disposer. `target` defaults to `#app`; pass one explicitly in tests.
|
|
26
26
|
*/
|
|
27
|
-
// @
|
|
27
|
+
// @documentation plumbing
|
|
28
28
|
export function startClient(
|
|
29
29
|
routes: Record<string, RouteLoader>,
|
|
30
30
|
layoutRoutes: Record<string, RouteLoader> = {},
|
package/src/lib/ui/state.ts
CHANGED
|
@@ -14,7 +14,7 @@ needs no magic.
|
|
|
14
14
|
`transform` is an optional coercion gate on the write path: every `.value =`
|
|
15
15
|
runs it and stores what it returns, with `previous` for clamp-relative writes or
|
|
16
16
|
rejection (`return previous` is an `Object.is` no-op). It is the local-truth
|
|
17
|
-
mirror of `
|
|
17
|
+
mirror of `computed`'s write-through `set` — here the value lives in this cell, so
|
|
18
18
|
the gate *returns* what to store rather than writing an external target. The
|
|
19
19
|
construction `initial` is taken verbatim; the gate runs on writes only.
|
|
20
20
|
*/
|
|
@@ -22,7 +22,6 @@ construction `initial` is taken verbatim; the gate runs on writes only.
|
|
|
22
22
|
`State<Foo | undefined>`. Without it `state<Foo>(undefined)` is an arity/assign
|
|
23
23
|
error and `state(undefined)` infers `T = undefined` (every `.value` access then
|
|
24
24
|
narrows to `never`). */
|
|
25
|
-
// @readme plumbing
|
|
26
25
|
export function state<T>(): State<T | undefined>
|
|
27
26
|
export function state<T>(initial: T, transform?: (next: T, previous: T) => T): State<T>
|
|
28
27
|
export function state<T>(
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { PATCH_BUS } from './runtime/PATCH_BUS.ts'
|
|
2
|
+
import type { Doc } from './runtime/types/Doc.ts'
|
|
3
|
+
import type { SyncTransport } from './types/SyncTransport.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Shares a document's state across peers in real time. Two directions over one
|
|
7
|
+
`transport`, both riding the patch bus:
|
|
8
|
+
- OUTBOUND: every local patch to `doc` is published (`transport.send`);
|
|
9
|
+
- INBOUND: every peer patch is applied to `doc`.
|
|
10
|
+
An inbound apply emits on the bus like any change, so an `applying` guard stops it
|
|
11
|
+
being echoed straight back — the thing that would otherwise ping-pong forever. The
|
|
12
|
+
transport itself should not deliver a sender its own patch either; together the two
|
|
13
|
+
guards keep a write to a path from looping. Last-write-wins by arrival order;
|
|
14
|
+
edits to *different* paths never conflict, by construction of the doc's wake. A
|
|
15
|
+
late joiner needs a snapshot seed before live patches (a transport concern, out of
|
|
16
|
+
this core). Returns a disposer.
|
|
17
|
+
*/
|
|
18
|
+
// @documentation plumbing
|
|
19
|
+
export function sync(doc: Doc, transport: SyncTransport): () => void {
|
|
20
|
+
/* True only while applying a received patch, so its bus echo isn't re-sent. */
|
|
21
|
+
let applying = false
|
|
22
|
+
|
|
23
|
+
const unsubscribeInbound = transport.subscribe((patch) => {
|
|
24
|
+
applying = true
|
|
25
|
+
try {
|
|
26
|
+
doc.apply(patch)
|
|
27
|
+
} finally {
|
|
28
|
+
applying = false
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const unsubscribeBus = PATCH_BUS.subscribe((event) => {
|
|
33
|
+
if (event.doc === doc && !applying) {
|
|
34
|
+
transport.send(event.patch)
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
return () => {
|
|
39
|
+
unsubscribeBus()
|
|
40
|
+
unsubscribeInbound()
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/lib/ui/tail.ts
CHANGED
|
@@ -108,7 +108,7 @@ from a $derived can't crash the component. Status distinguishes "haven't
|
|
|
108
108
|
received the first frame" (pending) from "stream ended cleanly" (done) and
|
|
109
109
|
"wire layer surfaced an error" (error).
|
|
110
110
|
*/
|
|
111
|
-
// @
|
|
111
|
+
// @documentation tail
|
|
112
112
|
export function tail<T>(subscribable: Subscribable<T>): T | undefined
|
|
113
113
|
export function tail<T>(subscribable: Subscribable<T>, options: TailOptions): T[]
|
|
114
114
|
export function tail<T>(subscribable: Subscribable<T>, options?: TailOptions): T | T[] | undefined {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The undo/redo handle returned by `history(doc)`. `undo`/`redo` step the journal
|
|
3
|
+
of inverse patches; `canUndo`/`canRedo` report availability; `transaction` groups
|
|
4
|
+
a burst of patches into one reversible step (one user action = one undo); `dispose`
|
|
5
|
+
detaches from the `PATCH_BUS` and drops the journal.
|
|
6
|
+
*/
|
|
7
|
+
export type History = {
|
|
8
|
+
undo: () => void
|
|
9
|
+
redo: () => void
|
|
10
|
+
canUndo: () => boolean
|
|
11
|
+
canRedo: () => boolean
|
|
12
|
+
transaction: (run: () => void) => void
|
|
13
|
+
dispose: () => void
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/*
|
|
2
|
+
A durable, FIFO queue of pending mutations that drains when the client is online —
|
|
3
|
+
the local-first half of "act now, sync later". `enqueue` records a serializable
|
|
4
|
+
payload (persisted, so it survives a reload) and tries to drain; `pending` is a
|
|
5
|
+
reactive read of the payloads still in flight (for a "3 unsynced" indicator);
|
|
6
|
+
`flush` drains on demand (and is what the reconnect effect calls); `dispose` stops
|
|
7
|
+
draining and detaches persistence.
|
|
8
|
+
*/
|
|
9
|
+
export type Outbox<T> = {
|
|
10
|
+
enqueue: (payload: T) => void
|
|
11
|
+
pending: () => T[]
|
|
12
|
+
flush: () => Promise<void>
|
|
13
|
+
dispose: () => void
|
|
14
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The handle returned by `persist(doc, key)`. `flush` writes the pending snapshot
|
|
3
|
+
immediately (the debounced writer otherwise coalesces a burst); `clear` removes
|
|
4
|
+
the stored snapshot; `dispose` stops persisting (unsubscribes, drops listeners).
|
|
5
|
+
On the server or a store-less browser, every method is a no-op.
|
|
6
|
+
*/
|
|
7
|
+
export type PersistHandle = {
|
|
8
|
+
flush: () => void
|
|
9
|
+
clear: () => void
|
|
10
|
+
dispose: () => void
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The durable backend `persist` writes a document snapshot to. Deliberately tiny
|
|
3
|
+
and synchronous — `load` must return the saved value in time to seed the doc
|
|
4
|
+
before first render, which a sync store (localStorage) gives for free. Inject a
|
|
5
|
+
custom one for a different backend (a test memory store; a server data-dir store);
|
|
6
|
+
an async backend (IndexedDB) needs an async-boot wrapper, out of this contract.
|
|
7
|
+
*/
|
|
8
|
+
export type PersistenceStore = {
|
|
9
|
+
load: (key: string) => unknown
|
|
10
|
+
save: (key: string, snapshot: unknown) => void
|
|
11
|
+
remove: (key: string) => void
|
|
12
|
+
}
|