@abide/abide 0.123.2 → 0.124.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 (150) hide show
  1. package/ABIDE.md +8 -5
  2. package/CHANGELOG.md +58 -0
  3. package/bin/abide.ts +36 -65
  4. package/docs/REGISTRY.md +45 -12
  5. package/docs/RULEBOOK.md +24 -14
  6. package/docs/abide.md +999 -459
  7. package/package.json +1 -1
  8. package/src/compiler/ARRAY_MUTATORS.ts +20 -0
  9. package/src/compiler/Diagnostics.ts +198 -0
  10. package/src/compiler/KeptChecker.ts +97 -0
  11. package/src/compiler/LoweredFile.ts +609 -0
  12. package/src/compiler/Lowering.ts +18 -1
  13. package/src/compiler/PositionedRefusal.ts +1 -1
  14. package/src/compiler/SHARED_NAMES.ts +2 -0
  15. package/src/compiler/ShadowHost.ts +670 -0
  16. package/src/compiler/ShadowMap.ts +1 -1
  17. package/src/compiler/TypeSession.ts +76 -1743
  18. package/src/compiler/appLayout.ts +28 -20
  19. package/src/compiler/appLints.ts +109 -0
  20. package/src/compiler/argsDefaultsIn.ts +1 -1
  21. package/src/compiler/checkApp.ts +64 -68
  22. package/src/compiler/checkedShadow.ts +2 -2
  23. package/src/compiler/classifyRead.ts +16 -1
  24. package/src/compiler/compileApp.ts +102 -71
  25. package/src/compiler/declaredExports.ts +46 -21
  26. package/src/compiler/diagnoseSources.ts +232 -0
  27. package/src/compiler/emitClient.ts +1 -1
  28. package/src/compiler/formatAbide.ts +7 -8
  29. package/src/compiler/handlersUnder.ts +32 -63
  30. package/src/compiler/lineOf.ts +0 -19
  31. package/src/compiler/matchersOf.ts +3 -3
  32. package/src/compiler/mountsOf.ts +3 -3
  33. package/src/compiler/reactiveReads.ts +235 -0
  34. package/src/compiler/reactiveTypes.ts +13 -0
  35. package/src/compiler/readSources.ts +16 -18
  36. package/src/compiler/readsIn.ts +181 -82
  37. package/src/compiler/routesDeclaration.ts +46 -0
  38. package/src/compiler/scaffold.ts +7 -2
  39. package/src/compiler/serveLanguage.ts +46 -74
  40. package/src/compiler/sharedModuleWrites.ts +1 -1
  41. package/src/compiler/splitImports.ts +2 -2
  42. package/src/compiler/templateWarnings.ts +1 -2
  43. package/src/compiler/trackingWarnings.ts +1 -1
  44. package/src/server/CLIENT_BUILD.ts +4 -32
  45. package/src/server/DesktopSession.ts +213 -0
  46. package/src/server/Followers.ts +399 -0
  47. package/src/server/MODULE_QUERIES.ts +6 -2
  48. package/src/server/PageRender.ts +201 -0
  49. package/src/server/RESUME_MILLISECONDS.ts +2 -0
  50. package/src/server/abideModules.ts +7 -7
  51. package/src/server/answerFailure.ts +3 -2
  52. package/src/server/answerRequest.ts +16 -369
  53. package/src/server/answerRpc.ts +48 -302
  54. package/src/server/answerSocket.ts +41 -490
  55. package/src/server/appBuild.ts +377 -0
  56. package/src/server/appKit.ts +1 -1
  57. package/src/server/appPlugins.ts +15 -17
  58. package/src/server/appTree.ts +6 -5
  59. package/src/server/argsContract.ts +209 -0
  60. package/src/server/bridgeRelay.ts +153 -65
  61. package/src/server/claudeCode.ts +30 -127
  62. package/src/server/config.ts +6 -7
  63. package/src/server/createApp.ts +38 -66
  64. package/src/server/csp.ts +1 -1
  65. package/src/server/deferredBuilds.ts +3 -16
  66. package/src/server/devWorker.ts +11 -15
  67. package/src/server/faceOf.ts +1 -1
  68. package/src/server/generatedSurfaces.ts +2 -1
  69. package/src/server/handlerFileMount.ts +40 -0
  70. package/src/server/index.ts +3 -3
  71. package/src/server/installServer.ts +5 -9
  72. package/src/server/invokeHandler.ts +230 -0
  73. package/src/server/mountHandlers.ts +184 -0
  74. package/src/server/openDesktopWindow.ts +308 -0
  75. package/src/server/packageDirectory.ts +14 -0
  76. package/src/server/preload.ts +21 -2
  77. package/src/server/principal.ts +5 -4
  78. package/src/server/remote.ts +2 -2
  79. package/src/server/render.ts +31 -59
  80. package/src/server/requestScope.ts +5 -36
  81. package/src/server/runCompiled.ts +1 -1
  82. package/src/server/runDesktop.ts +20 -448
  83. package/src/server/seedBuffer.ts +12 -12
  84. package/src/server/serveDev.ts +24 -58
  85. package/src/server/servedApp.ts +390 -0
  86. package/src/server/shell.ts +1 -1
  87. package/src/server/signInChanged.ts +20 -0
  88. package/src/server/surfaceOf.ts +3 -2
  89. package/src/server/versionOf.ts +7 -16
  90. package/src/shared/RPC_SERVER.ts +27 -31
  91. package/src/shared/Schema.ts +1 -2
  92. package/src/shared/applyPatch.ts +1 -1
  93. package/src/shared/index.ts +1 -1
  94. package/src/shared/{note.ts → issueReport.ts} +22 -0
  95. package/src/shared/loadCall.ts +66 -0
  96. package/src/shared/muxFrames.ts +1 -1
  97. package/src/shared/reactive/Keyed.ts +24 -16
  98. package/src/shared/reactive/Reactive.ts +37 -991
  99. package/src/shared/reactive/argsContract.ts +131 -4
  100. package/src/shared/reactive/byOf.ts +2 -5
  101. package/src/shared/reactive/callLoad.ts +8 -14
  102. package/src/shared/reactive/cellShape.ts +734 -0
  103. package/src/shared/reactive/index.ts +12 -23
  104. package/src/shared/reactive/pairReactive.ts +5 -3
  105. package/src/shared/reactive/patchLog.ts +79 -0
  106. package/src/shared/reactive/patchMarks.ts +34 -0
  107. package/src/shared/reactive/producer.ts +121 -0
  108. package/src/shared/reactive/recordWrites.ts +11 -14
  109. package/src/shared/reactive/scoped.ts +3 -2
  110. package/src/shared/reactive/selections.ts +209 -0
  111. package/src/shared/reactive/share.ts +4 -3
  112. package/src/shared/reactive/watch.ts +3 -4
  113. package/src/shared/rpc.ts +20 -24
  114. package/src/shared/validateJson.ts +1 -2
  115. package/src/shared/writePath.ts +29 -0
  116. package/src/ui/ArmRegion.ts +10 -4
  117. package/src/ui/Level.ts +11 -20
  118. package/src/ui/LevelChain.ts +272 -0
  119. package/src/ui/Router.ts +106 -313
  120. package/src/ui/Scope.ts +17 -3
  121. package/src/ui/TryRegion.ts +22 -23
  122. package/src/ui/bindSpread.ts +2 -4
  123. package/src/ui/listenForRoutes.ts +3 -10
  124. package/src/ui/outboxQueue.ts +23 -11
  125. package/src/ui/outcomeOf.ts +120 -0
  126. package/src/ui/reconcile.ts +4 -6
  127. package/src/ui/routing.ts +2 -1
  128. package/src/ui/rows.ts +2 -2
  129. package/src/ui/setAttribute.ts +12 -3
  130. package/src/ui/socketWire.ts +28 -13
  131. package/src/ui/wire.ts +9 -10
  132. package/src/compiler/Finding.ts +0 -2
  133. package/src/compiler/Refusals.ts +0 -65
  134. package/src/compiler/formatDiagnosis.ts +0 -20
  135. package/src/server/appDirectories.ts +0 -9
  136. package/src/server/argsFor.ts +0 -39
  137. package/src/server/buildDirectory.ts +0 -10
  138. package/src/server/coerceArgs.ts +0 -109
  139. package/src/server/derivedArgs.ts +0 -26
  140. package/src/server/runBuild.ts +0 -24
  141. package/src/server/serveBuild.ts +0 -144
  142. package/src/server/testBuild.ts +0 -220
  143. package/src/shared/LOAD_OFFLINE.ts +0 -15
  144. package/src/shared/LOAD_SIGNAL.ts +0 -10
  145. package/src/shared/issuesOf.ts +0 -18
  146. package/src/shared/reactive/ENTRY_LIVES.ts +0 -7
  147. package/src/shared/reactive/FOLLOWED.ts +0 -8
  148. package/src/shared/reactive/PATCHES.ts +0 -11
  149. package/src/shared/reactive/PATCH_SINKS.ts +0 -26
  150. package/src/shared/reactive/canonicalKey.ts +0 -95
