@abide/abide 0.33.1 → 0.34.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.
Files changed (160) hide show
  1. package/AGENTS.md +39 -34
  2. package/CHANGELOG.md +30 -0
  3. package/README.md +21 -21
  4. package/package.json +5 -5
  5. package/src/abideResolverPlugin.ts +1 -1
  6. package/src/build.ts +1 -1
  7. package/src/compile.ts +1 -1
  8. package/src/lib/bundle/BundleMenu.ts +1 -1
  9. package/src/lib/bundle/BundleMenuItem.ts +1 -1
  10. package/src/lib/bundle/BundleWindow.ts +1 -1
  11. package/src/lib/bundle/bundled.ts +1 -1
  12. package/src/lib/bundle/disconnected.abide +105 -75
  13. package/src/lib/bundle/onMenu.ts +1 -1
  14. package/src/lib/mcp/createMcpServer.ts +1 -1
  15. package/src/lib/server/AppModule.ts +1 -1
  16. package/src/lib/server/DELETE.ts +1 -1
  17. package/src/lib/server/GET.ts +1 -1
  18. package/src/lib/server/HEAD.ts +1 -1
  19. package/src/lib/server/PATCH.ts +1 -1
  20. package/src/lib/server/POST.ts +1 -1
  21. package/src/lib/server/PUT.ts +1 -1
  22. package/src/lib/server/agent.ts +1 -1
  23. package/src/lib/server/appDataDir.ts +1 -1
  24. package/src/lib/server/cookies.ts +1 -1
  25. package/src/lib/server/env.ts +1 -1
  26. package/src/lib/server/error.ts +1 -1
  27. package/src/lib/server/json.ts +1 -1
  28. package/src/lib/server/jsonl.ts +1 -1
  29. package/src/lib/server/prompts/definePrompt.ts +1 -1
  30. package/src/lib/server/prompts/renderPromptTemplate.ts +1 -1
  31. package/src/lib/server/reachable.ts +1 -1
  32. package/src/lib/server/redirect.ts +1 -1
  33. package/src/lib/server/request.ts +1 -1
  34. package/src/lib/server/rpc/defineVerb.ts +1 -1
  35. package/src/lib/server/runtime/buildCacheSnapshot.ts +1 -1
  36. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +2 -2
  37. package/src/lib/server/runtime/types/InspectorContext.ts +2 -2
  38. package/src/lib/server/server.ts +1 -1
  39. package/src/lib/server/socket.ts +1 -1
  40. package/src/lib/server/sockets/defineSocket.ts +1 -1
  41. package/src/lib/server/sse.ts +1 -1
  42. package/src/lib/shared/HttpError.ts +1 -1
  43. package/src/lib/shared/cache.ts +18 -18
  44. package/src/lib/shared/createSubscriber.ts +1 -1
  45. package/src/lib/shared/health.ts +1 -1
  46. package/src/lib/shared/html.ts +1 -1
  47. package/src/lib/shared/isSubscribable.ts +1 -1
  48. package/src/lib/shared/log.ts +1 -1
  49. package/src/lib/shared/online.ts +1 -1
  50. package/src/lib/shared/page.ts +1 -1
  51. package/src/lib/shared/pending.ts +2 -2
  52. package/src/lib/shared/probeRegistries.ts +2 -2
  53. package/src/lib/shared/refreshing.ts +2 -2
  54. package/src/lib/shared/selectorMatcher.ts +8 -8
  55. package/src/lib/shared/selectorPrefix.ts +1 -1
  56. package/src/lib/shared/snippet.ts +1 -1
  57. package/src/lib/shared/toTagSet.ts +4 -0
  58. package/src/lib/shared/trace.ts +1 -1
  59. package/src/lib/shared/types/CacheEntry.ts +3 -3
  60. package/src/lib/shared/types/CacheOptions.ts +3 -3
  61. package/src/lib/shared/types/CacheSelector.ts +2 -2
  62. package/src/lib/shared/url.ts +1 -1
  63. package/src/lib/shared/withJsonSchema.ts +1 -1
  64. package/src/lib/test/assertAgentFrameConformance.ts +1 -1
  65. package/src/lib/test/createScriptedSurface.ts +1 -1
  66. package/src/lib/test/createTestApp.ts +1 -1
  67. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +7 -2
  68. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +3 -4
  69. package/src/lib/ui/compile/abideUiPlugin.ts +1 -1
  70. package/src/lib/ui/compile/analyzeComponent.ts +2 -1
  71. package/src/lib/ui/compile/compileComponent.ts +3 -3
  72. package/src/lib/ui/compile/compileModule.ts +30 -2
  73. package/src/lib/ui/compile/compileSSR.ts +5 -5
  74. package/src/lib/ui/compile/compileShadow.ts +62 -37
  75. package/src/lib/ui/compile/desugarSignals.ts +147 -18
  76. package/src/lib/ui/compile/generateBuild.ts +15 -28
  77. package/src/lib/ui/compile/generateSSR.ts +20 -13
  78. package/src/lib/ui/compile/isControlFlow.ts +18 -0
  79. package/src/lib/ui/compile/isTextLeaf.ts +15 -0
  80. package/src/lib/ui/compile/lowerContext.ts +45 -5
  81. package/src/lib/ui/compile/prepareNestedScript.ts +12 -8
  82. package/src/lib/ui/compile/renameSignalRefs.ts +21 -4
  83. package/src/lib/ui/compile/skeletonable.ts +1 -14
  84. package/src/lib/ui/compile/types/AnalyzedComponent.ts +1 -0
  85. package/src/lib/ui/computed.ts +32 -0
  86. package/src/lib/ui/createScope.ts +91 -0
  87. package/src/lib/ui/dom/anchorCursor.ts +1 -1
  88. package/src/lib/ui/dom/appendSnippet.ts +1 -1
  89. package/src/lib/ui/dom/appendStatic.ts +1 -1
  90. package/src/lib/ui/dom/appendText.ts +1 -1
  91. package/src/lib/ui/dom/appendTextAt.ts +1 -1
  92. package/src/lib/ui/dom/applyResolved.ts +1 -1
  93. package/src/lib/ui/dom/attach.ts +7 -2
  94. package/src/lib/ui/dom/attr.ts +1 -1
  95. package/src/lib/ui/dom/awaitBlock.ts +1 -1
  96. package/src/lib/ui/dom/cloneStatic.ts +1 -1
  97. package/src/lib/ui/dom/each.ts +4 -3
  98. package/src/lib/ui/dom/eachAsync.ts +1 -1
  99. package/src/lib/ui/dom/hydrate.ts +20 -6
  100. package/src/lib/ui/dom/mount.ts +12 -1
  101. package/src/lib/ui/dom/mountChild.ts +6 -2
  102. package/src/lib/ui/dom/mountSlot.ts +1 -1
  103. package/src/lib/ui/dom/on.ts +10 -4
  104. package/src/lib/ui/dom/openMarker.ts +3 -3
  105. package/src/lib/ui/dom/skeleton.ts +13 -5
  106. package/src/lib/ui/dom/switchBlock.ts +1 -1
  107. package/src/lib/ui/dom/text.ts +1 -1
  108. package/src/lib/ui/dom/tryBlock.ts +1 -1
  109. package/src/lib/ui/dom/when.ts +1 -1
  110. package/src/lib/ui/effect.ts +7 -2
  111. package/src/lib/ui/enterScope.ts +18 -0
  112. package/src/lib/ui/exitScope.ts +8 -0
  113. package/src/lib/ui/history.ts +102 -0
  114. package/src/lib/ui/installHotBridge.ts +6 -8
  115. package/src/lib/ui/linked.ts +0 -1
  116. package/src/lib/ui/navigate.ts +1 -1
  117. package/src/lib/ui/outbox.ts +115 -0
  118. package/src/lib/ui/persist.ts +112 -0
  119. package/src/lib/ui/probeNavigation.ts +1 -1
  120. package/src/lib/ui/remoteProxy.ts +1 -1
  121. package/src/lib/ui/renderToStream.ts +1 -1
  122. package/src/lib/ui/router.ts +1 -1
  123. package/src/lib/ui/runtime/CURRENT_SCOPE.ts +10 -0
  124. package/src/lib/ui/runtime/OUTLET_TAG.ts +1 -1
  125. package/src/lib/ui/runtime/PATCH_BUS.ts +28 -0
  126. package/src/lib/ui/runtime/RESUME.ts +1 -1
  127. package/src/lib/ui/runtime/captureModelDoc.ts +29 -0
  128. package/src/lib/ui/runtime/claimExpected.ts +26 -0
  129. package/src/lib/ui/runtime/createComputedNode.ts +1 -1
  130. package/src/lib/ui/runtime/createDoc.ts +73 -3
  131. package/src/lib/ui/runtime/enterRenderPass.ts +1 -1
  132. package/src/lib/ui/runtime/escapeKey.ts +13 -0
  133. package/src/lib/ui/runtime/exitRenderPass.ts +1 -1
  134. package/src/lib/ui/runtime/hotReplace.ts +17 -8
  135. package/src/lib/ui/runtime/inScope.ts +19 -0
  136. package/src/lib/ui/runtime/localStoragePersistence.ts +34 -0
  137. package/src/lib/ui/runtime/nextBlockId.ts +1 -1
  138. package/src/lib/ui/runtime/seedModelDoc.ts +26 -0
  139. package/src/lib/ui/runtime/types/{Derived.ts → Computed.ts} +1 -1
  140. package/src/lib/ui/runtime/types/Doc.ts +3 -0
  141. package/src/lib/ui/runtime/types/HotInstance.ts +6 -2
  142. package/src/lib/ui/runtime/types/PatchEvent.ts +16 -0
  143. package/src/lib/ui/runtime/unescapeKey.ts +12 -0
  144. package/src/lib/ui/runtime/walkPath.ts +3 -1
  145. package/src/lib/ui/scope.ts +19 -0
  146. package/src/lib/ui/socketProxy.ts +1 -1
  147. package/src/lib/ui/startClient.ts +1 -1
  148. package/src/lib/ui/state.ts +1 -2
  149. package/src/lib/ui/sync.ts +42 -0
  150. package/src/lib/ui/tail.ts +1 -1
  151. package/src/lib/ui/types/History.ts +14 -0
  152. package/src/lib/ui/types/Outbox.ts +14 -0
  153. package/src/lib/ui/types/PersistHandle.ts +11 -0
  154. package/src/lib/ui/types/PersistenceStore.ts +12 -0
  155. package/src/lib/ui/types/Scope.ts +57 -0
  156. package/src/lib/ui/types/SyncTransport.ts +13 -0
  157. package/src/preload.ts +1 -1
  158. package/src/lib/shared/toScopeSet.ts +0 -4
  159. package/src/lib/ui/derived.ts +0 -49
  160. package/src/lib/ui/doc.ts +0 -15
