@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.
Files changed (82) hide show
  1. package/AGENTS.md +3 -3
  2. package/CHANGELOG.md +24 -0
  3. package/package.json +10 -1
  4. package/src/build.ts +14 -0
  5. package/src/lib/bundle/exitWithParent.ts +4 -2
  6. package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
  7. package/src/lib/server/runtime/createAppAssetServer.ts +37 -11
  8. package/src/lib/server/runtime/createPublicAssetServer.ts +14 -5
  9. package/src/lib/server/runtime/createUiPageRenderer.ts +53 -42
  10. package/src/lib/server/runtime/internalErrorResponse.ts +5 -2
  11. package/src/lib/server/runtime/snapshotEntryFromCache.ts +4 -1
  12. package/src/lib/server/sockets/createSocketDispatcher.ts +7 -0
  13. package/src/lib/shared/createRemoteFunction.ts +20 -11
  14. package/src/lib/shared/escapeHtml.ts +15 -0
  15. package/src/lib/shared/markFrameworkSourcesIgnored.ts +47 -0
  16. package/src/lib/shared/streamResponse.ts +8 -1
  17. package/src/lib/shared/types/RemoteCallable.ts +12 -3
  18. package/src/lib/shared/types/RpcOptions.ts +22 -0
  19. package/src/lib/shared/types/SourceMap.ts +14 -0
  20. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +4 -3
  21. package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
  22. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +6 -0
  23. package/src/lib/ui/compile/asOutlet.ts +6 -5
  24. package/src/lib/ui/compile/compileModule.ts +18 -11
  25. package/src/lib/ui/compile/compileSSR.ts +32 -9
  26. package/src/lib/ui/compile/compileShadow.ts +11 -3
  27. package/src/lib/ui/compile/composeProps.ts +53 -0
  28. package/src/lib/ui/compile/desugarSignals.ts +45 -17
  29. package/src/lib/ui/compile/generateBuild.ts +100 -61
  30. package/src/lib/ui/compile/generateSSR.ts +226 -73
  31. package/src/lib/ui/compile/isAnchorPositioned.ts +19 -0
  32. package/src/lib/ui/compile/isControlFlow.ts +3 -1
  33. package/src/lib/ui/compile/isTextLeaf.ts +1 -1
  34. package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
  35. package/src/lib/ui/compile/parseTemplate.ts +44 -1
  36. package/src/lib/ui/compile/skeletonContext.ts +19 -20
  37. package/src/lib/ui/compile/skeletonable.ts +3 -2
  38. package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
  39. package/src/lib/ui/compile/staticAttr.ts +1 -1
  40. package/src/lib/ui/compile/staticTextPart.ts +1 -1
  41. package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
  42. package/src/lib/ui/compile/types/TemplateNode.ts +3 -1
  43. package/src/lib/ui/dom/commentData.ts +14 -0
  44. package/src/lib/ui/dom/disposeRange.ts +21 -0
  45. package/src/lib/ui/dom/fillBoundary.ts +38 -0
  46. package/src/lib/ui/dom/fillRange.ts +30 -0
  47. package/src/lib/ui/dom/hydrate.ts +11 -21
  48. package/src/lib/ui/dom/mergeProps.ts +32 -0
  49. package/src/lib/ui/dom/mount.ts +16 -25
  50. package/src/lib/ui/dom/mountChild.ts +27 -14
  51. package/src/lib/ui/dom/mountRange.ts +45 -0
  52. package/src/lib/ui/dom/outlet.ts +62 -0
  53. package/src/lib/ui/dom/readCall.ts +27 -0
  54. package/src/lib/ui/dom/restProps.ts +32 -0
  55. package/src/lib/ui/dom/scopeLabel.ts +8 -6
  56. package/src/lib/ui/dom/skeleton.ts +11 -30
  57. package/src/lib/ui/dom/spreadAttrs.ts +34 -0
  58. package/src/lib/ui/dom/spreadProps.ts +32 -0
  59. package/src/lib/ui/dom/withScope.ts +33 -0
  60. package/src/lib/ui/installHotBridge.ts +12 -0
  61. package/src/lib/ui/remoteProxy.ts +68 -36
  62. package/src/lib/ui/renderChain.ts +49 -39
  63. package/src/lib/ui/renderToStream.ts +69 -61
  64. package/src/lib/ui/resumeSeedScript.ts +17 -0
  65. package/src/lib/ui/router.ts +155 -85
  66. package/src/lib/ui/runtime/OUTLET_MARKER.ts +10 -0
  67. package/src/lib/ui/runtime/OUTLET_TAG.ts +5 -6
  68. package/src/lib/ui/runtime/PENDING_OUTLET.ts +8 -0
  69. package/src/lib/ui/runtime/captureModelDoc.ts +12 -13
  70. package/src/lib/ui/runtime/createEffectNode.ts +5 -0
  71. package/src/lib/ui/runtime/hotReplace.ts +14 -10
  72. package/src/lib/ui/runtime/localStoragePersistence.ts +8 -1
  73. package/src/lib/ui/runtime/toTeardown.ts +10 -5
  74. package/src/lib/ui/runtime/types/HotInstance.ts +11 -7
  75. package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
  76. package/src/lib/ui/runtime/types/Route.ts +6 -5
  77. package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
  78. package/src/lib/ui/runtime/types/UiComponent.ts +12 -1
  79. package/src/lib/ui/compile/componentWrapperTag.ts +0 -15
  80. package/src/lib/ui/compile/escapeHtml.ts +0 -15
  81. package/src/lib/ui/runtime/firstOutlet.ts +0 -22
  82. /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 pending shell first (so the browser paints
6
- immediately), then one resolved fragment per await block as its promise settles —
7
- in completion order, not source order, so a slow read never blocks a fast one.
8
- Each resolved fragment is a `<abide-resolve data-id="ID"><script type="application/json">
9
- …</script>…</abide-resolve>` that `applyResolved` swaps into the matching
10
- `<!--abide:await:ID-->` boundary; the leading script holds the JSON-serialized value,
11
- registered for hydration so an `await` block adopts the resolved branch on resume
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 an await *block* flushes its
16
- shell now and streams the value when ready. Driven by a plain `render()` result,
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 settles before the first
20
- flush, its resolved branch spliced into its (empty) boundary and its value seeded
21
- into the resume manifest inline so the value is in the first paint, no pending
22
- shell, no swap. The first yield therefore awaits all blocking blocks; only the
23
- remaining streaming blocks flush out of order after it.
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(render: () => SsrRender): AsyncGenerator<string> {
27
- const { html, awaits } = render()
28
- /* Blocking awaits (a `then` on the `await` tag) settle BEFORE the first flush:
29
- their resolved branch is spliced into its empty boundary and its value registered
30
- inline, so the first paint already has the value no pending, no swap. */
31
- const blocking = awaits.filter((block) => block.blocking === true)
32
- let shell = html
33
- const resumed: Record<number, ResumeEntry> = {}
34
- for (const settled of await Promise.all(blocking.map(settle))) {
35
- shell = spliceResolved(shell, settled.id, settled.html)
36
- resumed[settled.id] = settled.resume
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
- yield shell + resumeScript(resumed)
39
- /* Streaming awaits flush their resolved fragment out of order as each settles. */
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
- for (const block of awaits) {
42
- if (block.blocking !== true) {
43
- inflight.set(block.id, settle(block))
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
- const resume = encodeResume(resolved.resume)
50
- yield `<abide-resolve data-id="${resolved.id}">` +
51
- `<script type="application/json">${resume}</script>` +
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 HTML,
80
- capturing the value (serializable) for the resume manifest. Errors serialize as
81
- their message — enough for the catch branch, without leaking a stack. */
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) => ({ id: block.id, html: block.then(value), resume: { ok: true, value } }),
85
- (error) => {
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
+ }
@@ -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
- /* A layout mounted in the active chain: its route key (the directory URL — its
18
- identity for the diff), the disposer that stops its reactivity, and the outlet
19
- element the next layer mounts into. */
20
- type MountedLayout = { key: string; dispose: () => void; outlet: Element }
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 mount that returns no disposer still needs one for the chain teardown. */
23
- const noop = (): void => {}
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), then
94
- return the container the rebuild mounts into the surviving layout's outlet, or
95
- `host` when the whole chain goes. */
96
- const disposeFrom = (index: number): Element => {
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
- /* Mount (or hydrate) the chain tail — layouts `[index..]` then the page into
108
- `base`, threading each layer into the previous one's outlet. Hydration brackets
109
- ONE render pass across all layers so await/try block ids stay unique and aligned
110
- with the SSR stream; a fresh mount needs no shared pass. */
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
- let container = base
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
- const dispose = hydrating
125
- ? (view.hydrate as NonNullable<Route['hydrate']>)(container, params)
126
- : (view(container, params) ?? noop)
127
- const outlet = firstOutlet(container)
128
- if (outlet === undefined) {
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, outlet })
132
- container = outlet
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 = hydrating
138
- ? (pageView.hydrate as NonNullable<Route['hydrate']>)(container, params)
139
- : (pageView(container, params) ?? undefined)
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 === null) {
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
- ]).then(([pageView, resolvedLayouts, decision]) => {
302
- if (token !== sequence || disposed) {
303
- return
304
- }
305
- /* handle() redirected: go where it pointed, replacing the blocked
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
- if (decision.kind === 'redirect') {
308
- navigate(decision.path, true)
309
- return
310
- }
311
- /* handle() blocked it / redirected off-origin / the probe failed:
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
- if (decision.kind === 'reload') {
314
- if (typeof location !== 'undefined') {
315
- location.href = decision.url
359
+ if (decision.kind === 'reload') {
360
+ if (typeof location !== 'undefined') {
361
+ location.href = decision.url
362
+ }
363
+ return
316
364
  }
317
- return
318
- }
319
- const layoutViews = resolvedLayouts.filter(
320
- (view): view is Route => view !== undefined,
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
- let divergence = 0
325
- while (
326
- divergence < mountedLayouts.length &&
327
- divergence < chainKeys.length &&
328
- mountedLayouts[divergence]?.key === chainKeys[divergence]
329
- ) {
330
- divergence += 1
331
- }
332
- const hydrating =
333
- first && pageView?.hydratable === true && pageView.hydrate !== undefined
334
- first = false
335
- /* The DOM mutation a navigation makes: tear the divergent chain down,
336
- clear its DOM (a fresh mount; hydration adopts in place), rebuild. */
337
- const swap = (): void => {
338
- /* Tear the outgoing page + divergent layouts down BEFORE publishing the
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
- const base = disposeFrom(divergence)
345
- const url = resolveUrl(path)
346
- clientPage.value = { route: chainRoute, params, url, navigating: false }
347
- if (!hydrating) {
348
- base.textContent = ''
349
- }
350
- buildFrom(base, divergence, chainKeys, layoutViews, pageView, params, hydrating)
351
- /* Reapply the destination entry's scroll once its DOM exists — a
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
- historyEntries.restore(url.hash)
358
- }
359
- /* Wrap the swap in a View Transition where the browser supports it, so
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
- if (
365
- !hydrating &&
366
- typeof document !== 'undefined' &&
367
- 'startViewTransition' in document
368
- ) {
369
- document.startViewTransition(swap)
370
- } else {
371
- swap()
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 element a layout's `<slot/>` lowers to: an empty structural container the
2
- router fills with the next layer of the route's layout chain (the nested layout
3
- or the page). SSR emits it empty so the renderer can fold the child's html into
4
- it; on the client the router mounts/hydrates the child into it and finds it by
5
- tag. Shared by both compiler back-ends, the SSR chain composer, and the router so
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 disposer alongside the component's own
6
- `model` document — the serializable `state` doc, needed so a hot swap can carry
7
- its value across (see `hotReplace`). The model is found, not threaded: a component
8
- seeds its `model` first (the desugared `const model = doc({})` + its init patches
9
- run before any child mounts or control-flow blocks), so the FIRST patch announced
10
- on the bus during the build names it. A component with no `state` mints no model
11
- and emits nothing first `model` is then `undefined` and there is nothing to
12
- preserve. Used only on the hot path; the subscription is torn down with the build.
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: () => () => void): {
15
- dispose: () => void
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
- dispose = build()
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) {