@abide/abide 0.38.0 → 0.39.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 +3 -3
- package/CHANGELOG.md +24 -0
- package/package.json +10 -1
- package/src/build.ts +14 -0
- package/src/lib/bundle/exitWithParent.ts +4 -2
- package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
- package/src/lib/server/runtime/createAppAssetServer.ts +37 -11
- package/src/lib/server/runtime/createPublicAssetServer.ts +14 -5
- package/src/lib/server/runtime/createUiPageRenderer.ts +53 -42
- package/src/lib/server/runtime/internalErrorResponse.ts +5 -2
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +4 -1
- package/src/lib/server/sockets/createSocketDispatcher.ts +7 -0
- package/src/lib/shared/createRemoteFunction.ts +20 -11
- package/src/lib/shared/escapeHtml.ts +15 -0
- package/src/lib/shared/markFrameworkSourcesIgnored.ts +47 -0
- package/src/lib/shared/streamResponse.ts +8 -1
- package/src/lib/shared/types/RemoteCallable.ts +12 -3
- package/src/lib/shared/types/RpcOptions.ts +22 -0
- package/src/lib/shared/types/SourceMap.ts +14 -0
- package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +4 -3
- package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +6 -0
- package/src/lib/ui/compile/asOutlet.ts +6 -5
- package/src/lib/ui/compile/compileModule.ts +18 -11
- package/src/lib/ui/compile/compileSSR.ts +32 -9
- package/src/lib/ui/compile/compileShadow.ts +11 -3
- package/src/lib/ui/compile/composeProps.ts +53 -0
- package/src/lib/ui/compile/desugarSignals.ts +45 -17
- package/src/lib/ui/compile/generateBuild.ts +100 -61
- package/src/lib/ui/compile/generateSSR.ts +226 -73
- package/src/lib/ui/compile/isAnchorPositioned.ts +19 -0
- package/src/lib/ui/compile/isControlFlow.ts +3 -1
- package/src/lib/ui/compile/isTextLeaf.ts +1 -1
- package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
- package/src/lib/ui/compile/parseTemplate.ts +44 -1
- package/src/lib/ui/compile/skeletonContext.ts +19 -20
- package/src/lib/ui/compile/skeletonable.ts +3 -2
- package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
- package/src/lib/ui/compile/staticAttr.ts +1 -1
- package/src/lib/ui/compile/staticTextPart.ts +1 -1
- package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
- package/src/lib/ui/compile/types/TemplateNode.ts +3 -1
- package/src/lib/ui/dom/commentData.ts +14 -0
- package/src/lib/ui/dom/disposeRange.ts +21 -0
- package/src/lib/ui/dom/fillBoundary.ts +38 -0
- package/src/lib/ui/dom/fillRange.ts +30 -0
- package/src/lib/ui/dom/hydrate.ts +11 -21
- package/src/lib/ui/dom/mergeProps.ts +32 -0
- package/src/lib/ui/dom/mount.ts +16 -25
- package/src/lib/ui/dom/mountChild.ts +27 -14
- package/src/lib/ui/dom/mountRange.ts +45 -0
- package/src/lib/ui/dom/outlet.ts +62 -0
- package/src/lib/ui/dom/readCall.ts +27 -0
- package/src/lib/ui/dom/restProps.ts +32 -0
- package/src/lib/ui/dom/scopeLabel.ts +8 -6
- package/src/lib/ui/dom/skeleton.ts +11 -30
- package/src/lib/ui/dom/spreadAttrs.ts +34 -0
- package/src/lib/ui/dom/spreadProps.ts +32 -0
- package/src/lib/ui/dom/withScope.ts +33 -0
- package/src/lib/ui/installHotBridge.ts +12 -0
- package/src/lib/ui/remoteProxy.ts +68 -36
- package/src/lib/ui/renderChain.ts +49 -39
- package/src/lib/ui/renderToStream.ts +69 -61
- package/src/lib/ui/resumeSeedScript.ts +17 -0
- package/src/lib/ui/router.ts +155 -85
- package/src/lib/ui/runtime/OUTLET_MARKER.ts +10 -0
- package/src/lib/ui/runtime/OUTLET_TAG.ts +5 -6
- package/src/lib/ui/runtime/PENDING_OUTLET.ts +8 -0
- package/src/lib/ui/runtime/captureModelDoc.ts +12 -13
- package/src/lib/ui/runtime/createEffectNode.ts +5 -0
- package/src/lib/ui/runtime/hotReplace.ts +14 -10
- package/src/lib/ui/runtime/localStoragePersistence.ts +8 -1
- package/src/lib/ui/runtime/toTeardown.ts +10 -5
- package/src/lib/ui/runtime/types/HotInstance.ts +11 -7
- package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
- package/src/lib/ui/runtime/types/Route.ts +6 -5
- package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
- package/src/lib/ui/runtime/types/UiComponent.ts +12 -1
- package/src/lib/ui/compile/componentWrapperTag.ts +0 -15
- package/src/lib/ui/compile/escapeHtml.ts +0 -15
- package/src/lib/ui/runtime/firstOutlet.ts +0 -22
- /package/src/lib/{server/runtime → shared}/safeJsonForScript.ts +0 -0
|
@@ -1,88 +1,96 @@
|
|
|
1
|
+
import { resumeSeedScript } from './resumeSeedScript.ts'
|
|
1
2
|
import type { ResumeEntry } from './runtime/RESUME.ts'
|
|
2
3
|
import type { SsrAwait, SsrRender } from './runtime/types/SsrRender.ts'
|
|
3
4
|
|
|
4
5
|
/*
|
|
5
|
-
Out-of-order SSR streaming. Yields the
|
|
6
|
-
immediately), then one resolved fragment per await block as its promise
|
|
7
|
-
in completion order, not source order, so a slow read never blocks a fast
|
|
8
|
-
Each resolved fragment is a `<abide-resolve data-id="ID"><script
|
|
9
|
-
|
|
10
|
-
`<!--abide:await:ID-->` boundary; the leading script holds the
|
|
11
|
-
registered for hydration so an `await` block adopts the
|
|
12
|
-
instead of re-running.
|
|
6
|
+
Out-of-order SSR streaming. Yields the shell first (so the browser paints
|
|
7
|
+
immediately), then one resolved fragment per STREAMING await block as its promise
|
|
8
|
+
settles — in completion order, not source order, so a slow read never blocks a fast
|
|
9
|
+
one. Each resolved fragment is a `<abide-resolve data-id="ID"><script
|
|
10
|
+
type="application/json">…</script>…</abide-resolve>` that `applyResolved` swaps into
|
|
11
|
+
the matching `<!--abide:await:ID-->` boundary; the leading script holds the
|
|
12
|
+
JSON-serialized value, registered for hydration so an `await` block adopts the
|
|
13
|
+
resolved branch on resume instead of re-running.
|
|
13
14
|
|
|
14
15
|
This is the await-block-streams half of the cache rule: a top-level `await` in the
|
|
15
|
-
script would have blocked the shell (inlined), but
|
|
16
|
-
shell now and streams the value when ready. Driven by
|
|
16
|
+
script would have blocked the shell (inlined), but a streaming await *block* flushes
|
|
17
|
+
its shell now and streams the value when ready. Driven by an async `render()` result,
|
|
17
18
|
so it composes with any transport (HTTP chunked, a socket frame, a test).
|
|
18
19
|
|
|
19
|
-
A `then` on the `await` tag makes the block BLOCKING: it
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
A `then` on the `await` tag makes the block BLOCKING: it is NOT streamed — it renders
|
|
21
|
+
inline during the async render pass (depth-first, matching the client) and its value
|
|
22
|
+
lands in `render().resume`. The shell already carries the resolved branch, so the
|
|
23
|
+
first yield just seeds those values into the manifest; only streaming blocks flush
|
|
24
|
+
out of order after it.
|
|
24
25
|
*/
|
|
25
26
|
// @documentation plumbing
|
|
26
|
-
export async function* renderToStream(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
27
|
+
export async function* renderToStream(
|
|
28
|
+
render: () => SsrRender | Promise<SsrRender>,
|
|
29
|
+
): AsyncGenerator<string> {
|
|
30
|
+
const { html, awaits, resume } = await render()
|
|
31
|
+
/* The shell already contains every blocking await's resolved branch (rendered
|
|
32
|
+
inline); seed their values so hydration adopts them without a refetch. */
|
|
33
|
+
yield html + resumeSeedScript(resume)
|
|
34
|
+
/* A BLOCKING await nested inside a streaming branch renders inline during `settle`
|
|
35
|
+
(after the seed above), writing its value onto this same `$resume` object — so the
|
|
36
|
+
initial seed misses it. Track which resume ids are already seeded and emit the delta
|
|
37
|
+
alongside each streamed fragment, so the client adopts the nested blocking branch
|
|
38
|
+
instead of refetching. (`resume` is the render body's live object, so late writes
|
|
39
|
+
appear here.) */
|
|
40
|
+
const seededResume = new Set<number>(Object.keys(resume).map(Number))
|
|
41
|
+
const resumeDelta = (): Record<number, ResumeEntry> => {
|
|
42
|
+
const delta: Record<number, ResumeEntry> = {}
|
|
43
|
+
for (const [key, entry] of Object.entries(resume)) {
|
|
44
|
+
const id = Number(key)
|
|
45
|
+
if (!seededResume.has(id)) {
|
|
46
|
+
seededResume.add(id)
|
|
47
|
+
delta[id] = entry
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return delta
|
|
37
51
|
}
|
|
38
|
-
|
|
39
|
-
|
|
52
|
+
/* Streaming awaits flush their resolved fragment out of order as each settles. A
|
|
53
|
+
streaming block's async resolved/error renderer may itself register NESTED streaming
|
|
54
|
+
awaits — its `branchContent` runs `$awaits.push(...)` onto this same `awaits` array
|
|
55
|
+
during `settle`, AFTER the initial scan. So re-scan for newly-appended blocks after
|
|
56
|
+
every settle (tracking which ids are already enqueued), composing to any depth. */
|
|
40
57
|
const inflight = new Map<number, Promise<Settled>>()
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
58
|
+
const enqueued = new Set<number>()
|
|
59
|
+
const enqueueNew = (): void => {
|
|
60
|
+
for (const block of awaits) {
|
|
61
|
+
if (!enqueued.has(block.id)) {
|
|
62
|
+
enqueued.add(block.id)
|
|
63
|
+
inflight.set(block.id, settle(block))
|
|
64
|
+
}
|
|
44
65
|
}
|
|
45
66
|
}
|
|
67
|
+
enqueueNew()
|
|
46
68
|
while (inflight.size > 0) {
|
|
47
69
|
const resolved = await Promise.race(inflight.values())
|
|
48
70
|
inflight.delete(resolved.id)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
71
|
+
enqueueNew()
|
|
72
|
+
const encoded = encodeResume(resolved.resume)
|
|
73
|
+
yield resumeSeedScript(resumeDelta()) +
|
|
74
|
+
`<abide-resolve data-id="${resolved.id}">` +
|
|
75
|
+
`<script type="application/json">${encoded}</script>` +
|
|
52
76
|
`${resolved.html}</abide-resolve>`
|
|
53
77
|
}
|
|
54
78
|
}
|
|
55
79
|
|
|
56
|
-
/* Inserts a blocking await's resolved markup into its (empty) boundary in the shell,
|
|
57
|
-
between the open and close markers. */
|
|
58
|
-
function spliceResolved(shell: string, id: number, resolved: string): string {
|
|
59
|
-
const open = `<!--abide:await:${id}-->`
|
|
60
|
-
const close = `<!--/abide:await:${id}-->`
|
|
61
|
-
/* A function replacement, so a `$&`/`$\`` etc. in the rendered value is inserted
|
|
62
|
-
literally rather than interpreted as a special replacement pattern. */
|
|
63
|
-
return shell.replace(`${open}${close}`, () => `${open}${resolved}${close}`)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/* A self-contained script seeding the resume manifest with the blocking values, so
|
|
67
|
-
client hydration adopts each resolved branch instead of re-running the promise.
|
|
68
|
-
Empty when no blocking awaits settled. */
|
|
69
|
-
function resumeScript(resumed: Record<number, ResumeEntry>): string {
|
|
70
|
-
if (Object.keys(resumed).length === 0) {
|
|
71
|
-
return ''
|
|
72
|
-
}
|
|
73
|
-
const payload = JSON.stringify(resumed).replace(/</g, '\\u003c')
|
|
74
|
-
return `<script>Object.assign(window.__abideResume=window.__abideResume||{},${payload})</script>`
|
|
75
|
-
}
|
|
76
|
-
|
|
77
80
|
type Settled = { id: number; html: string; resume: ResumeEntry }
|
|
78
81
|
|
|
79
|
-
/* Awaits one block's promise and renders the resolved or error branch to
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
/* Awaits one streaming block's promise and renders the resolved or error branch to
|
|
83
|
+
HTML (the renderers are async so a nested `await` block composes), capturing the
|
|
84
|
+
value (serializable) for the resume manifest. Errors serialize as their message —
|
|
85
|
+
enough for the catch branch, without leaking a stack. */
|
|
82
86
|
function settle(block: SsrAwait): Promise<Settled> {
|
|
83
87
|
return Promise.resolve(block.promise()).then(
|
|
84
|
-
(value) => ({
|
|
85
|
-
|
|
88
|
+
async (value) => ({
|
|
89
|
+
id: block.id,
|
|
90
|
+
html: await block.then(value),
|
|
91
|
+
resume: { ok: true, value },
|
|
92
|
+
}),
|
|
93
|
+
async (error) => {
|
|
86
94
|
/* No catch branch → surface the rejection (500 before the first flush,
|
|
87
95
|
mid-stream error after) instead of swallowing it into an empty fragment. */
|
|
88
96
|
if (block.catch === undefined) {
|
|
@@ -90,7 +98,7 @@ function settle(block: SsrAwait): Promise<Settled> {
|
|
|
90
98
|
}
|
|
91
99
|
return {
|
|
92
100
|
id: block.id,
|
|
93
|
-
html: block.catch(error),
|
|
101
|
+
html: await block.catch(error),
|
|
94
102
|
resume: { ok: false, error: String(error) },
|
|
95
103
|
}
|
|
96
104
|
},
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { safeJsonForScript } from '../shared/safeJsonForScript.ts'
|
|
2
|
+
import type { ResumeEntry } from './runtime/RESUME.ts'
|
|
3
|
+
|
|
4
|
+
/* A self-contained `<script>` seeding the await-resume manifest with the blocking
|
|
5
|
+
values rendered inline on the server, so client hydration adopts each resolved
|
|
6
|
+
branch instead of re-running its promise. Empty when nothing blocking resolved.
|
|
7
|
+
The payload runs as JS (`Object.assign`), so it's encoded via `safeJsonForScript`
|
|
8
|
+
— escaping `<`, `-->`, and U+2028/U+2029 so a serialized body value can't close
|
|
9
|
+
the script early or parse as a line terminator. Shared by the buffered
|
|
10
|
+
(`createUiPageRenderer`) and streaming (`renderToStream`) paths. */
|
|
11
|
+
// @documentation plumbing
|
|
12
|
+
export function resumeSeedScript(resume: Record<number, ResumeEntry>): string {
|
|
13
|
+
if (Object.keys(resume).length === 0) {
|
|
14
|
+
return ''
|
|
15
|
+
}
|
|
16
|
+
return `<script>Object.assign(window.__abideResume=window.__abideResume||{},${safeJsonForScript(resume)})</script>`
|
|
17
|
+
}
|
package/src/lib/ui/router.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { layoutChainForRoute } from '../shared/layoutChainForRoute.ts'
|
|
2
|
+
import { fillBoundary } from './dom/fillBoundary.ts'
|
|
3
|
+
import { outlet } from './dom/outlet.ts'
|
|
2
4
|
import { effect } from './effect.ts'
|
|
3
5
|
import { matchRoute } from './matchRoute.ts'
|
|
4
6
|
import { navigate } from './navigate.ts'
|
|
5
7
|
import { clientPage } from './runtime/clientPage.ts'
|
|
6
8
|
import { enterRenderPass } from './runtime/enterRenderPass.ts'
|
|
7
9
|
import { exitRenderPass } from './runtime/exitRenderPass.ts'
|
|
8
|
-
import { firstOutlet } from './runtime/firstOutlet.ts'
|
|
9
10
|
import { historyEntries } from './runtime/historyEntries.ts'
|
|
11
|
+
import { PENDING_OUTLET } from './runtime/PENDING_OUTLET.ts'
|
|
12
|
+
import { RENDER } from './runtime/RENDER.ts'
|
|
10
13
|
import { runtimePath } from './runtime/runtimePath.ts'
|
|
11
14
|
import type { AbideHistoryState } from './runtime/types/AbideHistoryState.ts'
|
|
12
15
|
import type { NavVerdict } from './runtime/types/NavVerdict.ts'
|
|
@@ -14,13 +17,15 @@ import type { Route } from './runtime/types/Route.ts'
|
|
|
14
17
|
import type { RouteLoader } from './runtime/types/RouteLoader.ts'
|
|
15
18
|
import { untrack } from './runtime/untrack.ts'
|
|
16
19
|
|
|
17
|
-
/*
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
type
|
|
20
|
+
/* An outlet boundary — the `<!--abide:outlet-->`…`<!--/abide:outlet-->` marker pair a
|
|
21
|
+
layer's content lives between (a layout's `<slot/>`, or the router's root boundary in
|
|
22
|
+
the mount host). The next chain layer fills it; no `<abide-outlet>` element. */
|
|
23
|
+
type Boundary = { open: Comment; close: Comment }
|
|
21
24
|
|
|
22
|
-
/* A layout
|
|
23
|
-
|
|
25
|
+
/* A layout mounted in the active chain: its route key (the directory URL — its identity
|
|
26
|
+
for the diff), the disposer that stops its reactivity and clears its content, and the
|
|
27
|
+
boundary of its own `<slot/>` — where the next layer mounts. */
|
|
28
|
+
type MountedLayout = { key: string; dispose: () => void; slot: Boundary }
|
|
24
29
|
|
|
25
30
|
/* The destination URL for a navigation `path`. On the server / headless there is no
|
|
26
31
|
`location`, so resolve against a localhost origin; in the browser, against the real
|
|
@@ -68,6 +73,10 @@ export function router(
|
|
|
68
73
|
/* The mounted layout chain (outermost first) + the page disposer. */
|
|
69
74
|
const mountedLayouts: MountedLayout[] = []
|
|
70
75
|
let disposePage: (() => void) | undefined
|
|
76
|
+
/* The root outlet boundary in `host` (`#app`) the outermost layer fills — established
|
|
77
|
+
once on the first mount (claimed from the SSR DOM when hydrating, created otherwise)
|
|
78
|
+
and reused across navigations, since `#app` itself never re-mounts. */
|
|
79
|
+
let rootBoundary: Boundary | undefined
|
|
71
80
|
const patterns = Object.keys(loaders).filter((key) => key !== '*')
|
|
72
81
|
const layoutKeys = Object.keys(layoutLoaders)
|
|
73
82
|
|
|
@@ -90,60 +99,94 @@ export function router(
|
|
|
90
99
|
const resolvePage = resolver(loaders)
|
|
91
100
|
const resolveLayout = resolver(layoutLoaders)
|
|
92
101
|
|
|
93
|
-
/* Tear down the page and every layout from `index` inward (innermost first)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
102
|
+
/* Tear down the page and every layout from `index` inward (innermost first). Each
|
|
103
|
+
layer's disposer stops its reactivity and clears its content from its boundary
|
|
104
|
+
(the outermost cleared range removes the inner DOM too — harmless double-clears).
|
|
105
|
+
Leaves the boundary markers in place so the rebuild fills the same boundary. */
|
|
106
|
+
const disposeFrom = (index: number): void => {
|
|
97
107
|
disposePage?.()
|
|
98
108
|
disposePage = undefined
|
|
99
109
|
for (let depth = mountedLayouts.length - 1; depth >= index; depth -= 1) {
|
|
100
110
|
mountedLayouts[depth]?.dispose()
|
|
101
111
|
}
|
|
102
|
-
const base = index === 0 ? host : (mountedLayouts[index - 1] as MountedLayout).outlet
|
|
103
112
|
mountedLayouts.length = index
|
|
104
|
-
return base
|
|
105
113
|
}
|
|
106
114
|
|
|
107
|
-
/*
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
115
|
+
/* The outlet boundary the layer at `index` fills: the root boundary for the outermost
|
|
116
|
+
layer, else the surviving parent layout's `<slot/>`. */
|
|
117
|
+
const baseBoundary = (index: number): Boundary =>
|
|
118
|
+
index === 0 ? (rootBoundary as Boundary) : (mountedLayouts[index - 1] as MountedLayout).slot
|
|
119
|
+
|
|
120
|
+
/* Build (or hydrate) the chain tail — layouts `[index..]` then the page — filling each
|
|
121
|
+
layer into the previous one's `<slot/>` boundary (the root boundary for the
|
|
122
|
+
outermost). `outlet()` records each layer's own slot in `PENDING_OUTLET` as the
|
|
123
|
+
layer builds, so the next layer knows where to mount — no DOM scan. Hydration
|
|
124
|
+
brackets ONE render pass + claim cursor across all layers so await/try block ids
|
|
125
|
+
stay unique and aligned with the SSR stream; a fresh mount needs no shared pass. */
|
|
111
126
|
const buildFrom = (
|
|
112
|
-
base: Element,
|
|
113
127
|
index: number,
|
|
114
128
|
chainKeys: string[],
|
|
115
129
|
layoutViews: Route[],
|
|
116
130
|
pageView: Route | undefined,
|
|
131
|
+
pageKey: string,
|
|
117
132
|
params: Record<string, string>,
|
|
118
133
|
hydrating: boolean,
|
|
119
134
|
): void => {
|
|
120
135
|
const run = (): void => {
|
|
121
|
-
|
|
136
|
+
/* Establish the root boundary on the first mount — `outlet(host)` claims the
|
|
137
|
+
SSR root boundary (hydrating) or creates it (fresh), recording it in
|
|
138
|
+
`PENDING_OUTLET`. Reused across navigations thereafter. A fresh first mount
|
|
139
|
+
(no claim cursor — e.g. a non-hydratable page whose SSR shell can't be
|
|
140
|
+
adopted) discards whatever the server put in `#app` first, so the created
|
|
141
|
+
boundary is the only content. */
|
|
142
|
+
if (rootBoundary === undefined) {
|
|
143
|
+
if (RENDER.hydration === undefined) {
|
|
144
|
+
host.textContent = ''
|
|
145
|
+
}
|
|
146
|
+
outlet(host)
|
|
147
|
+
rootBoundary = PENDING_OUTLET.current as Boundary
|
|
148
|
+
}
|
|
149
|
+
let boundary = baseBoundary(index)
|
|
122
150
|
for (let depth = index; depth < layoutViews.length; depth += 1) {
|
|
123
151
|
const view = layoutViews[depth] as Route
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
152
|
+
PENDING_OUTLET.current = undefined
|
|
153
|
+
const { dispose } = fillBoundary(
|
|
154
|
+
boundary.open,
|
|
155
|
+
boundary.close,
|
|
156
|
+
view.build,
|
|
157
|
+
params,
|
|
158
|
+
/* The layout's route key names its scope in the inspector's Reactive tab
|
|
159
|
+
(no host element to read a tag from — see `scopeLabel`). */
|
|
160
|
+
chainKeys[depth],
|
|
161
|
+
)
|
|
162
|
+
const slot = PENDING_OUTLET.current
|
|
163
|
+
if (slot === undefined) {
|
|
129
164
|
throw new Error('[abide] a layout.abide must contain a <slot/> outlet')
|
|
130
165
|
}
|
|
131
|
-
mountedLayouts.push({ key: chainKeys[depth] as string, dispose,
|
|
132
|
-
|
|
166
|
+
mountedLayouts.push({ key: chainKeys[depth] as string, dispose, slot })
|
|
167
|
+
boundary = slot
|
|
133
168
|
}
|
|
134
169
|
if (pageView === undefined) {
|
|
135
170
|
return
|
|
136
171
|
}
|
|
137
|
-
disposePage =
|
|
138
|
-
|
|
139
|
-
|
|
172
|
+
disposePage = fillBoundary(
|
|
173
|
+
boundary.open,
|
|
174
|
+
boundary.close,
|
|
175
|
+
pageView.build,
|
|
176
|
+
params,
|
|
177
|
+
/* The page's route key names its scope in the inspector (see above). */
|
|
178
|
+
pageKey,
|
|
179
|
+
).dispose
|
|
140
180
|
}
|
|
141
181
|
if (hydrating) {
|
|
182
|
+
const previous = RENDER.hydration
|
|
183
|
+
RENDER.hydration = { next: new Map() }
|
|
142
184
|
enterRenderPass()
|
|
143
185
|
try {
|
|
144
186
|
run()
|
|
145
187
|
} finally {
|
|
146
188
|
exitRenderPass()
|
|
189
|
+
RENDER.hydration = previous
|
|
147
190
|
}
|
|
148
191
|
return
|
|
149
192
|
}
|
|
@@ -181,8 +224,10 @@ export function router(
|
|
|
181
224
|
return
|
|
182
225
|
}
|
|
183
226
|
const target = event.target as Element
|
|
227
|
+
/* `closest?.` is undefined when the target is a non-Element (text node, document)
|
|
228
|
+
that has no `closest`; `== null` catches both that and a genuine no-match null. */
|
|
184
229
|
const link = target.closest?.('a[href]') as HTMLAnchorElement | null
|
|
185
|
-
if (link
|
|
230
|
+
if (link == null) {
|
|
186
231
|
return
|
|
187
232
|
}
|
|
188
233
|
/* Defer to the browser for links it should own: a new-tab target, a
|
|
@@ -298,79 +343,104 @@ export function router(
|
|
|
298
343
|
resolvePage(key),
|
|
299
344
|
Promise.all(chainKeys.map((layoutKey) => resolveLayout(layoutKey))),
|
|
300
345
|
verdict,
|
|
301
|
-
])
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
346
|
+
])
|
|
347
|
+
.then(([pageView, resolvedLayouts, decision]) => {
|
|
348
|
+
if (token !== sequence || disposed) {
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
/* handle() redirected: go where it pointed, replacing the blocked
|
|
306
352
|
URL so back doesn't trap on it. The router re-probes the target. */
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
353
|
+
if (decision.kind === 'redirect') {
|
|
354
|
+
navigate(decision.path, true)
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
/* handle() blocked it / redirected off-origin / the probe failed:
|
|
312
358
|
let the browser load the server's real response. */
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
359
|
+
if (decision.kind === 'reload') {
|
|
360
|
+
if (typeof location !== 'undefined') {
|
|
361
|
+
location.href = decision.url
|
|
362
|
+
}
|
|
363
|
+
return
|
|
316
364
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
(
|
|
321
|
-
)
|
|
322
|
-
/* The shared prefix of layouts (same route key at the same depth) stays
|
|
365
|
+
const layoutViews = resolvedLayouts.filter(
|
|
366
|
+
(view): view is Route => view !== undefined,
|
|
367
|
+
)
|
|
368
|
+
/* The shared prefix of layouts (same route key at the same depth) stays
|
|
323
369
|
mounted; the first divergence and everything inward is rebuilt. */
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
first
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
370
|
+
let divergence = 0
|
|
371
|
+
while (
|
|
372
|
+
divergence < mountedLayouts.length &&
|
|
373
|
+
divergence < chainKeys.length &&
|
|
374
|
+
mountedLayouts[divergence]?.key === chainKeys[divergence]
|
|
375
|
+
) {
|
|
376
|
+
divergence += 1
|
|
377
|
+
}
|
|
378
|
+
const hydrating = first && pageView?.hydratable === true
|
|
379
|
+
first = false
|
|
380
|
+
/* The DOM mutation a navigation makes: tear the divergent chain down
|
|
381
|
+
(clearing its content from its boundary) and rebuild into the same
|
|
382
|
+
boundary (hydration adopts in place). */
|
|
383
|
+
const swap = (): void => {
|
|
384
|
+
/* `startViewTransition` runs this callback in a later frame, so a newer
|
|
385
|
+
navigation may have superseded this one since the token guard above —
|
|
386
|
+
re-check before mutating, or a stale swap clobbers the newer page. */
|
|
387
|
+
if (token !== sequence || disposed) {
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
/* Tear the outgoing page + divergent layouts down BEFORE publishing the
|
|
339
391
|
new snapshot. Publishing first would re-run the doomed leaf page's
|
|
340
392
|
computeds against the new route's params (a missing `[id]` reads back
|
|
341
393
|
`undefined`, e.g. `Number(page.params.id)` → NaN → a bogus request)
|
|
342
394
|
while it's still mounted. Disposing first kills that scope; surviving
|
|
343
395
|
prefix layouts then update in place on publish. */
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
396
|
+
disposeFrom(divergence)
|
|
397
|
+
const url = resolveUrl(path)
|
|
398
|
+
clientPage.value = { route: chainRoute, params, url, navigating: false }
|
|
399
|
+
buildFrom(
|
|
400
|
+
divergence,
|
|
401
|
+
chainKeys,
|
|
402
|
+
layoutViews,
|
|
403
|
+
pageView,
|
|
404
|
+
key,
|
|
405
|
+
params,
|
|
406
|
+
hydrating,
|
|
407
|
+
)
|
|
408
|
+
/* Reapply the destination entry's scroll once its DOM exists — a
|
|
352
409
|
back/forward restores its offset, a fresh nav scrolls to the `#hash`
|
|
353
410
|
anchor (now built) or the top. Runs on the initial paint too: with
|
|
354
411
|
`scrollRestoration='manual'` the browser does NOT restore a reload's
|
|
355
412
|
offset, so first paint recovers it from the persisted `history.state`
|
|
356
413
|
(a fresh load with no persisted offset falls through to hash/top). */
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
414
|
+
historyEntries.restore(url.hash)
|
|
415
|
+
}
|
|
416
|
+
/* Wrap the swap in a View Transition where the browser supports it, so
|
|
360
417
|
the page change cross-fades (and shared `view-transition-name` elements
|
|
361
418
|
morph) — the synchronous swap is exactly the mutation the API snapshots
|
|
362
419
|
around. Skipped while hydrating: the first paint adopts SSR DOM in place,
|
|
363
420
|
not animate. CSS owns opting out (e.g. prefers-reduced-motion). */
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
421
|
+
if (
|
|
422
|
+
!hydrating &&
|
|
423
|
+
typeof document !== 'undefined' &&
|
|
424
|
+
'startViewTransition' in document
|
|
425
|
+
) {
|
|
426
|
+
document.startViewTransition(swap)
|
|
427
|
+
} else {
|
|
428
|
+
swap()
|
|
429
|
+
}
|
|
430
|
+
})
|
|
431
|
+
.catch(() => {
|
|
432
|
+
/* A page/layout chunk import (or the probe) rejected — offline, a hashed
|
|
433
|
+
chunk filename rotated by a deploy, or a transient asset 5xx. Without
|
|
434
|
+
this the navigating:true latched above never clears (a bound spinner
|
|
435
|
+
spins forever) and the rejection surfaces as an unhandledrejection.
|
|
436
|
+
Fall back to a full browser load so the server serves the target. */
|
|
437
|
+
if (token !== sequence || disposed) {
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
if (typeof location !== 'undefined') {
|
|
441
|
+
location.href = resolveUrl(path).href
|
|
442
|
+
}
|
|
443
|
+
})
|
|
374
444
|
})
|
|
375
445
|
})
|
|
376
446
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/* The comment-marker boundary a layout's `<slot/>` outlet lowers to — replacing the old
|
|
2
|
+
`<abide-outlet>` ELEMENT, so the next chain layer the router fills in lays out as a true
|
|
3
|
+
direct child of the slot's parent (no wrapper box breaking the layout's flex/grid/`:first-child`).
|
|
4
|
+
|
|
5
|
+
`abide:outlet` / `/abide:outlet` deliberately match `skeleton`'s `isOpenMarker`/`isCloseMarker`
|
|
6
|
+
(the `abide:` / `/abide:` convention), so a layout's own hole-scanning treats the outlet's
|
|
7
|
+
future child content as a balanced range and skips it — exactly like an `await`/`try` boundary.
|
|
8
|
+
The router fills the boundary with the next layer (see `outlet`/`fillBoundary`). */
|
|
9
|
+
export const OUTLET_OPEN = 'abide:outlet'
|
|
10
|
+
export const OUTLET_CLOSE = '/abide:outlet'
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
/* The
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
they all agree on the marker. */
|
|
1
|
+
/* The compile-time AST tag a layout's `<slot/>` is rewritten to (`asOutlet`). It is a
|
|
2
|
+
sentinel only — never a rendered element: both compiler back-ends lower it to an empty
|
|
3
|
+
`<!--abide:outlet-->`…`<!--/abide:outlet-->` comment boundary (`outlet` on the client,
|
|
4
|
+
the marker string in SSR) the router fills with the next chain layer. Shared by both
|
|
5
|
+
back-ends and `skeletonContext` so they all agree which node is the outlet. */
|
|
7
6
|
// @documentation plumbing
|
|
8
7
|
export const OUTLET_TAG = 'abide-outlet'
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/* The outlet boundary the most recent `outlet()` call established (a layout's `<slot/>`
|
|
2
|
+
fill point, or the router's root boundary in `#app`). The router reads it right after
|
|
3
|
+
building/claiming a layer — synchronously, so it is exactly that layer's single slot —
|
|
4
|
+
to learn where the NEXT chain layer mounts, without scanning the DOM. A layer with no
|
|
5
|
+
slot (the leaf page) leaves it whatever the router reset it to. */
|
|
6
|
+
export const PENDING_OUTLET: { current: { open: Comment; close: Comment } | undefined } = {
|
|
7
|
+
current: undefined,
|
|
8
|
+
}
|
|
@@ -2,28 +2,27 @@ import { PATCH_BUS } from './PATCH_BUS.ts'
|
|
|
2
2
|
import type { Doc } from './types/Doc.ts'
|
|
3
3
|
|
|
4
4
|
/*
|
|
5
|
-
Runs a component `build` and returns its
|
|
6
|
-
`model` document — the serializable `state` doc,
|
|
7
|
-
its value across (see `hotReplace`). The model is
|
|
8
|
-
seeds its `model` first (the desugared
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
preserve. Used only on the hot path; the
|
|
5
|
+
Runs a component `build` and returns its result (the mount handle / disposer)
|
|
6
|
+
alongside the component's own `model` document — the serializable `state` doc,
|
|
7
|
+
needed so a hot swap can carry its value across (see `hotReplace`). The model is
|
|
8
|
+
found, not threaded: a component seeds its `model` first (the desugared
|
|
9
|
+
`const model = doc({})` + its init patches run before any child mounts or
|
|
10
|
+
control-flow blocks), so the FIRST patch announced on the bus during the build names
|
|
11
|
+
it. A component with no `state` mints no model and emits nothing first — `model` is
|
|
12
|
+
then `undefined` and there is nothing to preserve. Used only on the hot path; the
|
|
13
|
+
subscription is torn down with the build.
|
|
13
14
|
*/
|
|
14
|
-
export function captureModelDoc(build: () =>
|
|
15
|
-
|
|
15
|
+
export function captureModelDoc<T>(build: () => T): {
|
|
16
|
+
value: T
|
|
16
17
|
model: Doc | undefined
|
|
17
18
|
} {
|
|
18
19
|
let model: Doc | undefined
|
|
19
20
|
const unsubscribe = PATCH_BUS.subscribe((event) => {
|
|
20
21
|
model ??= event.doc
|
|
21
22
|
})
|
|
22
|
-
let dispose: () => void = () => undefined
|
|
23
23
|
try {
|
|
24
|
-
|
|
24
|
+
return { value: build(), model }
|
|
25
25
|
} finally {
|
|
26
26
|
unsubscribe()
|
|
27
27
|
}
|
|
28
|
-
return { dispose, model }
|
|
29
28
|
}
|
|
@@ -49,6 +49,11 @@ export function createEffectNode(fn: () => EffectResult): () => void {
|
|
|
49
49
|
const dispose = () => {
|
|
50
50
|
runCleanup()
|
|
51
51
|
unlinkDeps(node)
|
|
52
|
+
/* Clearing compute makes runNode a no-op: an effect disposed mid-flush (by an
|
|
53
|
+
earlier effect in the same batch) is still in flushEffects' snapshot array
|
|
54
|
+
after pendingEffects.delete, so it would otherwise re-run its body and
|
|
55
|
+
re-link into the graph — a disposed effect resurrected. */
|
|
56
|
+
node.compute = undefined
|
|
52
57
|
REACTIVE_CONTEXT.pendingEffects.delete(node)
|
|
53
58
|
}
|
|
54
59
|
if (OWNER.current !== undefined) {
|