package/src/ui/Router.ts CHANGED
@@ -1,41 +1,25 @@
1
1
  import { CURRENT_ROUTER, type NavigateOptions } from '#shared/CURRENT_ROUTER.ts'
2
2
  import { decodedOrKept } from '#shared/decodedOrKept.ts'
3
3
  import { ERROR_META } from '#shared/ERROR_META.ts'
4
- import { firstMatch, type Params, partsOf } from '#shared/matchRoute.ts'
4
+ import { firstMatch, partsOf } from '#shared/matchRoute.ts'
5
5
  import { unmounted } from '#shared/mount.ts'
6
6
  import { NAVIGATION_HEADER } from '#shared/NAVIGATION_HEADER.ts'
7
7
  import { OFFLINE_SAVED } from '#shared/OFFLINE_CACHES.ts'
8
8
  import { REACHED_MESSAGE } from '#shared/REACHED_MESSAGE.ts'
9
- import type { ShareScope } from '#shared/reactive/share.ts'
10
9
  import { arrive, route } from '#shared/route.ts'
11
10
  import { rankRoutes, type Segment } from '#shared/routePattern.ts'
12
- import { setUpLevels } from '#shared/setUpLevels.ts'
13
11
  import { paintedCallsLanded } from '#shared/takeEarlyCall.ts'
