@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.
- package/AGENTS.md +39 -34
- package/CHANGELOG.md +30 -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 +62 -37
- 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
package/src/lib/ui/dom/on.ts
CHANGED
|
@@ -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
|
-
// @
|
|
13
|
+
// @documentation plumbing
|
|
10
14
|
export function on(element: Element, type: string, handler: EventListener): void {
|
|
11
|
-
|
|
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,
|
|
19
|
+
OWNER.current.push(() => element.removeEventListener(type, wrapped))
|
|
14
20
|
}
|
|
15
21
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
16
|
-
hydration.next.set(parent, node
|
|
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
|
|
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
|
-
// @
|
|
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
|
-
// @
|
|
21
|
+
// @documentation plumbing
|
|
22
22
|
export function switchBlock(
|
|
23
23
|
parent: Node,
|
|
24
24
|
subject: () => unknown,
|
package/src/lib/ui/dom/text.ts
CHANGED
|
@@ -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
|
-
// @
|
|
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
|
-
// @
|
|
23
|
+
// @documentation plumbing
|
|
24
24
|
export function tryBlock(
|
|
25
25
|
parent: Node,
|
|
26
26
|
id: number,
|
package/src/lib/ui/dom/when.ts
CHANGED
|
@@ -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
|
-
// @
|
|
21
|
+
// @documentation plumbing
|
|
22
22
|
export function when(
|
|
23
23
|
parent: Node,
|
|
24
24
|
condition: () => unknown,
|
package/src/lib/ui/effect.ts
CHANGED
|
@@ -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
|
-
// @
|
|
15
|
+
// @documentation effect
|
|
14
16
|
export function effect(fn: () => EffectResult): () => void {
|
|
15
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
derived,
|
|
47
|
+
scope,
|
|
48
|
+
enterScope,
|
|
49
|
+
exitScope,
|
|
52
50
|
effect,
|
|
53
51
|
mount,
|
|
54
52
|
appendText,
|
package/src/lib/ui/linked.ts
CHANGED
|
@@ -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. */
|
package/src/lib/ui/navigate.ts
CHANGED
|
@@ -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
|
-
// @
|
|
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
|
-
// @
|
|
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
|
-
// @
|
|
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
|
-
// @
|
|
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:
|
package/src/lib/ui/router.ts
CHANGED
|
@@ -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
|
-
// @
|
|
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
|
-
// @
|
|
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
|
+
}
|