@abide/abide 0.47.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +26 -13
- package/CHANGELOG.md +53 -0
- package/README.md +9 -3
- package/package.json +3 -1
- package/src/buildCli.ts +3 -5
- package/src/buildDisconnected.ts +2 -3
- package/src/bundleApp.ts +2 -3
- package/src/compile.ts +2 -4
- package/src/lib/bundle/installDownloads.ts +13 -3
- package/src/lib/bundle/installMacMenu.ts +13 -3
- package/src/lib/cli/parseArgvForRpc.ts +36 -9
- package/src/lib/cli/tokenizeArgvFlags.ts +4 -2
- package/src/lib/mcp/createMcpResourceServer.ts +13 -2
- package/src/lib/mcp/toolResultFromResponse.ts +40 -12
- package/src/lib/server/rpc/parseArgs.ts +15 -1
- package/src/lib/server/rpc/runWithRpcTimeout.ts +12 -1
- package/src/lib/server/runtime/buildCacheSnapshot.ts +6 -6
- package/src/lib/server/runtime/finalizeResponse.ts +11 -1
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +10 -2
- package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
- package/src/lib/server/runtime/types/InspectorCacheSnapshot.ts +4 -4
- package/src/lib/server/runtime/types/InspectorContext.ts +1 -1
- package/src/lib/shared/buildArtifact.ts +17 -0
- package/src/lib/shared/buildRpcRequest.ts +6 -2
- package/src/lib/shared/cache.ts +66 -28
- package/src/lib/shared/cacheStores.ts +3 -3
- package/src/lib/shared/canonicalJson.ts +24 -1
- package/src/lib/shared/createChannelLog.ts +24 -0
- package/src/lib/shared/exitOnBuildFailure.ts +4 -3
- package/src/lib/shared/parseEnv.ts +17 -6
- package/src/lib/shared/refresh.ts +3 -3
- package/src/lib/shared/serializeEnv.ts +18 -6
- package/src/lib/shared/sharedCacheStore.ts +14 -0
- package/src/lib/shared/sharedCacheStoreSlot.ts +10 -0
- package/src/lib/shared/snippet.ts +11 -6
- package/src/lib/shared/types/CacheOptions.ts +15 -11
- package/src/lib/shared/types/CacheStats.ts +1 -1
- package/src/lib/shared/types/SmartReadOptions.ts +15 -11
- package/src/lib/shared/url.ts +5 -0
- package/src/lib/test/createTestApp.ts +6 -6
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/bindListenEvent.ts +5 -6
- package/src/lib/ui/compile/compileSSR.ts +1 -1
- package/src/lib/ui/compile/compileShadow.ts +121 -25
- package/src/lib/ui/compile/composeProps.ts +3 -2
- package/src/lib/ui/compile/desugarSignals.ts +1 -1
- package/src/lib/ui/compile/generateBuild.ts +23 -9
- package/src/lib/ui/compile/generateSSR.ts +49 -37
- package/src/lib/ui/compile/isWhitespaceText.ts +10 -2
- package/src/lib/ui/compile/lowerDocAccess.ts +39 -1
- package/src/lib/ui/compile/parseTemplate.ts +3 -1
- package/src/lib/ui/compile/renameSignalRefs.ts +5 -18
- package/src/lib/ui/compile/resolveReactiveExport.ts +4 -7
- package/src/lib/ui/compile/scopeCss.ts +27 -4
- package/src/lib/ui/dom/awaitBlock.ts +67 -29
- package/src/lib/ui/dom/discardBoundary.ts +24 -6
- package/src/lib/ui/dom/each.ts +15 -8
- package/src/lib/ui/dom/fillBefore.ts +6 -7
- package/src/lib/ui/dom/mergeProps.ts +1 -1
- package/src/lib/ui/dom/mountSlot.ts +1 -1
- package/src/lib/ui/dom/mutateDocArray.ts +38 -0
- package/src/lib/ui/dom/restProps.ts +2 -2
- package/src/lib/ui/dom/spreadProps.ts +3 -4
- package/src/lib/ui/dom/tryBlock.ts +10 -11
- package/src/lib/ui/dom/withScope.ts +7 -0
- package/src/lib/ui/history.ts +14 -7
- package/src/lib/ui/installHotBridge.ts +2 -0
- package/src/lib/ui/props.ts +17 -0
- package/src/lib/ui/renderChain.ts +7 -3
- package/src/lib/ui/router.ts +23 -17
- package/src/lib/ui/runtime/CHILD_PRESENT.ts +2 -2
- package/src/lib/ui/runtime/applyPatchToTree.ts +16 -3
- package/src/lib/ui/runtime/captureModelDoc.ts +22 -7
- package/src/lib/ui/runtime/createDoc.ts +28 -12
- package/src/lib/ui/runtime/flushEffects.ts +23 -1
- package/src/lib/ui/runtime/scope.ts +11 -0
- package/src/lib/ui/runtime/toTeardown.ts +10 -3
- package/src/lib/ui/runtime/types/UiProps.ts +6 -5
- package/src/lib/ui/runtime/withoutHydration.ts +22 -0
- package/src/lib/ui/startClient.ts +3 -3
- package/src/lib/ui/state.ts +9 -3
- package/src/serverEntry.ts +11 -9
- package/src/lib/shared/globalCacheStore.ts +0 -15
- package/src/lib/shared/globalCacheStoreSlot.ts +0 -12
|
@@ -3,7 +3,7 @@ import type { UiProps } from '../runtime/types/UiProps.ts'
|
|
|
3
3
|
/*
|
|
4
4
|
Composes a child's props from ordered layers — explicit prop runs (`{ name: () =>
|
|
5
5
|
value }` thunk maps), `{...spread}` layers (`spreadProps`), and a trailing slot
|
|
6
|
-
layer (
|
|
6
|
+
layer (`children`) — into one prop bag, last layer wins per key (source order, like
|
|
7
7
|
JSX). A key resolves by scanning layers in reverse for the first that carries it, so
|
|
8
8
|
an explicit prop after a spread overrides it and vice-versa. Emitted only when a
|
|
9
9
|
component carries a spread; the plain object literal stays the path otherwise.
|
|
@@ -8,7 +8,7 @@ import { openMarker } from './openMarker.ts'
|
|
|
8
8
|
/*
|
|
9
9
|
Mounts a component's `<slot>` content as a marker-bounded range, so a slot positions among
|
|
10
10
|
static siblings exactly like a control-flow block — by `before` (create) or the claim
|
|
11
|
-
cursor (hydrate). `render` appends the parent-supplied
|
|
11
|
+
cursor (hydrate). `render` appends the parent-supplied `children`, or the slot's own
|
|
12
12
|
fallback when none was passed; it runs once (a slot never toggles), so there is no effect or
|
|
13
13
|
re-render — the markers exist only to delimit the range for create insertion and hydrate
|
|
14
14
|
claiming.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Doc } from '../runtime/types/Doc.ts'
|
|
2
|
+
import { readCall } from './readCall.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
An in-place-mutating array method lowered on a reactive-document array. The
|
|
6
|
+
doc-access lowering rewrites e.g. `model.items.splice(i, 1)` to
|
|
7
|
+
`$$mutateDocArray(model, "items", "splice", [i, 1])` rather than the bare
|
|
8
|
+
`$$readCall(model.read("items"), …)` it emits for non-mutating calls. The reason:
|
|
9
|
+
`read` returns the live tree array by reference, so a bare `.splice()`/`.sort()`/…
|
|
10
|
+
would mutate the document in place while never emitting a patch — no reader would
|
|
11
|
+
re-render and undo/persistence/multiplayer sync (all keyed off `apply`) would
|
|
12
|
+
never see the change (`.push` is the one already-handled exception, lowered to
|
|
13
|
+
`add` patches). Cloning the array, applying the mutation to the copy, and writing
|
|
14
|
+
it back through `replace` emits a real patch: readers wake and the change is
|
|
15
|
+
journalled. Element identity is preserved for untouched entries (a shallow
|
|
16
|
+
`slice`), so a keyed `{#for}` reconciles instead of rebuilding. The native
|
|
17
|
+
method's return value (spliced-out elements, the new length, …) is returned
|
|
18
|
+
unchanged.
|
|
19
|
+
*/
|
|
20
|
+
// @documentation plumbing
|
|
21
|
+
export function mutateDocArray(doc: Doc, path: string, member: string, args: unknown[]): unknown {
|
|
22
|
+
const current = doc.read<unknown>(path)
|
|
23
|
+
/* Not an array — nothing patchable to clone. Fall back to the guarded in-place
|
|
24
|
+
call so the author still gets readCall's authored-scope error message. */
|
|
25
|
+
if (!Array.isArray(current)) {
|
|
26
|
+
return readCall(current, path, member, args)
|
|
27
|
+
}
|
|
28
|
+
const copy = current.slice()
|
|
29
|
+
const method = (copy as unknown as Record<string, unknown>)[member]
|
|
30
|
+
if (typeof method !== 'function') {
|
|
31
|
+
throw new TypeError(
|
|
32
|
+
`abide: cannot call .${member}() — "${path}".${member} is not a function (got ${typeof method})`,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
const returned = (method as (...callArgs: unknown[]) => unknown).apply(copy, args)
|
|
36
|
+
doc.replace(path, copy)
|
|
37
|
+
return returned
|
|
38
|
+
}
|
|
@@ -6,14 +6,14 @@ object of the UNCONSUMED prop values. A child receives prop THUNKS, so `rest.key
|
|
|
6
6
|
unwraps them (`$props[key]?.()`), tracking as a reactive dependency; a top-level
|
|
7
7
|
page/layout instead receives its route params as PLAIN values, so a non-function
|
|
8
8
|
value is returned as-is rather than called (which would throw `value is not a
|
|
9
|
-
function`). The explicitly-destructured keys and the
|
|
9
|
+
function`). The explicitly-destructured keys and the `children` slot are excluded.
|
|
10
10
|
Enumerable (`ownKeys`/`for…in`/`Object.keys`), so `{...rest}` can forward the
|
|
11
11
|
remaining props onto a child or a native element. Key membership is live, but a
|
|
12
12
|
consumer that captures the key SET (a `{...rest}` spread) snapshots it at that point.
|
|
13
13
|
*/
|
|
14
14
|
// @documentation plumbing
|
|
15
15
|
export function restProps(props: UiProps, consumed: string[]): Record<string, unknown> {
|
|
16
|
-
const skip = new Set([...consumed, '
|
|
16
|
+
const skip = new Set([...consumed, 'children'])
|
|
17
17
|
const bag = props as Record<string, unknown>
|
|
18
18
|
const visible = (key: string | symbol): key is string =>
|
|
19
19
|
typeof key === 'string' && !skip.has(key) && key in bag
|
|
@@ -5,7 +5,7 @@ reads a spread key exactly like an authored one (`$props[key]?.()`). `source` is
|
|
|
5
5
|
THUNK over the spread expression (not its value), re-evaluated on each key read and
|
|
6
6
|
membership test, so a reactive source stays live both when its keys mutate and when
|
|
7
7
|
the whole object is replaced — and the read registers as the reader's dependency. A
|
|
8
|
-
nullish source spreads nothing. The reserved
|
|
8
|
+
nullish source spreads nothing. The reserved `children` slot key is never surfaced —
|
|
9
9
|
a source happening to carry one must not masquerade as slot content.
|
|
10
10
|
*/
|
|
11
11
|
// @documentation plumbing
|
|
@@ -13,9 +13,8 @@ export function spreadProps(
|
|
|
13
13
|
source: () => Record<string, unknown> | null | undefined,
|
|
14
14
|
): Record<string, () => unknown> {
|
|
15
15
|
/* A spread key the merged bag exposes — present on the current source and not the
|
|
16
|
-
reserved
|
|
17
|
-
const carries = (key: string | symbol): boolean =>
|
|
18
|
-
key !== '$children' && key in (source() ?? {})
|
|
16
|
+
reserved `children` slot key (a source carrying one must not become slot content). */
|
|
17
|
+
const carries = (key: string | symbol): boolean => key !== 'children' && key in (source() ?? {})
|
|
19
18
|
return new Proxy(
|
|
20
19
|
{},
|
|
21
20
|
{
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { claimExpected } from '../runtime/claimExpected.ts'
|
|
2
2
|
import { OWNER } from '../runtime/OWNER.ts'
|
|
3
3
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
4
4
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
5
|
+
import { withoutHydration } from '../runtime/withoutHydration.ts'
|
|
5
6
|
import { discardBoundary } from './discardBoundary.ts'
|
|
6
7
|
import { enterNamespace } from './enterNamespace.ts'
|
|
7
8
|
|
|
@@ -59,12 +60,14 @@ export function tryBlock(
|
|
|
59
60
|
|
|
60
61
|
const hydration = RENDER.hydration
|
|
61
62
|
if (hydration !== undefined) {
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
// Guaranteed control-flow markers — claimExpected throws a legible desync (the close
|
|
64
|
+
// is caught below → rebuild the catch fresh) instead of claiming null and over-clearing.
|
|
65
|
+
const open = claimExpected(hydration, parent, `abide:try:${id} open marker`)
|
|
66
|
+
hydration.next.set(parent, open.nextSibling ?? null) // advance past the open marker
|
|
64
67
|
try {
|
|
65
68
|
guard(() => renderTry(parent)) // claims the guarded nodes in place
|
|
66
|
-
const close =
|
|
67
|
-
hydration.next.set(parent, close
|
|
69
|
+
const close = claimExpected(hydration, parent, `/abide:try:${id} close marker`)
|
|
70
|
+
hydration.next.set(parent, close.nextSibling ?? null)
|
|
68
71
|
} catch (error) {
|
|
69
72
|
/* The server markup didn't adopt — drop the whole boundary and build the
|
|
70
73
|
catch fresh in its place. */
|
|
@@ -72,15 +75,11 @@ export function tryBlock(
|
|
|
72
75
|
if (renderCatch === undefined) {
|
|
73
76
|
throw error
|
|
74
77
|
}
|
|
75
|
-
|
|
76
|
-
RENDER.hydration = undefined
|
|
77
|
-
try {
|
|
78
|
+
withoutHydration(() => {
|
|
78
79
|
const fragment = document.createDocumentFragment()
|
|
79
80
|
enterNamespace(parent, () => guard(() => renderCatch(fragment, error)))
|
|
80
81
|
parent.insertBefore(fragment, after)
|
|
81
|
-
}
|
|
82
|
-
RENDER.hydration = previous
|
|
83
|
-
}
|
|
82
|
+
})
|
|
84
83
|
}
|
|
85
84
|
return
|
|
86
85
|
}
|
|
@@ -22,6 +22,13 @@ export function withScope(label: string | undefined, build: () => () => void): {
|
|
|
22
22
|
CURRENT_SCOPE.current = lexical
|
|
23
23
|
try {
|
|
24
24
|
lexical.own(build())
|
|
25
|
+
} catch (error) {
|
|
26
|
+
/* `build` threw before returning its stopper — e.g. a hydration desync in one of
|
|
27
|
+
several sibling blocks. `own` never ran, so nothing downstream will dispose this
|
|
28
|
+
lexical scope; tear it down here (its child scopes/effects created before the
|
|
29
|
+
throw) before rethrowing, rather than leaking a live, unreachable scope. */
|
|
30
|
+
lexical.dispose()
|
|
31
|
+
throw error
|
|
25
32
|
} finally {
|
|
26
33
|
exitRenderPass()
|
|
27
34
|
CURRENT_SCOPE.current = parentScope
|
package/src/lib/ui/history.ts
CHANGED
|
@@ -53,9 +53,12 @@ export function history(doc: Doc, { limit = 100 }: { limit?: number } = {}): His
|
|
|
53
53
|
}
|
|
54
54
|
} finally {
|
|
55
55
|
sink = undefined
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
/* Push captured inverses even if `apply` threw mid-loop: the already-popped
|
|
57
|
+
entry is gone, so the forward patches for what DID apply must land on the
|
|
58
|
+
opposite stack or a partial replay becomes irreversible. */
|
|
59
|
+
if (captured.length > 0) {
|
|
60
|
+
push(target, captured)
|
|
61
|
+
}
|
|
59
62
|
}
|
|
60
63
|
}
|
|
61
64
|
|
|
@@ -86,10 +89,14 @@ export function history(doc: Doc, { limit = 100 }: { limit?: number } = {}): His
|
|
|
86
89
|
run()
|
|
87
90
|
} finally {
|
|
88
91
|
sink = undefined
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
/* Commit whatever was applied even if `run` threw partway: those patches are
|
|
93
|
+
already live in the doc, so their inverses must reach the undo stack or the
|
|
94
|
+
committed changes are left with no way to undo them. The throw still
|
|
95
|
+
propagates after this. */
|
|
96
|
+
if (entry.length > 0) {
|
|
97
|
+
push(undoStack, entry)
|
|
98
|
+
redoStack.length = 0
|
|
99
|
+
}
|
|
93
100
|
}
|
|
94
101
|
},
|
|
95
102
|
dispose: () => {
|
|
@@ -16,6 +16,7 @@ import { mergeProps } from './dom/mergeProps.ts'
|
|
|
16
16
|
import { mount } from './dom/mount.ts'
|
|
17
17
|
import { mountChild } from './dom/mountChild.ts'
|
|
18
18
|
import { mountSlot } from './dom/mountSlot.ts'
|
|
19
|
+
import { mutateDocArray } from './dom/mutateDocArray.ts'
|
|
19
20
|
import { on } from './dom/on.ts'
|
|
20
21
|
import { outlet } from './dom/outlet.ts'
|
|
21
22
|
import { readCall } from './dom/readCall.ts'
|
|
@@ -82,6 +83,7 @@ export function installHotBridge(): void {
|
|
|
82
83
|
restProps,
|
|
83
84
|
spreadAttrs,
|
|
84
85
|
readCall,
|
|
86
|
+
mutateDocArray,
|
|
85
87
|
hydrate,
|
|
86
88
|
escapeKey,
|
|
87
89
|
nextBlockId,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The prop reader. In a `.abide` component the compiler rewrites `const { name } =
|
|
3
|
+
props()` to reads off the component's prop bag (`$props`), so this runtime body
|
|
4
|
+
never executes there — it exists for import resolution and for typing `props()`
|
|
5
|
+
in plain `.ts` modules. Called directly (only possible outside a compiled
|
|
6
|
+
component) it throws, like `request()` outside a request scope, rather than
|
|
7
|
+
silently returning undefined.
|
|
8
|
+
|
|
9
|
+
The return type is `T` (default `Record<string, unknown>`); inside a `.abide`
|
|
10
|
+
file the check shadow supplies the file-contextual `RouteShape & T` instead.
|
|
11
|
+
*/
|
|
12
|
+
// @documentation reactive-state
|
|
13
|
+
export function props<T = Record<string, unknown>>(): T {
|
|
14
|
+
throw new Error(
|
|
15
|
+
'[abide] props() is compiler-lowered inside a .abide component and has no runtime meaning when called directly',
|
|
16
|
+
)
|
|
17
|
+
}
|
|
@@ -35,8 +35,10 @@ export async function renderChain(
|
|
|
35
35
|
const renders: SsrRender[] = []
|
|
36
36
|
/* Route params as thunks (static server-side — only shape parity with the client so
|
|
37
37
|
`props()` reads `$props["id"]?.()` resolve). A layout (every view but the last) also
|
|
38
|
-
gets
|
|
39
|
-
the client does, keeping hydration congruent.
|
|
38
|
+
gets `children` set to `CHILD_PRESENT` so SSR renders `{#if children}` the same way
|
|
39
|
+
the client does, keeping hydration congruent. The sentinel itself must be thunk-wrapped
|
|
40
|
+
(`() => CHILD_PRESENT`) since the destructure lowering CALLS every bag entry — leaving
|
|
41
|
+
it raw would invoke `CHILD_PRESENT` (a function) and read back `undefined`. */
|
|
40
42
|
const paramThunks: UiProps = {}
|
|
41
43
|
for (const key of Object.keys(params)) {
|
|
42
44
|
paramThunks[key] = () => params[key]
|
|
@@ -44,7 +46,9 @@ export async function renderChain(
|
|
|
44
46
|
for (let index = 0; index < views.length; index += 1) {
|
|
45
47
|
const view = views[index] as UiComponent
|
|
46
48
|
const hasChild = index < views.length - 1
|
|
47
|
-
const props: UiProps = hasChild
|
|
49
|
+
const props: UiProps = hasChild
|
|
50
|
+
? { ...paramThunks, children: () => CHILD_PRESENT }
|
|
51
|
+
: paramThunks
|
|
48
52
|
renders.push(await view.render(props, ctx))
|
|
49
53
|
}
|
|
50
54
|
let html = renders[renders.length - 1]?.html ?? ''
|
package/src/lib/ui/router.ts
CHANGED
|
@@ -200,17 +200,19 @@ export function router(
|
|
|
200
200
|
/* Route params as reactive thunks: reading `clientPage.value.params` inside the
|
|
201
201
|
thunk tracks the page signal, so an in-place same-route hop (params change, the
|
|
202
202
|
page stays mounted) re-runs each `props()` derive. The key set is stable across
|
|
203
|
-
same-route hops, so the bag need not rebuild. A layout also gets
|
|
203
|
+
same-route hops, so the bag need not rebuild. A layout also gets `children`
|
|
204
204
|
set to `CHILD_PRESENT` when a child layer exists below it, read by
|
|
205
205
|
`{#if children}` (a layout's `{children()}` lowers to its `outlet()` boundary,
|
|
206
|
-
so it ignores this value).
|
|
206
|
+
so it ignores this value). Every bag entry is a thunk — `props()` destructure
|
|
207
|
+
lowers to `$props["key"]?.()` — so the sentinel must be wrapped too, or calling
|
|
208
|
+
it would invoke `CHILD_PRESENT` and yield `undefined` instead of the sentinel. */
|
|
207
209
|
const propsBag = (hasChild: boolean): UiProps => {
|
|
208
210
|
const bag: UiProps = {}
|
|
209
211
|
for (const key of Object.keys(params)) {
|
|
210
212
|
bag[key] = () => clientPage.value.params[key]
|
|
211
213
|
}
|
|
212
214
|
if (hasChild) {
|
|
213
|
-
bag
|
|
215
|
+
bag.children = () => CHILD_PRESENT
|
|
214
216
|
}
|
|
215
217
|
return bag
|
|
216
218
|
}
|
|
@@ -379,6 +381,15 @@ export function router(
|
|
|
379
381
|
would re-run the router and re-mount the page, dropping local state. */
|
|
380
382
|
const path = runtimePath.value
|
|
381
383
|
untrack(() => {
|
|
384
|
+
/* Capture whether THIS run is the first paint, then flip the shared flag
|
|
385
|
+
synchronously. Previously `first` was only cleared inside the async `.then`,
|
|
386
|
+
so a second navigation that began before the boot resolve settled (a
|
|
387
|
+
double-tapped back button, two quick `navigate()` calls) still read
|
|
388
|
+
`first === true` and skipped its auth/redirect probe. Each run's `.then`
|
|
389
|
+
closes over its own `isFirstRun`, so hydration still keys off the real first
|
|
390
|
+
paint. */
|
|
391
|
+
const isFirstRun = first
|
|
392
|
+
first = false
|
|
382
393
|
/* The route matches on the pathname only; the query/hash ride along for
|
|
383
394
|
the probe (so server gating sees them) and for clientPage.url. */
|
|
384
395
|
const pathname = path.split(/[?#]/)[0] ?? path
|
|
@@ -390,7 +401,7 @@ export function router(
|
|
|
390
401
|
const targetUrl = resolveUrl(path)
|
|
391
402
|
const mountedUrl = clientPage.value.url
|
|
392
403
|
if (
|
|
393
|
-
!
|
|
404
|
+
!isFirstRun &&
|
|
394
405
|
mountedUrl.pathname === targetUrl.pathname &&
|
|
395
406
|
mountedUrl.search === targetUrl.search &&
|
|
396
407
|
mountedUrl.hash !== targetUrl.hash
|
|
@@ -422,13 +433,13 @@ export function router(
|
|
|
422
433
|
Skipped on first paint — there is no page to leave. An instant SPA hop
|
|
423
434
|
(chunk cached, no probe) flips it back within the same microtask, so the
|
|
424
435
|
browser never paints the intermediate state — no flash. */
|
|
425
|
-
if (!
|
|
436
|
+
if (!isFirstRun && !clientPage.value.navigating) {
|
|
426
437
|
clientPage.value = { ...clientPage.value, navigating: true }
|
|
427
438
|
}
|
|
428
439
|
/* First paint adopts a document the server already ran handle() on;
|
|
429
440
|
only later navigations re-run it through the probe. */
|
|
430
441
|
const verdict: Promise<NavVerdict> =
|
|
431
|
-
|
|
442
|
+
isFirstRun || probe === undefined ? Promise.resolve({ kind: 'mount' }) : probe(path)
|
|
432
443
|
/* Resolve the page chunk, its layout chunks, and the gate in parallel,
|
|
433
444
|
keeping the current chain mounted until all land — no blank frame while
|
|
434
445
|
imports are in flight or the probe is in the air. */
|
|
@@ -439,14 +450,10 @@ export function router(
|
|
|
439
450
|
])
|
|
440
451
|
.then(([pageView, resolvedLayouts, decision]) => {
|
|
441
452
|
if (token !== sequence || disposed) {
|
|
442
|
-
/*
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
own — claimExpected then throws and commit swallows it, stranding a
|
|
447
|
-
broken page. Cleared here → the superseding nav builds create-mode
|
|
448
|
-
(clearing the stale SSR DOM) and runs its probe. */
|
|
449
|
-
first = false
|
|
453
|
+
/* Superseded (or disposed) — drop this build. The first-paint flag was
|
|
454
|
+
already consumed synchronously at run start (`first = false`), so a
|
|
455
|
+
navigation that started after this one already saw `first === false`,
|
|
456
|
+
ran its probe, and builds in create mode over the stale SSR DOM. */
|
|
450
457
|
return
|
|
451
458
|
}
|
|
452
459
|
/* handle() redirected: go where it pointed, replacing the blocked
|
|
@@ -474,9 +481,8 @@ export function router(
|
|
|
474
481
|
) {
|
|
475
482
|
divergence += 1
|
|
476
483
|
}
|
|
477
|
-
const firstPaint =
|
|
478
|
-
const hydrating =
|
|
479
|
-
first = false
|
|
484
|
+
const firstPaint = isFirstRun
|
|
485
|
+
const hydrating = isFirstRun && pageView?.hydratable === true
|
|
480
486
|
/* Same page, same layout chain — only params/url differ (e.g. stepping
|
|
481
487
|
between episodes on one detail page). The whole structure survives, so
|
|
482
488
|
publish the new snapshot on the reactive `page` proxy and let the mounted
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/*
|
|
2
2
|
A no-op truthiness marker the router (client) and `renderChain` (SSR) set as a layout's
|
|
3
|
-
`$props
|
|
4
|
-
only by `{#if children}` (→ `$props
|
|
3
|
+
`$props.children` when a child layer (a nested layout or the page) exists below it. Read
|
|
4
|
+
only by `{#if children}` (→ `$props?.children`); a layout's `{children()}` lowers to the
|
|
5
5
|
`outlet()` boundary the router fills, so this value is never invoked — it exists purely so
|
|
6
6
|
`{#if children}` reads a uniform presence signal on both the client and the server.
|
|
7
7
|
*/
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { setOwnProperty } from '../../shared/setOwnProperty.ts'
|
|
1
2
|
import type { Patch } from './types/Patch.ts'
|
|
2
3
|
|
|
3
4
|
/*
|
|
@@ -21,16 +22,28 @@ export function applyPatchToTree(tree: unknown, patch: Patch, segments: string[]
|
|
|
21
22
|
}
|
|
22
23
|
let parent = tree as Record<string, unknown>
|
|
23
24
|
for (const segment of segments.slice(0, -1)) {
|
|
24
|
-
|
|
25
|
+
/* Only descend into an OWN property. A segment of `__proto__`/`constructor`/
|
|
26
|
+
`prototype` is not own on a plain data object/array, so this refuses to walk
|
|
27
|
+
into a shared prototype (or the constructor function) that a later write would
|
|
28
|
+
then pollute — the real vector, since `apply` is reachable from `sync()` with
|
|
29
|
+
unvalidated peer-controlled patch paths. A legit document key that happens to be
|
|
30
|
+
named `constructor` is an own data property, so `hasOwn` still traverses it. */
|
|
31
|
+
const next = Object.hasOwn(parent, segment) ? parent[segment] : undefined
|
|
32
|
+
if (next === null || typeof next !== 'object') {
|
|
33
|
+
throw new TypeError(
|
|
34
|
+
`abide: patch path segment "${segment}" does not address a container`,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
parent = next as Record<string, unknown>
|
|
25
38
|
}
|
|
26
39
|
const key = segments[segments.length - 1] as string
|
|
27
40
|
if (patch.op === 'replace') {
|
|
28
|
-
parent
|
|
41
|
+
setOwnProperty(parent, key, patch.value)
|
|
29
42
|
} else if (patch.op === 'add') {
|
|
30
43
|
if (Array.isArray(parent)) {
|
|
31
44
|
parent.splice(key === '-' ? parent.length : Number(key), 0, patch.value)
|
|
32
45
|
} else {
|
|
33
|
-
parent
|
|
46
|
+
setOwnProperty(parent, key, patch.value)
|
|
34
47
|
}
|
|
35
48
|
} else if (Array.isArray(parent)) {
|
|
36
49
|
parent.splice(Number(key), 1)
|
|
@@ -1,28 +1,43 @@
|
|
|
1
1
|
import { PATCH_BUS } from './PATCH_BUS.ts'
|
|
2
2
|
import type { Doc } from './types/Doc.ts'
|
|
3
3
|
|
|
4
|
+
/* Stack of in-flight captures, innermost last. `mountChild` wraps every hot-tracked child
|
|
5
|
+
mount in its own `captureModelDoc`, and these NEST inside a parent's build, so a single
|
|
6
|
+
shared PATCH_BUS would let a parent's listener (subscribed first) grab a nested child's
|
|
7
|
+
model. Assigning each patch only to the innermost active capture keeps a component's model
|
|
8
|
+
scoped to its OWN build. */
|
|
9
|
+
const captureStack: { model: Doc | undefined }[] = []
|
|
10
|
+
|
|
4
11
|
/*
|
|
5
12
|
Runs a component `build` and returns its result (the mount handle / disposer)
|
|
6
13
|
alongside the component's own `model` document — the serializable `state` doc,
|
|
7
14
|
needed so a hot swap can carry its value across (see `hotReplace`). The model is
|
|
8
15
|
found, not threaded: a component seeds its `model` first (the desugared
|
|
9
16
|
`const $$model = scope()` + its init `replace` patches (state slots lowered by `lowerDocAccess`) run before any child mounts or
|
|
10
|
-
control-flow blocks), so the FIRST patch announced
|
|
11
|
-
it. A component with no `state` mints no model and emits nothing first
|
|
12
|
-
then `undefined` and there is nothing to preserve.
|
|
13
|
-
|
|
17
|
+
control-flow blocks), so the FIRST patch announced during THIS build (not a nested
|
|
18
|
+
child's) names it. A component with no `state` mints no model and emits nothing first
|
|
19
|
+
— `model` is then `undefined` and there is nothing to preserve. A nested child mount
|
|
20
|
+
pushes its own frame, so its state-init patch names the child's model, not the parent's
|
|
21
|
+
(the M5 stateless-parent/stateful-child fix). Used only on the hot path; the subscription
|
|
22
|
+
is torn down with the build.
|
|
14
23
|
*/
|
|
15
24
|
export function captureModelDoc<T>(build: () => T): {
|
|
16
25
|
value: T
|
|
17
26
|
model: Doc | undefined
|
|
18
27
|
} {
|
|
19
|
-
|
|
28
|
+
const frame: { model: Doc | undefined } = { model: undefined }
|
|
29
|
+
captureStack.push(frame)
|
|
20
30
|
const unsubscribe = PATCH_BUS.subscribe((event) => {
|
|
21
|
-
|
|
31
|
+
/* Only the innermost active capture claims a patch — a nested child mounting during
|
|
32
|
+
this build has pushed a deeper frame, so its patches don't leak up to this one. */
|
|
33
|
+
if (captureStack[captureStack.length - 1] === frame) {
|
|
34
|
+
frame.model ??= event.doc
|
|
35
|
+
}
|
|
22
36
|
})
|
|
23
37
|
try {
|
|
24
|
-
return { value: build(), model }
|
|
38
|
+
return { value: build(), model: frame.model }
|
|
25
39
|
} finally {
|
|
26
40
|
unsubscribe()
|
|
41
|
+
captureStack.pop()
|
|
27
42
|
}
|
|
28
43
|
}
|
|
@@ -316,23 +316,39 @@ export function createDoc(initial: unknown): Doc {
|
|
|
316
316
|
const node = nodeFor(path)
|
|
317
317
|
const segments = pathSegments(path)
|
|
318
318
|
const leafKey = segments[segments.length - 1] as string
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
319
|
+
const ancestors = segments.slice(0, -1)
|
|
320
|
+
/* The resolved parent container, cached after the first write. `get` never needs
|
|
321
|
+
it (it reads through `node`), so it is not computed at cell creation. */
|
|
322
|
+
let parent: Record<string, unknown> | undefined
|
|
323
|
+
/* Auto-vivify missing ancestor objects so a `set` on a nested path bound over a
|
|
324
|
+
doc booted shallow (e.g. `state({})`) writes into the LIVE tree (snapshot/persist
|
|
325
|
+
see it) rather than crashing. Done LAZILY on the first write, NOT at cell
|
|
326
|
+
creation: `hoistCells` lifts a cell to component-mount scope, so vivifying eagerly
|
|
327
|
+
would fabricate container structure for a path only ever written behind a branch /
|
|
328
|
+
in a handler that never runs. Mirrors applyPatchToTree's container assumption,
|
|
329
|
+
but the intermediates may not exist yet since this walk is compiler-emitted. */
|
|
330
|
+
const resolveParent = (): Record<string, unknown> => {
|
|
331
|
+
let current = tree as Record<string, unknown>
|
|
332
|
+
for (let index = 0; index < ancestors.length; index += 1) {
|
|
333
|
+
const segment = ancestors[index] as string
|
|
334
|
+
let next = current[segment]
|
|
335
|
+
if (next === null || typeof next !== 'object') {
|
|
336
|
+
/* Create an array when the NEXT segment addresses an array element (a
|
|
337
|
+
numeric index or the `-` push slot), else a plain object — otherwise a
|
|
338
|
+
bound path like `items/0/name` on a shallow doc fabricates `items` as an
|
|
339
|
+
object keyed by "0", which a later `add("items/-", …)` can't push into. */
|
|
340
|
+
const childKey = (ancestors[index + 1] ?? leafKey) as string
|
|
341
|
+
next = childKey === '-' || /^\d+$/.test(childKey) ? [] : {}
|
|
342
|
+
current[segment] = next
|
|
343
|
+
}
|
|
344
|
+
current = next as Record<string, unknown>
|
|
330
345
|
}
|
|
331
|
-
|
|
346
|
+
return current
|
|
332
347
|
}
|
|
333
348
|
return {
|
|
334
349
|
get: () => readNode(node) as T,
|
|
335
350
|
set: (value: T) => {
|
|
351
|
+
parent ??= resolveParent()
|
|
336
352
|
parent[leafKey] = value
|
|
337
353
|
writeNode(node, value)
|
|
338
354
|
},
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { NODE_STATE } from './NODE_STATE.ts'
|
|
1
2
|
import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
|
|
2
3
|
import type { ReactiveNode } from './types/ReactiveNode.ts'
|
|
3
4
|
import { updateIfNecessary } from './updateIfNecessary.ts'
|
|
@@ -39,13 +40,34 @@ export function flushEffects(): void {
|
|
|
39
40
|
|
|
40
41
|
function drain(): void {
|
|
41
42
|
let spare: ReactiveNode[] = []
|
|
43
|
+
let errors: unknown[] | undefined
|
|
42
44
|
do {
|
|
43
45
|
const batch = REACTIVE_CONTEXT.pendingEffects
|
|
44
46
|
REACTIVE_CONTEXT.pendingEffects = spare
|
|
45
47
|
for (let index = 0; index < batch.length; index += 1) {
|
|
46
|
-
|
|
48
|
+
const node = batch[index] as ReactiveNode
|
|
49
|
+
try {
|
|
50
|
+
updateIfNecessary(node)
|
|
51
|
+
} catch (error) {
|
|
52
|
+
/* One effect throwing must not strand the effects queued behind it — they
|
|
53
|
+
live in this same `batch`, which becomes unreachable the moment we swap
|
|
54
|
+
`pendingEffects`. Reset the culprit to CLEAN so a later write to its
|
|
55
|
+
dependencies can re-queue it (otherwise `mark`'s CLEAN→dirty gate leaves
|
|
56
|
+
it permanently inert), then keep draining and surface the error(s) once
|
|
57
|
+
the graph has settled rather than swallowing them. */
|
|
58
|
+
node.status = NODE_STATE.CLEAN
|
|
59
|
+
if (errors === undefined) {
|
|
60
|
+
errors = []
|
|
61
|
+
}
|
|
62
|
+
errors.push(error)
|
|
63
|
+
}
|
|
47
64
|
}
|
|
48
65
|
batch.length = 0
|
|
49
66
|
spare = batch
|
|
50
67
|
} while (REACTIVE_CONTEXT.pendingEffects.length > 0)
|
|
68
|
+
if (errors !== undefined) {
|
|
69
|
+
throw errors.length === 1
|
|
70
|
+
? errors[0]
|
|
71
|
+
: new AggregateError(errors, 'abide: effects threw during flush')
|
|
72
|
+
}
|
|
51
73
|
}
|
|
@@ -23,6 +23,17 @@ export function scope(build: () => void): () => void {
|
|
|
23
23
|
OWNER.current = disposers
|
|
24
24
|
try {
|
|
25
25
|
untrack(build)
|
|
26
|
+
} catch (error) {
|
|
27
|
+
/* A build that throws partway (e.g. a hydration desync deep in a subtree) has
|
|
28
|
+
already created and subscribed some effects/listeners. The caller never
|
|
29
|
+
receives this scope's disposer on the throw path, so tear down what was built —
|
|
30
|
+
in reverse, children before parents — before rethrowing; otherwise those effects
|
|
31
|
+
stay subscribed forever with no owner able to dispose them. */
|
|
32
|
+
for (let index = disposers.length - 1; index >= 0; index -= 1) {
|
|
33
|
+
disposers[index]?.()
|
|
34
|
+
}
|
|
35
|
+
disposers.length = 0
|
|
36
|
+
throw error
|
|
26
37
|
} finally {
|
|
27
38
|
OWNER.current = previous
|
|
28
39
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { abideLog } from '../../shared/abideLog.ts'
|
|
1
2
|
import type { EffectResult } from './types/EffectResult.ts'
|
|
2
3
|
import type { Teardown } from './types/Teardown.ts'
|
|
3
4
|
|
|
@@ -15,15 +16,21 @@ export function toTeardown(result: EffectResult): Teardown | undefined {
|
|
|
15
16
|
}
|
|
16
17
|
if (result instanceof Promise) {
|
|
17
18
|
return () => {
|
|
18
|
-
/* Swallow a rejection: an async body that rejected (e.g. an aborted RPC) must
|
|
19
|
-
not surface as an unhandled rejection when the teardown runs at dispose. */
|
|
20
19
|
result.then(
|
|
21
20
|
(teardown) => {
|
|
22
21
|
if (typeof teardown === 'function') {
|
|
23
22
|
teardown()
|
|
24
23
|
}
|
|
25
24
|
},
|
|
26
|
-
() =>
|
|
25
|
+
(error) => {
|
|
26
|
+
/* A superseded reactive read aborts its in-flight RPC — expected, stays
|
|
27
|
+
quiet. Any OTHER rejection is a real bug in the async effect body;
|
|
28
|
+
surface it (the visibility goal) instead of silently discarding it. */
|
|
29
|
+
const aborted = (error as { name?: string } | undefined)?.name === 'AbortError'
|
|
30
|
+
if (!aborted) {
|
|
31
|
+
abideLog.error(error)
|
|
32
|
+
}
|
|
33
|
+
},
|
|
27
34
|
)
|
|
28
35
|
}
|
|
29
36
|
}
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
What a component is invoked with. A top-level page/layout is called by the router
|
|
3
3
|
(client) and `renderChain` (SSR) with its route params as reactive thunks, shaped
|
|
4
4
|
identically to the thunk map `mountChild` passes a nested child — so `props()` reads
|
|
5
|
-
(`$props[name]?.()`) work uniformly on both.
|
|
6
|
-
component
|
|
7
|
-
the router/SSR set
|
|
5
|
+
(`$props[name]?.()`) work uniformly on both. `children` is an ordinary prop thunk like
|
|
6
|
+
any other: on a component it returns the parent's `Snippet`; on a layout with a child
|
|
7
|
+
layer below it, the router/SSR set it to `() => CHILD_PRESENT` (a presence sentinel, not
|
|
8
|
+
a slot builder) so `{#if children}` reads truthy.
|
|
8
9
|
*/
|
|
9
|
-
export type UiProps = Record<string, (
|
|
10
|
-
|
|
10
|
+
export type UiProps = Record<string, () => unknown> & {
|
|
11
|
+
children?: () => unknown
|
|
11
12
|
}
|