14
12
 
15
- import { LAYER_MOUNT, LAYER_NAVIGATE } from './LAYER_NAMES.ts'
16
- import { Level } from './Level.ts'
13
+ import { LAYER_NAVIGATE } from './LAYER_NAMES.ts'
14
+ import { LevelChain, type PageModule } from './LevelChain.ts'
17
15
  import { beginLayer, endLayer } from './measureLayer.ts'
18
- import { fillRegion, pageRegion, type Region } from './regions.ts'
19
- import type { Scope, Stoppable } from './Scope.ts'
20
-
21
- /**
22
- * A page's client mount, called against the region the router owns.
23
- *
24
- * The same default export a page already has — the router is a CALLER of a page exactly as a page
25
- * is a caller of a component, which is why nothing about the emitted module changes to be routable.
26
- */
27
- export type PageMount = (
28
- root: Region,
29
- props?: Record<string, unknown>,
30
- // 39.6 — A LEVEL'S HEAD IS ITS OWN MOUNT'S TO APPLY, from inside the instance its slots read
31
- // (`emitClient`), so the router hands the module a region and says it is the router by handing
32
- // it the level it stands at — a file invoked as a component too is mounted without it, and
33
- // applies no head a row. 39.14 — the level is where the mount leaves its head for the router
34
- // to read when it withdraws.
35
- routed?: Level,
36
- ) => Scope
37
-
38
- type PageModule = { default: PageMount }
16
+ import {
17
+ type Bearing,
18
+ type Found,
19
+ outcomeOf,
20
+ type Settled,
21
+ } from './outcomeOf.ts'
22
+ import { pageRegion, type Region } from './regions.ts'
39
23
 