@@ -12,5 +12,5 @@ export type ResumeEntry = { ok: true; value: unknown } | { ok: false; error: unk
12
12
  const globalScope = globalThis as { __abideResume?: Record<number, ResumeEntry> }
13
13
  globalScope.__abideResume ??= {}
14
14
 
15
- // @readme plumbing
15
+ // @documentation plumbing
16
16
  export const RESUME: Record<number, ResumeEntry> = globalScope.__abideResume
@@ -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 derived node. Born dirty so its first read computes; thereafter
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
- const segments = patch.path === '' ? [] : patch.path.split('/')
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
- return {
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
- // @readme plumbing
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,7 +1,7 @@
1
1
  import { RENDER } from './RENDER.ts'
2
2
 
3
3
  /* Marks exit from a render/mount, unwinding the depth `enterRenderPass` raised. */
4
- // @readme plumbing
4
+ // @documentation plumbing
5
5
  export function exitRenderPass(): void {
6
6
  RENDER.depth -= 1
7
7
  }
@@ -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 the host), then
7
- re-run `next` into the same host with the same props so state above the boundary
8
- survives (props are thunks that re-read the parent's live signals) while the
9
- component's own state resets. The hot module calls this on load with its freshly
10
- compiled `next`. Returns whether it swapped at least one instance: false (the edited
11
- component has none mounted e.g. a router-mounted page, or a hidden branch) tells
12
- the caller to fall back to a full reload, since nothing on screen would update.
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
- instance.dispose = next(instance.host, instance.props)
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
- // @readme plumbing
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
+ }
@@ -1,3 +1,3 @@
1
1
  /* A read-only reactive cell computed from other cells. Reading `.value`
2
2
  subscribes the running observer and lazily recomputes if a dependency changed. */
3
- export type Derived<T> = { readonly value: T }
3
+ export type Computed<T> = { readonly value: T }
@@ -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), and the disposer for its current scope + DOM. A swap
7
- mutates `factory`/`dispose` in place (see `hotReplace`).
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 segment of path.split('/')) {
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
- // @readme plumbing
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
- // @readme plumbing
27
+ // @documentation plumbing
28
28
  export function startClient(
29
29
  routes: Record<string, RouteLoader>,
30
30
  layoutRoutes: Record<string, RouteLoader> = {},
@@ -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 `derived`'s write-through `set` — here the value lives in this cell, so
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
+ }
@@ -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
- // @readme tail
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
+ }