@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
@@ -1,15 +1,21 @@
1
+ import { CURRENT_SCOPE } from '../runtime/CURRENT_SCOPE.ts'
2
+ import { inScope } from '../runtime/inScope.ts'
1
3
  import { OWNER } from '../runtime/OWNER.ts'
2
4
 
3
5
  /*
4
6
  Attaches an event listener and registers its removal with the current ownership
5
7
  scope, so a component's listeners detach when it disposes. This is the runtime
6
8
  target for an `onclick={…}` binding; the handler body is where the compiler's
7
- lowered patches (`model.replace(...)`) run.
9
+ lowered patches (`model.replace(...)`) run. The handler is pinned to the scope it
10
+ was attached under, so an ambient `scope()` inside it (e.g. `scope().undo()`)
11
+ resolves the component, not whatever is current when the event fires.
8
12
  */
9
- // @readme plumbing
13
+ // @documentation plumbing
10
14
  export function on(element: Element, type: string, handler: EventListener): void {
11
- element.addEventListener(type, handler)
15
+ const captured = CURRENT_SCOPE.current
16
+ const wrapped: EventListener = (event) => inScope(captured, () => handler(event))
17
+ element.addEventListener(type, wrapped)
12
18
  if (OWNER.current !== undefined) {
13
- OWNER.current.push(() => element.removeEventListener(type, handler))
19
+ OWNER.current.push(() => element.removeEventListener(type, wrapped))
14
20
  }
15
21
  }
@@ -1,4 +1,4 @@
1
- import { claimChild } from '../runtime/claimChild.ts'
1
+ import { claimExpected } from '../runtime/claimExpected.ts'
2
2
  import { RENDER } from '../runtime/RENDER.ts'
3
3
 
4
4
  /*
@@ -12,8 +12,8 @@ rather than a list of single nodes.
12
12
  export function openMarker(parent: Node, data: string, before: Node | null = null): Comment {
13
13
  const hydration = RENDER.hydration
14
14
  if (hydration !== undefined) {
15
- const node = claimChild(hydration, parent) as unknown as Comment
16
- hydration.next.set(parent, node === null ? null : node.nextSibling)
15
+ const node = claimExpected(hydration, parent, `control-flow marker "${data}"`) as Comment
16
+ hydration.next.set(parent, node.nextSibling)
17
17
  return node
18
18
  }
19
19
  const node = document.createComment(data)
@@ -53,7 +53,7 @@ function isCloseMarker(data: string): boolean {
53
53
  SHALLOW template (block positions are `<!--a-->` anchors, no content), so on hydrate the
54
54
  expanded tree must skip that inline content or a hole positioned after a block shifts. In
55
55
  create mode the clone is shallow (no markers), so depth stays 0 — a plain element count. */