40
24
  export type Route = {
41
25
  /** The route literal, in the bracket spelling the page's directory is already written in. */
@@ -52,8 +36,6 @@ export type Route = {
52
36
 
53
37
  type Compiled = Route & { segments: Segment[] }
54
38
 
55
- type Found = { route: Compiled; params: Params }
56
-
57
39
  /** A window's scroll, `[scrollX, scrollY]`. */
58
40
  type Scroll = readonly [number, number]
59
41
 
@@ -82,32 +64,25 @@ const SUPERSEDED = 'superseded'
82
64
  *
83
65
  * It holds what it put on the page, so a navigation can take it back off. A page mounted into the
84
66
  * region owns a `Scope` (12.13) and the router is the only thing that can remove its nodes, so the
85
- * router is where that scope is stopped.
67
+ * router — through its `LevelChain` — is where that scope is stopped. The router keeps the address,
68
+ * the history and the scroll.
86
69
  */
87
70
  export class Router {
88
- region: Region
89
- routes: Compiled[]
71
+ readonly region: Region
72
+ private readonly routes: Compiled[]
90
73
  /** The chain standing in the document, outermost layout first and the page last. */
91
- levels: Level[]
92
- /**
93
- * The chain the address resolved to.
94
- *
95
- * A kept layout's `<slot/>` mounts from it whenever its region opens — read at that moment
96
- * rather than captured when the layout mounted, or a slot reopened after a navigation put back
97
- * the page the reader had left.
98
- */
99
- chain: PageModule[]
74
+ readonly levels: LevelChain
100
75
  /**
101
76
  * The path the standing chain was mounted for: a step to it that moves only the query or the
102
77
  * fragment keeps the page (30.23, 30.24).
103
78
  */
104
- mounted: string | undefined
79
+ private mounted: string | undefined
105
80
  /**
106
81
  * The route the standing page was matched by.
107
82
  *
108
83
  * 30.23 — an address it matches again keeps it.
109
84
  */
110
- standing: Compiled | undefined
85
+ private standing: Compiled | undefined
111
86
  /**
112
87
  * The ticket of the live navigation; a superseded one stops when it sees its own aborted.
113
88
  *
@@ -124,40 +99,39 @@ export class Router {
124
99
  * 30.31 — AN `AbortController`, so superseding a navigation also aborts the `middleware` request
125
100
  * it is still waiting on, rather than leaving it to finish for nobody.
126
101
  */
127
- navigation: AbortController
102
+ private navigation: AbortController
128
103
  /**
129
104
  * 35.18 — whether the document is an error page's. Its markup is no page this router has, so
130
105
  * nothing hydrates over it, and leaving it is a document load.
131
106
  */
132
- errorPage: boolean
107
+ private readonly errorPage: boolean
133
108
  /** The place in this document's history of the entry the address bar stands at (`Entry`). */
134
- index: number
109
+ private index: number
135
110
  /**
136
111
  * 30.29 — the scroll each entry was left at, by its `index`.
137
112
  *
138
113
  * HELD HERE AS WELL AS IN `history.state`, because a back or forward step has already moved the
139
114
  * bar when `popstate` arrives, so the entry it left can no longer be written to.
140
115
  */
141
- scrolls: (Scroll | undefined)[]
116
+ private readonly scrolls: (Scroll | undefined)[]
142
117
  /**
143
118
  * The routes whose chunks `prefetch` has already asked for.
144
119
  *
145
120
  * `pointerover` bubbles, so a pointer crossing a link's children asks again at every edge, and
146
121
  * after the first time the answer is the module cache's anyway.
147
122
  */
148
- prefetched: Set<Compiled>
123
+ private readonly prefetched: Set<Compiled>
149
124
  /**
150
125
  * Whether a navigation's write is a view transition, as the build read it (39.7, 39.8).
151
126
  *
152
127
  * Read off the compiled `<style>` blocks. Nothing here looks at the document's stylesheets.
153
128
  */
154
- transitions: boolean
129
+ private readonly transitions: boolean
155
130
 
156
131
  constructor(region: Region, routes: Compiled[], transitions: boolean) {
157
132
  this.region = region
158
133
  this.routes = routes
159
- this.levels = []
160
- this.chain = []
134
+ this.levels = new LevelChain(region)
161
135
  this.mounted = undefined
162
136
  this.standing = undefined
163
137
  this.navigation = new AbortController()
@@ -181,7 +155,7 @@ export class Router {
181
155
  * as the address below the app's root. Every way in — a click, an intent, back and forward, a
182
156
  * hydration — is matched here.
183
157
  */
184
- match(pathname: string): Found | undefined {
158
+ match(pathname: string): Found<Compiled> | undefined {
185
159
  const below = unmounted(pathname)
186
160
  const parts = below === undefined ? undefined : partsOf(below)
187
161
  return parts === undefined ? undefined : firstMatch(this.routes, parts)
@@ -214,33 +188,42 @@ export class Router {
214
188
  * fills itself from the template it carries. One module, both ways in — the `fillEmpty`
215
189
  * the emitter already writes is the whole of the difference.
216
190
  */
217
- async go(
191
+ go(url: URL | string, options: NavigateOptions = {}): Promise<void> {
192
+ return this.travel(url, options, PUSHED)
193
+ }
194
+
195
+ /**
196
+ * Arrives at the entry a back or forward step has already moved the address bar to.
197
+ *
198
+ * The page is mounted where the bar already points and nothing is pushed for it. 30.20 — a step
199
+ * within the standing page, back over an in-page `#anchor` jump, is `follow`'s: nothing asked,
200
+ * nothing remounted. 30.29 — the scroll is put back once the page it belongs to is mounted.
201
+ */
202
+ popped(): Promise<void> {
203
+ return this.travel(
204
+ location.href,
205
+ { replace: true, keepScroll: true },
206
+ this.stepped(),
207
+ )
208
+ }
209
+
210
+ /** `go`, arriving as `step` says: a push, or a history step with the scroll it restores. */
211
+ private async travel(
218
212
  url: URL | string,
219
- options: NavigateOptions = {},
220
- step: Step = PUSHED,
213
+ options: NavigateOptions,
214
+ step: Step,
221
215
  ): Promise<void> {
222
- const target =
223
- typeof url === 'string' ? new URL(url, location.href) : url
224
- // A CROSS-ORIGIN ADDRESS IS NOT A ROUTE. Handing it to `pushState` throws, and treating
225
- // it as a miss would leave the caller with a navigation that silently did nothing.
226
- if (target.origin !== location.origin || this.errorPage) {
227
- location.href = target.href
228
- return
229
- }
230
- const found = this.match(target.pathname)
231
- if (found === undefined) {
232
- // NOT A ROUTE THIS BUILD KNOWS, so it is the server's to answer — which is a document
233
- // load, and the one case where 30.7 does not apply because there is no route to run.
234
- location.href = target.href
235
- return
236
- }
237
- // 30.21, 30.23, 30.24 — THE ROUTE STANDING, AT ITS OWN PATH: another query, another fragment or
238
- // the same address. The page reads its address through `route`, so it follows rather than
239
- // being rebuilt, and there is no path for `middleware` to decide anything new about.
240
- if (found.route === this.standing && this.mounted === target.pathname) {
241
- this.follow(target, options, found, step)
242
- return
216
+ const bearing: Bearing<Compiled> = {
217
+ target: typeof url === 'string' ? new URL(url, location.href) : url,
218
+ origin: location.origin,
219
+ errorPage: this.errorPage,
220
+ standing: this.standing,
221
+ mounted: this.mounted,
222
+ match: (pathname) => this.match(pathname),
243
223
  }
224
+ const before = outcomeOf(bearing)
225
+ if (before.kind !== 'ask') return this.settle(before, options, step)
226
+ const found = before.found
244
227
  const ticket = this.supersede()
245
228
  route.navigating.set(true)
246
229
  // EVERY WAY OUT CLEARS WHAT THE WAY IN SET: a load that fails, a setup that throws and a
@@ -249,70 +232,61 @@ export class Router {
249
232
  try {
250
233
  // 30.15 — THE SERVER IS ASKED ALONGSIDE THE LOAD, so its `middleware` decides the
251
234
  // navigation for the price of the one round trip the chunks already cost.
235
+ // 30.27 — `loadOrMiss`, because a rejection here was dropped by the click's `void`,
236
+ // and the link did nothing.
252
237
  const [answer, loaded] = await Promise.all([
253
- ask(target, ticket.signal),
238
+ ask(bearing.target, ticket.signal),
254
239
  loadOrMiss(found.route),
255
240
  ])
256
241
  // SUPERSEDED, so this navigation is over. Nothing has been touched yet, which is the
257
242
  // whole reason the ticket is checked here rather than after the mount — and
258
243
  // `navigating` stays the newer navigation's to clear.
259
244
  if (ticket.signal.aborted) return
260
- // 30.32 — OFFLINE, THE DOCUMENT LOAD BELOW CAN ONLY PAINT THE BROWSER'S ERROR PAGE over a
261
- // page that works. A history step has already moved the bar, so it is left to the
262
- // document.
263
- if (
264
- answer === undefined &&
265
- !navigator.onLine &&
266
- step.direction === 'push'
267
- ) {
245
+ const after = outcomeOf(bearing, {
246
+ found,
247
+ answer,
248
+ loaded: loaded !== undefined,
249
+ online: navigator.onLine,
250
+ pushed: step.direction === 'push',
251
+ })
252
+ if (after.kind === 'offline') {
268
253
  // 45.12 — UNLESS THE SERVICE WORKER SAVED THE PAGE, which the document load is
269
254
  // then answered with.
270
- if ((await savedOffline(target)) && !ticket.signal.aborted)
271
- location.href = target.href
272
- return
273
- }
274
- // 30.18 — A REFUSAL, A RESPONSE MIDDLEWARE ANSWERED ITSELF, or no answer at all: the
275
- // document asks again and paints whatever the server says. A fetch that failed —
276
- // offline, or a redirect to another origin that CORS will not let it follow — goes to
277
- // the address it was sent to.
278
- // 30.27 — AND CHUNKS THAT WOULD NOT LOAD, with the server down or a deploy that removed
279
- // them: a rejection here was dropped by the click's `void`, and the link did nothing.
280
- if (answer?.status !== 204 || loaded === undefined) {
281
- location.href = answer?.url ?? target.href
282
- return
283
- }
284
- // 30.17 — REDIRECTED, and the server already said yes to where it landed: the answer is
285
- // the 204 at the end of the chain. That address is mounted in place of the one asked
286
- // for, and a route the client has no row for is the document's (30.10).
287
- const landed = answer.redirected ? new URL(answer.url) : target
288
- const landing = answer.redirected
289
- ? this.match(landed.pathname)
290
- : found
291
- if (landing === undefined) {
292
- location.href = landed.href
293
- return
294
- }
295
- // 30.23 — THE ROUTE STANDING, AT ANOTHER PATH: `middleware` has said yes to the path, and
296
- // the page it matched is the one already mounted, so it follows.
297
- if (landing.route === this.standing) {
298
- this.follow(landed, options, landing, step)
255
+ if (
256
+ (await savedOffline(bearing.target)) &&
257
+ !ticket.signal.aborted
258
+ )
259
+ location.href = after.href
299
260
  return
300
261
  }
262
+ if (after.kind !== 'mount') return this.settle(after, options, step)
301
263
  const arriving =
302
- landing === found ? loaded : await loadOrMiss(landing.route)
264
+ after.found === found
265
+ ? loaded
266
+ : await loadOrMiss(after.found.route)
303
267
  if (ticket.signal.aborted) return
304
268
  if (arriving === undefined) {
305
- location.href = landed.href
269
+ location.href = after.at.href
306
270
  return
307
271
  }
308
272
  await this.write(ticket, step.direction, () =>
309
- this.commit(landed, landing, arriving, options, step),
273
+ this.commit(after.at, after.found, arriving, options, step),
310
274
  )
311
275
  } finally {
312
276
  if (this.navigation === ticket) route.navigating.set(false)
313
277
  }
314
278
  }
315
279
 
280
+ /** Executes an outcome that loads nothing: a document load, or a step within the page. */
281
+ private settle(
282
+ outcome: Settled<Compiled>,
283
+ options: NavigateOptions,
284
+ step: Step,
285
+ ): void {
286
+ if (outcome.kind === 'document') location.href = outcome.href
287
+ else this.follow(outcome.at, options, outcome.found, step)
288
+ }
289
+
316
290
  /**
317
291
  * Moves the address within the standing page, leaving the page mounted.
318
292
  *
@@ -327,7 +301,7 @@ export class Router {
327
301
  follow(
328
302
  target: URL,
329
303
  options: NavigateOptions,
330
- found: Found,
304
+ found: Found<Compiled>,
331
305
  step: Step,
332
306
  ): void {
333
307
  this.supersede()
@@ -354,7 +328,7 @@ export class Router {
354
328
  * browser pushed itself — a link to a fragment — so it is stamped as the next entry, and
355
329
  * arrived at as a push.
356
330
  */
357
- stepped(): Step {
331
+ private stepped(): Step {
358
332
  this.scrolls[this.index] = [scrollX, scrollY]
359
333
  const state = history.state as Partial<Entry> | null
360
334
  if (typeof state?.index !== 'number') {
@@ -457,7 +431,7 @@ export class Router {
457
431
  */
458
432
  private commit(
459
433
  target: URL,
460
- found: Found,
434
+ found: Found<Compiled>,
461
435
  arriving: PageModule[],
462
436
  options: NavigateOptions,
463
437
  step: Step,
@@ -468,37 +442,15 @@ export class Router {
468
442
  // honest is which fraction of click -> paint the router's WORK is.
469
443
  beginLayer(LAYER_NAVIGATE)
470
444
  try {
471
- // 41.11 — WHAT THE TWO ADDRESSES SHARE IS NOT REBUILT. The comparison is by the MOUNT
472
- // rather than by the loader or the module record: the table holds an arrow per row, so
473
- // two routes under one layout name the same file through two different functions, and a
474
- // table written by hand may answer with a fresh record each call. The function a module
475
- // exports is the same function either way.
476
- const shared = this.sharedLevels(arriving)
477
- // RECONCILED BEFORE THE STOP, while the leaving levels' heads read what they read on
478
- // screen: what the server painted for them is adopted as theirs, so it leaves with them.
479
- Level.reconcile(this.levels)
445
+ // 41.11 — WHAT THE TWO ADDRESSES SHARE IS NOT REBUILT.
446
+ const kept = this.levels.sharedWith(arriving)
447
+ // Read before anything is written: read after a region was emptied, it forces a layout
448
+ // and reads the offset that layout clamped.
480
449
  const left: Scroll = [scrollX, scrollY]
481
- this.stopFrom(shared)
482
- // The region the arriving part goes into: the page region where nothing is kept, and
483
- // the deepest kept layout's own slot where something is.
484
- //
485
- // NOWHERE, where that slot's region is closed: the arriving part is mounted by the slot
486
- // when it opens, off `chain`, and not into a region no longer in the document.
487
- const into =
488
- shared === 0
489
- ? this.region
490
- : (this.levels[shared - 1] as Level).slot
491
- // EMPTIED THROUGH THE PRIMITIVE EVERY OTHER REGION IS. `fillRegion(region)` with no
492
- // build is this removal, and it carries the 41.10 refusal for a region no longer in a
493
- // document that a hand-written walk swallows.
494
- if (into !== undefined) fillRegion(into)
495
- this.writeAddress(target, options.replace === true, left)
496
- // 39.6 — each arriving level applies its own head as it mounts into its emptied region,
497
- // outermost first; a kept layout is not mounted again, so neither is its head.
498
- this.mountChain(target, found, arriving, shared, into)
499
- // 39.14 — what the leaving levels keyed and the arriving ones did not goes back to the
500
- // nearest kept layout's, else leaves; their keyless nodes leave with them (39.16).
501
- Level.reconcile(this.levels)
450
+ this.levels.replace(kept, arriving, () => {
451
+ this.writeAddress(target, options.replace === true, left)
452
+ this.arriveAt(target, found)
453
+ })
502
454
  // 30.29 — where the entry was left, on a history step; else the top of the page, or —
503
455
  // as the browser does on a load — the fragment's element.
504
456
  if (step.scroll !== undefined)
@@ -509,134 +461,6 @@ export class Router {
509
461
  }
510
462
  }
511
463
 
512
- /**
513
- * How many levels of the arriving chain are already standing.
514
- *
515
- * Layouts only: a page reached here is another route's, the same route's being followed instead
516
- * (30.23).
517
- *
518
- * EVERY STANDING LEVEL IS COMPARED, the page included: a page is never a layout, so it cannot
519
- * match one, and a layout whose slot is closed stands with nothing below it.
520
- */
521
- private sharedLevels(arriving: PageModule[]): number {
522
- const layouts = arriving.length - 1
523
- let shared = 0
524
- while (
525
- shared < layouts &&
526
- shared < this.levels.length &&
527
- (this.chain[shared] as PageModule).default ===
528
- (arriving[shared] as PageModule).default
529
- )
530
- shared += 1
531
- return shared
532
- }
533
-
534
- /**
535
- * Stops every level from `at` down, inside out, and drops them.
536
- *
537
- * Stopped from the INSIDE OUT, so a layout's disposers run after the page's — the order a
538
- * scope's own teardown already has, and the one an author reading two `watch` cleanups in
539
- * two files would expect.
540
- */
541
- private stopFrom(at: number): void {
542
- for (let index = this.levels.length - 1; index >= at; index -= 1) {
543
- const level = this.levels[index] as Level
544
- // OUT OF THE CHAIN BEFORE IT STOPS, so a slot stop it fires on the way finds it gone
545
- // (`release`) and leaves the head to whoever stopped it.
546
- this.levels.length = index
547
- // The level's head leaves whole, so none of its components withdraws its own.
548
- level.contributed.clear()
549
- level.scope?.stop()
550
- }
551
- }
552
-
553
- /**
554
- * Mounts the chain from level `at` into `region`, each level handed the next as `children`.
555
- *
556
- * 35.1 — each level is handed the one below it as `children` (32.18), so a layout puts the page
557
- * in its `<slot/>` with the same mechanism a caller writing markup between two tags uses — and
558
- * the router learns where that slot is on the way past, which is what lets it swap the page
559
- * there later without the layout knowing.
560
- *
561
- * THE CHILD'S SCOPE IS THE ROUTER'S, not the layout's, so a kept layout cannot stop the page
562
- * arriving under it. What `mount` hands the layout is a stop for WHATEVER STANDS IN THE SLOT
563
- * when the slot's region leaves — a `<slot/>` inside a closed `{#if}` — rather than for the
564
- * page that happened to be mounted first. And the slot mounts from `chain`, so a slot that
565
- * opens again mounts the page the address resolves to now.
566
- */
567
- private mountFrom(at: number, region: Region): void {
568
- setUpLevels(
569
- at,
570
- this.chain.length,
571
- this.levels[at - 1]?.share,
572
- ROUTE_PROPS,
573
- (below, props) => this.setUp(below, region, props),
574
- )
575
- this.markUp(at, region)
576
- }
577
-
578
- /**
579
- * Runs level `at`'s setup over `props`, leaving its markup on the level for `markUp`, and
580
- * answers the share scope the setup left.
581
- */
582
- private setUp(
583
- at: number,
584
- region: Region,
585
- props: Record<string, (() => unknown) | undefined>,
586
- ): ShareScope | null {
587
- const module = this.chain[at] as PageModule
588
- const level = new Level(this.levels, at)
589
- this.levels[at] = level
590
- if (at < this.chain.length - 1)
591
- props.children = () => ({
592
- // NO BUILD, because the level below carries its own (32.17): a module a route
593
- // reaches fills the region it is mounted against when that region is empty, so
594
- // the build and the mount are one call rather than two.
595
- build: undefined,
596
- mount: (slot: Region): Stoppable => {
597
- // The level below as `mountFrom` set it up, the first time its slot opens; a
598
- // slot opening again sets it up afresh.
599
- const pending = this.levels[at + 1]?.markup !== undefined
600
- if (!pending) this.release(level, at)
601
- level.slot = slot
602
- if (pending) this.markUp(at + 1, slot)
603
- else this.mountFrom(at + 1, slot)
604
- return { stop: () => this.release(level, at) }
605
- },
606
- })
607
- // The region a level's markup lands in is its slot above, known only once that markup
608
- // runs; a module mounted by the router leaves its markup rather than using this one.
609
- // A deferred level answers the scope its setup made, so the watches that setup opened are
610
- // stopped with the level even where its markup never runs — a slot that never opens.
611
- level.scope = level.mount(() => module.default(region, props, level))
612
- return level.share
613
- }
614
-
615
- /** Runs the markup level `at`'s setup left, into `region`. */
616
- private markUp(at: number, region: Region): void {
617
- const level = this.levels[at] as Level
618
- const markup = level.markup
619
- if (markup === undefined) return
620
- level.markup = undefined
621
- level.scope = level.mount(() => markup(region))
622
- }
623
-
624
- /**
625
- * Stops what stands below `level` and forgets its slot.
626
- *
627
- * A no-op once the level itself has been replaced, which is how a stop handed to a layout that
628
- * has since left does nothing.
629
- */
630
- private release(level: Level, at: number): void {
631
- if (this.levels[at] !== level) return
632
- level.slot = undefined
633
- // 39.14, 39.16 — THE LEVELS BELOW LEAVE WITH THE SLOT'S REGION, and their head with them:
634
- // reconciled first, so what the server painted for them is adopted as theirs.
635
- Level.reconcile(this.levels)
636
- this.stopFrom(at + 1)
637
- Level.reconcile(this.levels)
638
- }
639
-
640
464
  /**
641
465
  * Hydrates the page the server painted, wiring it where it stands.
642
466
  *
@@ -663,38 +487,22 @@ export class Router {
663
487
  if (ticket.signal.aborted) return
664
488
  // THE HYDRATE SHARE. Same name as the navigation's mount because it is the same work on the
665
489
  // same chain — what differs is the op it is a fraction of.
666
- this.mountChain(new URL(location.href), found, standing, 0, this.region)
490
+ this.arriveAt(new URL(location.href), found)
491
+ this.levels.hydrate(standing)
667
492
  // 30.30 — A RELOAD lands on an entry that carries the scroll `remember` wrote on the way out.
668
493
  const scroll = (history.state as Partial<Entry> | null)?.scroll
669
494
  if (scroll != null) scrollTo(scroll[0], scroll[1])
670
495
  }
671
496
 
672
497
  /**
673
- * Records the arriving chain as standing and mounts it from level `from` into `into`.
498
+ * Records the arriving route as standing at `target`.
674
499
  *
675
- * AT THE TOP OF THE CHAIN, NOT INSIDE `mountFrom`: it recurses one level per layout, and
676
- * `shares()` SUMS every measure of a name inside the op — so a pair inside the recursion would
677
- * count the innermost level once per level above it. 23.1 — WRITTEN BEFORE THE ARRIVING PAGE'S
678
- * SETUP, which reads it.
500
+ * 23.1 — WRITTEN BEFORE THE ARRIVING PAGE'S SETUP, which reads it.
679
501
  */
680
- private mountChain(
681
- target: URL,
682
- found: Found,
683
- modules: PageModule[],
684
- from: number,
685
- into: Region | undefined,
686
- ): void {
502
+ private arriveAt(target: URL, found: Found<Compiled>): void {
687
503
  arrive(target.href, found.route.pattern, found.params)
688
- this.chain = modules
689
504
  this.standing = found.route
690
505
  this.mounted = target.pathname
691
- if (into === undefined) return
692
- beginLayer(LAYER_MOUNT)
693
- try {
694
- this.mountFrom(from, into)
695
- } finally {
696
- endLayer(LAYER_MOUNT)
697
- }
698
506
  }
699
507
  }
700
508
 
@@ -764,21 +572,6 @@ async function ask(
764
572
  }
765
573
  }
766
574
 
767
- /**
768
- * The props a routed page or layout is set up over, `children` aside: `params` as a live accessor.
769
- *
770
- * 33.4 — A PROP IS AN ACCESSOR. A page is mounted as any other component is, so its one prop is
771
- * passed the way `renderComponent` passes a child's and the way `answerRequest` paints one: a
772
- * function per key. Passed as the plain record it lowered to `params()` on a value that was not
773
- * one, so every page with a `[param]` in its address hydrated dead — with its server markup
774
- * already on screen, which is what kept it quiet.
775
- *
776
- * 33.5 — AND A LIVE ONE: `params` reads `route.params`, which every navigation writes. A layout
777
- * kept across `/users/1` -> `/users/2` is not mounted again, so an accessor over the params it was
778
- * mounted with went on answering user 1.
779
- */
780
- const ROUTE_PROPS = { params: (): Params => route.params() }
781
-
782
575
  function entryAt(index: number, scroll: Scroll | null): Entry {
783
576
  return { index, scroll }
784
577
  }
package/src/ui/Scope.ts CHANGED
@@ -11,8 +11,22 @@ type Boundary = {
11
11
  }
12
12
 
13
13
  /** The boundary a scope built now falls under, or `undefined` outside every `{#try}` body. */
14
- export const BOUNDARY: { current: Boundary | undefined } = {
15
- current: undefined,
14
+ let ambientBoundary: Boundary | undefined
15
+
16
+ /**
17
+ * Runs `run` with every scope it builds falling under `boundary` (32.16), restoring the one around it.
18
+ *
19
+ * The ambient is this module's so `Scope`'s field reads a local at construction; `TryRegion` is the
20
+ * one writer, and a page with no `{#try}` does not import it.
21
+ */
22
+ export function underBoundary<T>(boundary: Boundary, run: () => T): T {
23
+ const previous = ambientBoundary
24
+ ambientBoundary = boundary
25
+ try {
26
+ return run()
27
+ } finally {
28
+ ambientBoundary = previous
29
+ }
16
30
  }
17
31
 
18
32
  /**
@@ -54,7 +68,7 @@ export class Scope {
54
68
  *
55
69
  * 32.16 — read once at construction.
56
70
  */
57
- boundary: Boundary | undefined = BOUNDARY.current
71
+ boundary: Boundary | undefined = ambientBoundary
58
72
  /**
59
73
  * 32.67 — where in `stops` a row's item watches start (`itemWatch`), and how many there are:
60
74
  * opened one after another by a row's mount, so two numbers find them and the row allocates