56
- function elementChildAt(children: ArrayLike<Node>, index: number): Element {
56
+ function elementChildAt(children: ArrayLike<Node>, index: number): Element | undefined {
57
57
  let seen = 0
58
58
  let depth = 0
59
59
  for (let cursor = 0; cursor < children.length; cursor += 1) {
@@ -72,7 +72,7 @@ function elementChildAt(children: ArrayLike<Node>, index: number): Element {
72
72
  depth += 1
73
73
  }
74
74
  }
75
- return undefined as unknown as Element
75
+ return undefined
76
76
  }
77
77
 
78
78
  /* Records each element hole's element-only path in PRE-ORDER (the `HOLE_ATTRIBUTE`
@@ -152,12 +152,20 @@ function compile(html: string, wrapper: string | undefined): CompiledSkeleton {
152
152
  return compiled
153
153
  }
154
154
 
155
- /* Walks an element-only path from the top-level node list to the target element. */
155
+ /* Walks an element-only path from the top-level node list to the target element. A step
156
+ that resolves to nothing means the claimed server run is missing an element the skeleton
157
+ expects here — a hydration desync; throw AT it (naming the path) rather than returning
158
+ the undefined that derefs in the downstream `mountChild`/`attr`, far from the cause. */
156
159
  function resolveElementHole(topLevel: ArrayLike<Node>, path: number[]): Element {
157
160
  let node = elementChildAt(topLevel, path[0] as number)
158
- for (let depth = 1; depth < path.length; depth += 1) {
161
+ for (let depth = 1; depth < path.length && node !== undefined; depth += 1) {
159
162
  node = elementChildAt(node.childNodes, path[depth] as number)
160
163
  }
164
+ if (node === undefined) {
165
+ throw new Error(
166
+ `[abide] hydration desync: skeleton element hole [${path.join(',')}] resolved to no node — the server DOM is missing an element the client build expects here.`,
167
+ )
168
+ }
161
169
  return node
162
170
  }
163
171
 
@@ -173,7 +181,7 @@ client-side — no server marker). Anchor holes resolve by scanning for their `a
173
181
  markers (present in both clone and server DOM). Create mode clones the parsed top-level
174
182
  nodes; hydrate mode claims the matching server run.
175
183
  */
176
- // @readme plumbing
184
+ // @documentation plumbing
177
185
  export function skeleton(parent: Node, html: string): SkeletonHoles {
178
186
  const { source, elementPaths, topLevelCount } = compile(html, foreignWrapperTag(parent))
179
187
  const hydration = RENDER.hydration
@@ -18,7 +18,7 @@ On hydrate it adopts the case the server rendered: claim the start marker, run t
18
18
  matching case in place, claim the end marker. The effect's first run picks the same
19
19
  case and is a no-op; later changes swap the range.
20
20
  */
21
- // @readme plumbing
21
+ // @documentation plumbing
22
22
  export function switchBlock(
23
23
  parent: Node,
24
24
  subject: () => unknown,
@@ -10,7 +10,7 @@ that may yield an `html\`…\`` or snippet value the compiler emits `appendText`
10
10
  which branches on the branded value and inserts raw markup; `text` cannot host
11
11
  multiple nodes and would stringify such a value to `[object Object]`.
12
12
  */
13
- // @readme plumbing
13
+ // @documentation plumbing
14
14
  export function text(read: () => unknown): Text {
15
15
  const node = document.createTextNode('')
16
16
  effect(() => {
@@ -20,7 +20,7 @@ builds. On hydrate it claims the SSR boundary
20
20
  (`<!--abide:try:N-->…<!--/abide:try:N-->`): the happy path adopts the guarded nodes
21
21
  in place; a throw discards the boundary's server nodes and builds the catch fresh.
22
22
  */
23
- // @readme plumbing
23
+ // @documentation plumbing
24
24
  export function tryBlock(
25
25
  parent: Node,
26
26
  id: number,
@@ -18,7 +18,7 @@ matching render in place (its content claims the existing nodes), then claim the
18
18
  end marker. The effect's first run sees the same branch and is a no-op; later
19
19
  toggles clear the range and build fresh into a fragment.
20
20
  */
21
- // @readme plumbing
21
+ // @documentation plumbing
22
22
  export function when(
23
23
  parent: Node,
24
24
  condition: () => unknown,
@@ -1,4 +1,6 @@
1
+ import { CURRENT_SCOPE } from './runtime/CURRENT_SCOPE.ts'
1
2
  import { createEffectNode } from './runtime/createEffectNode.ts'
3
+ import { inScope } from './runtime/inScope.ts'
2
4
  import type { EffectResult } from './runtime/types/EffectResult.ts'
3
5
 
4
6
  /*
@@ -10,7 +12,10 @@ the final teardown and detaches it from the graph. This is abide's from-scratch
10
12
  effect primitive: the open-on-first-read / close-on-last-reader lifecycle,
11
13
  grounded in abide's own reactive core.
12
14
  */
13
- // @readme plumbing
15
+ // @documentation effect
14
16
  export function effect(fn: () => EffectResult): () => void {
15
- return createEffectNode(fn)
17
+ /* Re-runs fire after the build, so pin the scope this effect was created under
18
+ (the dep-tracking observer is managed separately, so this can't disturb it). */
19
+ const captured = CURRENT_SCOPE.current
20
+ return createEffectNode(() => inScope(captured, fn))
16
21
  }
@@ -0,0 +1,18 @@
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
+ Establishes a fresh lexical scope for an SSR render and returns the previous one
7
+ to restore (see `exitScope`). The client gets its per-component scope from
8
+ `mount`/`hydrate`; a server render has no mount, so its body brackets itself with
9
+ `enterScope`/`exitScope` — each render (and each nested child render) owns an
10
+ isolated scope, so `scope()` and `model` don't bleed across renders. Nests via the
11
+ returned previous, like `mount`'s save/restore.
12
+ */
13
+ // @documentation plumbing
14
+ export function enterScope(): Scope | undefined {
15
+ const previous = CURRENT_SCOPE.current
16
+ CURRENT_SCOPE.current = createScope({}, previous, true)
17
+ return previous
18
+ }
@@ -0,0 +1,8 @@
1
+ import { CURRENT_SCOPE } from './runtime/CURRENT_SCOPE.ts'
2
+ import type { Scope } from './types/Scope.ts'
3
+
4
+ /* Restores the scope `enterScope` saved, closing an SSR render's scope. */
5
+ // @documentation plumbing
6
+ export function exitScope(previous: Scope | undefined): void {
7
+ CURRENT_SCOPE.current = previous
8
+ }
@@ -0,0 +1,102 @@
1
+ import { PATCH_BUS } from './runtime/PATCH_BUS.ts'
2
+ import type { Doc } from './runtime/types/Doc.ts'
3
+ import type { Patch } from './runtime/types/Patch.ts'
4
+ import type { History } from './types/History.ts'
5
+
6
+ /*
7
+ Undo/redo for a document. Subscribes the `PATCH_BUS`, filters to this `doc`, and
8
+ journals each applied patch's `inverse` into a bounded stack — O(size of the
9
+ change) per step, never a tree snapshot. `undo`/`redo` replay inverses through
10
+ `doc.apply`; the patches that replay itself emits are routed into a `sink` so a
11
+ replay never opens a fresh entry nor clears the opposite stack (it instead fills
12
+ it, keeping the move reversible). A genuine new edit clears the redo stack.
13
+ `transaction(fn)` groups every patch `fn` emits into one entry. `limit` caps
14
+ depth — the oldest entry drops. Inert (but harmless) on the server.
15
+ */
16
+ // @documentation plumbing
17
+ export function history(doc: Doc, { limit = 100 }: { limit?: number } = {}): History {
18
+ const undoStack: Patch[][] = []
19
+ const redoStack: Patch[][] = []
20
+ /* When set, captured inverses accumulate here instead of opening a new undo entry
21
+ — held by an in-progress transaction and by an undo/redo replay. */
22
+ let sink: Patch[] | undefined
23
+
24
+ /* Push one finished entry, evicting the oldest past `limit`; a fresh edit (no
25
+ sink) also invalidates the redo stack. */
26
+ const push = (stack: Patch[][], entry: Patch[]): void => {
27
+ stack.push(entry)
28
+ if (stack.length > limit) {
29
+ stack.shift()
30
+ }
31
+ }
32
+
33
+ const unsubscribe = PATCH_BUS.subscribe((event) => {
34
+ if (event.doc !== doc || event.inverse === undefined) {
35
+ return
36
+ }
37
+ if (sink !== undefined) {
38
+ sink.push(event.inverse)
39
+ return
40
+ }
41
+ push(undoStack, [event.inverse])
42
+ redoStack.length = 0
43
+ })
44
+
45
+ /* Apply an entry's inverses in reverse application order (LIFO), capturing the
46
+ inverses they emit — the original forward patches — onto `target`, so the move
47
+ is itself reversible and lands on the opposite stack. */
48
+ const replay = (entry: Patch[], target: Patch[][]): void => {
49
+ const captured: Patch[] = []
50
+ sink = captured
51
+ try {
52
+ for (let index = entry.length - 1; index >= 0; index -= 1) {
53
+ doc.apply(entry[index] as Patch)
54
+ }
55
+ } finally {
56
+ sink = undefined
57
+ }
58
+ if (captured.length > 0) {
59
+ push(target, captured)
60
+ }
61
+ }
62
+
63
+ return {
64
+ undo: () => {
65
+ const entry = undoStack.pop()
66
+ if (entry !== undefined) {
67
+ replay(entry, redoStack)
68
+ }
69
+ },
70
+ redo: () => {
71
+ const entry = redoStack.pop()
72
+ if (entry !== undefined) {
73
+ replay(entry, undoStack)
74
+ }
75
+ },
76
+ canUndo: () => undoStack.length > 0,
77
+ canRedo: () => redoStack.length > 0,
78
+ transaction: (run: () => void) => {
79
+ /* A nested transaction folds into the one already collecting. */
80
+ if (sink !== undefined) {
81
+ run()
82
+ return
83
+ }
84
+ const entry: Patch[] = []
85
+ sink = entry
86
+ try {
87
+ run()
88
+ } finally {
89
+ sink = undefined
90
+ }
91
+ if (entry.length > 0) {
92
+ push(undoStack, entry)
93
+ redoStack.length = 0
94
+ }
95
+ },
96
+ dispose: () => {
97
+ unsubscribe()
98
+ undoStack.length = 0
99
+ redoStack.length = 0
100
+ },
101
+ }
102
+ }
@@ -1,7 +1,5 @@
1
1
  import { html } from '../shared/html.ts'
2
2
  import { snippet } from '../shared/snippet.ts'
3
- import { derived } from './derived.ts'
4
- import { doc } from './doc.ts'
5
3
  import { anchorCursor } from './dom/anchorCursor.ts'
6
4
  import { appendSnippet } from './dom/appendSnippet.ts'
7
5
  import { appendStatic } from './dom/appendStatic.ts'
@@ -23,13 +21,14 @@ import { switchBlock } from './dom/switchBlock.ts'
23
21
  import { tryBlock } from './dom/tryBlock.ts'
24
22
  import { when } from './dom/when.ts'
25
23
  import { effect } from './effect.ts'
26
- import { linked } from './linked.ts'
24
+ import { enterScope } from './enterScope.ts'
25
+ import { exitScope } from './exitScope.ts'
27
26
  import { enterRenderPass } from './runtime/enterRenderPass.ts'
28
27
  import { exitRenderPass } from './runtime/exitRenderPass.ts'
29
28
  import { hotReloadEnabled } from './runtime/hotReloadEnabled.ts'
30
29
  import { hotReplace } from './runtime/hotReplace.ts'
31
30
  import { nextBlockId } from './runtime/nextBlockId.ts'
32
- import { state } from './state.ts'
31
+ import { scope } from './scope.ts'
33
32
 
34
33
  /*
35
34
  Dev-only: exposes the abide-ui runtime plus `hotReplace` on `window.__abide`, and
@@ -45,10 +44,9 @@ export function installHotBridge(): void {
45
44
  ;(globalThis as { __abide?: Record<string, unknown> }).__abide = {
46
45
  html,
47
46
  snippet,
48
- doc,
49
- state,
50
- linked,
51
- derived,
47
+ scope,
48
+ enterScope,
49
+ exitScope,
52
50
  effect,
53
51
  mount,
54
52
  appendText,
@@ -17,7 +17,6 @@ never holds an un-coerced value (`return previous` rejects via the `Object.is`
17
17
  no-op). The seed is captured by reference: callers clone in the thunk
18
18
  (`linked(() => structuredClone(x))`) when they want isolation.
19
19
  */
20
- // @readme plumbing
21
20
  export function linked<T>(seed: () => T, transform?: (next: T, previous: T) => T): State<T> {
22
21
  /* The cell is a plain `state` — same store, same write path, so `transform` gates
23
22
  reseeds and explicit writes identically. */
@@ -4,7 +4,7 @@ import { runtimePath } from './runtime/runtimePath.ts'
4
4
  reactive route, which re-mounts the matching page via `router`. `replace` swaps
5
5
  the current entry instead of pushing — used when honouring a server redirect, so
6
6
  the blocked URL isn't left behind in history. */
7
- // @readme plumbing
7
+ // @documentation navigate
8
8
  export function navigate(path: string, replace = false): void {
9
9
  if (typeof history !== 'undefined') {
10
10
  if (replace) {
@@ -0,0 +1,115 @@
1
+ import { online as systemOnline } from '../shared/online.ts'
2
+ import { effect } from './effect.ts'
3
+ import { persist } from './persist.ts'
4
+ import { createDoc as doc } from './runtime/createDoc.ts'
5
+ import type { Outbox } from './types/Outbox.ts'
6
+ import type { PersistenceStore } from './types/PersistenceStore.ts'
7
+
8
+ /* One queued mutation: a stable id plus the serializable payload to replay. */
9
+ type OutboxEntry<T> = { id: string; payload: T }
10
+
11
+ /*
12
+ A durable, FIFO mutation queue for local-first writes. Built on `doc` + `persist`,
13
+ so the queue IS a persisted document — it survives a reload with no extra
14
+ machinery — and on the patch bus the same as everything else. `enqueue` appends a
15
+ payload and tries to drain; the drain sends entries head-first while `online()`
16
+ holds:
17
+ - success → dequeue and continue;
18
+ - rejected while now offline → keep the head, retry when connectivity returns
19
+ (the effect below re-drains on the online edge);
20
+ - rejected while still online → a real server/validation failure: drop the entry
21
+ so the queue can't wedge, and report it via `onDrop` so the caller can roll back
22
+ the optimistic change it applied (e.g. `history().undo()` or a `cache.patch`).
23
+ Delivery is at-least-once (a crash between a successful send and its dequeue
24
+ re-sends on reload), so `send` should be idempotent. Reconnect RESYNC of reads is
25
+ `cache.on`'s job, not this. Client-intended: `send` performs a network mutation.
26
+ */
27
+ // @documentation ui
28
+ export function outbox<T>({
29
+ key,
30
+ send,
31
+ store,
32
+ online = systemOnline,
33
+ onDrop,
34
+ }: {
35
+ key: string
36
+ send: (payload: T) => Promise<void>
37
+ store?: PersistenceStore
38
+ online?: () => boolean
39
+ onDrop?: (payload: T, error: unknown) => void
40
+ }): Outbox<T> {
41
+ const queue = doc({ items: [] as OutboxEntry<T>[] })
42
+ /* `debounce: 0` because the queue writes itself synchronously below — a queued
43
+ mutation must be durable the instant it's recorded, not after a debounce
44
+ window where a crash could lose it. */
45
+ const persistence = persist(queue, key, { store, debounce: 0 })
46
+ /* Drop the head and make the dequeue durable at once, so a reload never re-sends
47
+ an entry already acknowledged. */
48
+ const dequeue = (): void => {
49
+ queue.remove('items/0')
50
+ persistence.flush()
51
+ }
52
+
53
+ /* At most one drain runs at a time; `settled` lets a caller await the active one.
54
+ A drain re-reads the queue each step, so entries enqueued mid-drain are picked
55
+ up by the running loop. An empty drain finishes synchronously (it hits no
56
+ `await`), so `draining` is back to false before the next `enqueue` — there is no
57
+ resolved-but-still-flagged window for a new entry to fall into. */
58
+ let draining = false
59
+ let settled: Promise<void> = Promise.resolve()
60
+ const flush = (): Promise<void> => {
61
+ if (draining) {
62
+ return settled
63
+ }
64
+ draining = true
65
+ settled = (async () => {
66
+ try {
67
+ while (online()) {
68
+ const { items } = queue.snapshot() as { items: OutboxEntry<T>[] }
69
+ const head = items[0]
70
+ if (head === undefined) {
71
+ break
72
+ }
73
+ try {
74
+ await send(head.payload)
75
+ } catch (error) {
76
+ /* Went offline mid-send → keep the head, retry on reconnect. */
77
+ if (!online()) {
78
+ break
79
+ }
80
+ /* Online but rejected → permanent: drop it, report for rollback. */
81
+ dequeue()
82
+ onDrop?.(head.payload, error)
83
+ continue
84
+ }
85
+ dequeue()
86
+ }
87
+ } finally {
88
+ draining = false
89
+ }
90
+ })()
91
+ return settled
92
+ }
93
+
94
+ /* Re-drain whenever connectivity returns — reading `online()` subscribes this
95
+ effect, so the offline→online edge fires it. */
96
+ const stop = effect(() => {
97
+ if (online()) {
98
+ void flush()
99
+ }
100
+ })
101
+
102
+ return {
103
+ enqueue: (payload: T) => {
104
+ queue.add('items/-', { id: crypto.randomUUID(), payload })
105
+ persistence.flush() // durable before we attempt the network
106
+ void flush()
107
+ },
108
+ pending: () => (queue.read('items') as OutboxEntry<T>[]).map((entry) => entry.payload),
109
+ flush,
110
+ dispose: () => {
111
+ stop()
112
+ persistence.dispose()
113
+ },
114
+ }
115
+ }
@@ -0,0 +1,112 @@
1
+ import { localStoragePersistence } from './runtime/localStoragePersistence.ts'
2
+ import { PATCH_BUS } from './runtime/PATCH_BUS.ts'
3
+ import type { Doc } from './runtime/types/Doc.ts'
4
+ import type { PersistenceStore } from './types/PersistenceStore.ts'
5
+ import type { PersistHandle } from './types/PersistHandle.ts'
6
+
7
+ /*
8
+ Makes a document durable: seeds it from the saved snapshot on boot, then writes
9
+ its snapshot back (debounced) on every change the patch bus announces — so the
10
+ state survives a reload. The bus is the single tap; `persist` adds no bookkeeping
11
+ to the doc. A store-less environment (the server, or a browser without
12
+ localStorage and no injected store) returns an inert handle, so the same call is
13
+ safe to write isomorphically.
14
+
15
+ Restore is a per-top-level-key overlay (saved wins, current keeps keys saved
16
+ lacks), so a doc that boots empty fills from the snapshot while a key added in a
17
+ newer app version keeps its fresh default. Writes coalesce into one per `debounce`
18
+ window and flush when the tab is hidden, so the tail of a burst isn't lost.
19
+ */
20
+ // @documentation plumbing
21
+ export function persist(
22
+ doc: Doc,
23
+ key: string,
24
+ {
25
+ store = localStoragePersistence(),
26
+ debounce = 200,
27
+ }: { store?: PersistenceStore; debounce?: number } = {},
28
+ ): PersistHandle {
29
+ /* No durable store → inert: server render, or a browser without localStorage. */
30
+ if (store === undefined) {
31
+ return { flush: () => undefined, clear: () => undefined, dispose: () => undefined }
32
+ }
33
+
34
+ const saved = store.load(key)
35
+ if (saved !== undefined) {
36
+ restore(doc, saved)
37
+ }
38
+
39
+ /* Coalesce a burst into one write: the timer is armed by the first patch and
40
+ writes the latest snapshot when it fires, bounding staleness to `debounce`. */
41
+ let timer: ReturnType<typeof setTimeout> | undefined
42
+ const flush = (): void => {
43
+ if (timer !== undefined) {
44
+ clearTimeout(timer)
45
+ timer = undefined
46
+ }
47
+ store.save(key, doc.snapshot())
48
+ }
49
+ const schedule = (): void => {
50
+ if (timer === undefined) {
51
+ timer = setTimeout(flush, debounce)
52
+ }
53
+ }
54
+
55
+ const unsubscribe = PATCH_BUS.subscribe((event) => {
56
+ if (event.doc === doc) {
57
+ schedule()
58
+ }
59
+ })
60
+
61
+ /* Flush the tail before the tab goes away, so a pending debounced write survives. */
62
+ const onHidden = (): void => {
63
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
64
+ flush()
65
+ }
66
+ }
67
+ const canListen = typeof window !== 'undefined' && typeof window.addEventListener === 'function'
68
+ if (canListen) {
69
+ window.addEventListener('pagehide', flush)
70
+ document.addEventListener('visibilitychange', onHidden)
71
+ }
72
+
73
+ return {
74
+ flush,
75
+ clear: () => {
76
+ if (timer !== undefined) {
77
+ clearTimeout(timer)
78
+ timer = undefined
79
+ }
80
+ store.remove(key)
81
+ },
82
+ dispose: () => {
83
+ unsubscribe()
84
+ if (timer !== undefined) {
85
+ clearTimeout(timer)
86
+ timer = undefined
87
+ }
88
+ if (canListen) {
89
+ window.removeEventListener('pagehide', flush)
90
+ document.removeEventListener('visibilitychange', onHidden)
91
+ }
92
+ },
93
+ }
94
+ }
95
+
96
+ /* Load `saved` into `doc`: a per-key overlay for object roots (so the doc's own
97
+ keys that the snapshot lacks keep their value), else a wholesale root replace
98
+ (a primitive or array root). */
99
+ function restore(doc: Doc, saved: unknown): void {
100
+ const current = doc.snapshot()
101
+ if (isPlainObject(saved) && isPlainObject(current)) {
102
+ for (const key of Object.keys(saved)) {
103
+ doc.replace(key, saved[key])
104
+ }
105
+ return
106
+ }
107
+ doc.replace('', saved)
108
+ }
109
+
110
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
111
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
112
+ }
@@ -12,7 +12,7 @@ cross-origin a full browser load. A non-OK status (handle() blocked it) or a thr
12
12
  fetch (offline) also hands off to the browser, so the server's real response —
13
13
  a login page, an error page — is what renders.
14
14
  */
15
- // @readme plumbing
15
+ // @documentation plumbing
16
16
  export async function probeNavigation(path: string): Promise<NavVerdict> {
17
17
  let response: Response
18
18
  try {
@@ -22,7 +22,7 @@ the query string (GET/DELETE/HEAD). Plain `fn(args)` decodes the Response
22
22
  by Content-Type and throws HttpError on non-2xx; `.raw(args)` is the
23
23
  escape hatch that returns the Response untouched.
24
24
  */
25
- // @readme plumbing
25
+ // @documentation plumbing
26
26
  export function remoteProxy<Args, Return>(
27
27
  method: HttpVerb,
28
28
  url: string,
@@ -22,7 +22,7 @@ into the resume manifest inline — so the value is in the first paint, no pendi
22
22
  shell, no swap. The first yield therefore awaits all blocking blocks; only the
23
23
  remaining streaming blocks flush out of order after it.
24
24
  */
25
- // @readme plumbing
25
+ // @documentation plumbing
26
26
  export async function* renderToStream(render: () => SsrRender): AsyncGenerator<string> {
27
27
  const { html, awaits } = render()
28
28
  /* Blocking awaits (a `then` on the `await` tag) settle BEFORE the first flush:
@@ -42,7 +42,7 @@ adopts a document handle() already ran on, so it isn't probed. There is no serve
42
42
  router — the server picks the page by request URL directly; this is the client
43
43
  half. `*` is the fallback route.
44
44
  */
45
- // @readme plumbing
45
+ // @documentation plumbing
46
46
  export function router(
47
47
  host: Element,
48
48
  loaders: Record<string, RouteLoader>,
@@ -0,0 +1,10 @@
1
+ import type { Scope } from '../types/Scope.ts'
2
+
3
+ /*
4
+ The ambient lexical scope. The compiler establishes one per lexical level (a
5
+ component, a control-flow branch) by setting `current` around the build, so the
6
+ bare `scope()` accessor and the scope-bound primitives resolve "where they are"
7
+ with no handle threaded. Undefined outside any scope, where `scope()` mints a
8
+ detached root on first use.
9
+ */
10
+ export const CURRENT_SCOPE: { current: Scope | undefined } = { current: undefined }
@@ -4,5 +4,5 @@
4
4
  it; on the client the router mounts/hydrates the child into it and finds it by
5
5
  tag. Shared by both compiler back-ends, the SSR chain composer, and the router so
6
6
  they all agree on the marker. */
7
- // @readme plumbing
7
+ // @documentation plumbing
8
8
  export const OUTLET_TAG = 'abide-outlet'
@@ -0,0 +1,28 @@
1
+ import type { PatchEvent } from './types/PatchEvent.ts'
2
+
3
+ /*
4
+ Process-global tap for every document mutation. `createDoc` emits a `PatchEvent`
5
+ here on each applied patch; cross-cutting consumers (undo history, persistence,
6
+ sync) subscribe. One chokepoint means those features journal a single uniform
7
+ stream instead of each re-deriving change detection. `active` lets the emitter
8
+ skip computing an inverse — its only real cost — when nobody is listening.
9
+ */
10
+ const listeners = new Set<(event: PatchEvent) => void>()
11
+
12
+ export const PATCH_BUS = {
13
+ /* True only while at least one consumer is attached; gates inverse capture. */
14
+ get active(): boolean {
15
+ return listeners.size > 0
16
+ },
17
+ emit(event: PatchEvent): void {
18
+ for (const listener of listeners) {
19
+ listener(event)
20
+ }
21
+ },
22
+ subscribe(listener: (event: PatchEvent) => void): () => void {
23
+ listeners.add(listener)
24
+ return () => {
25
+ listeners.delete(listener)
26
+ }
27
+ },
28
+ }