@abide/abide 0.123.1 → 0.123.3

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 (98) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/bin/abide.ts +36 -65
  3. package/docs/REGISTRY.md +2 -2
  4. package/docs/RULEBOOK.md +2 -2
  5. package/docs/abide.md +2 -2
  6. package/package.json +1 -1
  7. package/src/compiler/Diagnostics.ts +198 -0
  8. package/src/compiler/KeptChecker.ts +97 -0
  9. package/src/compiler/Lowering.ts +1 -1
  10. package/src/compiler/PositionedRefusal.ts +1 -1
  11. package/src/compiler/TypeSession.ts +62 -85
  12. package/src/compiler/appLayout.ts +28 -20
  13. package/src/compiler/argsDefaultsIn.ts +1 -1
  14. package/src/compiler/checkApp.ts +64 -68
  15. package/src/compiler/checkedShadow.ts +1 -1
  16. package/src/compiler/compileApp.ts +101 -70
  17. package/src/compiler/declaredExports.ts +2 -4
  18. package/src/compiler/emitClient.ts +1 -1
  19. package/src/compiler/formatAbide.ts +2 -2
  20. package/src/compiler/handlersUnder.ts +17 -29
  21. package/src/compiler/lineOf.ts +0 -19
  22. package/src/compiler/matchersOf.ts +3 -3
  23. package/src/compiler/mountsOf.ts +3 -3
  24. package/src/compiler/readSources.ts +16 -18
  25. package/src/compiler/readsIn.ts +1 -1
  26. package/src/compiler/serveLanguage.ts +23 -57
  27. package/src/compiler/sharedModuleWrites.ts +1 -1
  28. package/src/compiler/templateWarnings.ts +1 -2
  29. package/src/compiler/trackingWarnings.ts +1 -1
  30. package/src/server/CLIENT_BUILD.ts +4 -32
  31. package/src/server/RESUME_MILLISECONDS.ts +2 -0
  32. package/src/server/answerRequest.ts +11 -300
  33. package/src/server/answerRpc.ts +12 -54
  34. package/src/server/answerSocket.ts +8 -35
  35. package/src/server/appBuild.ts +377 -0
  36. package/src/server/appPlugins.ts +15 -17
  37. package/src/server/appTree.ts +6 -5
  38. package/src/server/argsContract.ts +209 -0
  39. package/src/server/bridgeRelay.ts +153 -65
  40. package/src/server/claudeCode.ts +30 -127
  41. package/src/server/config.ts +6 -7
  42. package/src/server/createApp.ts +30 -64
  43. package/src/server/devWorker.ts +11 -15
  44. package/src/server/handlerFileMount.ts +40 -0
  45. package/src/server/index.ts +2 -2
  46. package/src/server/packageDirectory.ts +14 -0
  47. package/src/server/preload.ts +3 -2
  48. package/src/server/principal.ts +4 -3
  49. package/src/server/remote.ts +1 -1
  50. package/src/server/runCompiled.ts +1 -1
  51. package/src/server/runDesktop.ts +1 -1
  52. package/src/server/seedBuffer.ts +1 -1
  53. package/src/server/serveDev.ts +24 -58
  54. package/src/server/servedApp.ts +390 -0
  55. package/src/server/signInChanged.ts +20 -0
  56. package/src/server/surfaceOf.ts +2 -1
  57. package/src/server/versionOf.ts +6 -15
  58. package/src/shared/applyPatch.ts +1 -1
  59. package/src/shared/muxFrames.ts +1 -1
  60. package/src/shared/reactive/Keyed.ts +1 -1
  61. package/src/shared/reactive/Reactive.ts +21 -8
  62. package/src/shared/reactive/argsContract.ts +131 -4
  63. package/src/shared/reactive/index.ts +1 -0
  64. package/src/shared/reactive/patchLog.ts +79 -0
  65. package/src/shared/reactive/patchMarks.ts +34 -0
  66. package/src/shared/reactive/recordWrites.ts +11 -14
  67. package/src/shared/reactive/share.ts +4 -3
  68. package/src/shared/rpc.ts +12 -19
  69. package/src/shared/validateJson.ts +14 -3
  70. package/src/ui/ArmRegion.ts +10 -4
  71. package/src/ui/Level.ts +11 -20
  72. package/src/ui/LevelChain.ts +272 -0
  73. package/src/ui/Router.ts +54 -245
  74. package/src/ui/Scope.ts +17 -3
  75. package/src/ui/TryRegion.ts +22 -23
  76. package/src/ui/bindSpread.ts +2 -4
  77. package/src/ui/listenForRoutes.ts +3 -10
  78. package/src/ui/outboxQueue.ts +21 -3
  79. package/src/ui/reconcile.ts +4 -6
  80. package/src/ui/routing.ts +2 -1
  81. package/src/ui/rows.ts +2 -2
  82. package/src/ui/setAttribute.ts +12 -3
  83. package/src/ui/socketWire.ts +26 -11
  84. package/src/compiler/Finding.ts +0 -2
  85. package/src/compiler/Refusals.ts +0 -65
  86. package/src/compiler/formatDiagnosis.ts +0 -20
  87. package/src/server/appDirectories.ts +0 -9
  88. package/src/server/argsFor.ts +0 -39
  89. package/src/server/buildDirectory.ts +0 -10
  90. package/src/server/coerceArgs.ts +0 -109
  91. package/src/server/derivedArgs.ts +0 -95
  92. package/src/server/runBuild.ts +0 -24
  93. package/src/server/serveBuild.ts +0 -144
  94. package/src/server/testBuild.ts +0 -220
  95. package/src/shared/reactive/FOLLOWED.ts +0 -8
  96. package/src/shared/reactive/PATCHES.ts +0 -11
  97. package/src/shared/reactive/PATCH_SINKS.ts +0 -26
  98. package/src/shared/reactive/canonicalKey.ts +0 -95
package/src/ui/Router.ts CHANGED
@@ -6,36 +6,14 @@ 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 { pageRegion, type Region } from './regions.ts'
39
17
 
40
18
  export type Route = {
41
19
  /** The route literal, in the bracket spelling the page's directory is already written in. */
@@ -82,32 +60,25 @@ const SUPERSEDED = 'superseded'
82
60
  *
83
61
  * It holds what it put on the page, so a navigation can take it back off. A page mounted into the
84
62
  * 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.
63
+ * router — through its `LevelChain` — is where that scope is stopped. The router keeps the address,
64
+ * the history and the scroll.
86
65
  */
87
66
  export class Router {
88
- region: Region
89
- routes: Compiled[]
67
+ readonly region: Region
68
+ private readonly routes: Compiled[]
90
69
  /** 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[]
70
+ readonly levels: LevelChain
100
71
  /**
101
72
  * The path the standing chain was mounted for: a step to it that moves only the query or the
102
73
  * fragment keeps the page (30.23, 30.24).
103
74
  */
104
- mounted: string | undefined
75
+ private mounted: string | undefined
105
76
  /**
106
77
  * The route the standing page was matched by.
107
78
  *
108
79
  * 30.23 — an address it matches again keeps it.
109
80
  */
110
- standing: Compiled | undefined
81
+ private standing: Compiled | undefined
111
82
  /**
112
83
  * The ticket of the live navigation; a superseded one stops when it sees its own aborted.
113
84
  *
@@ -124,40 +95,39 @@ export class Router {
124
95
  * 30.31 — AN `AbortController`, so superseding a navigation also aborts the `middleware` request
125
96
  * it is still waiting on, rather than leaving it to finish for nobody.
126
97
  */
127
- navigation: AbortController
98
+ private navigation: AbortController
128
99
  /**
129
100
  * 35.18 — whether the document is an error page's. Its markup is no page this router has, so
130
101
  * nothing hydrates over it, and leaving it is a document load.
131
102
  */
132
- errorPage: boolean
103
+ private readonly errorPage: boolean
133
104
  /** The place in this document's history of the entry the address bar stands at (`Entry`). */
134
- index: number
105
+ private index: number
135
106
  /**
136
107
  * 30.29 — the scroll each entry was left at, by its `index`.
137
108
  *
138
109
  * HELD HERE AS WELL AS IN `history.state`, because a back or forward step has already moved the
139
110
  * bar when `popstate` arrives, so the entry it left can no longer be written to.
140
111
  */
141
- scrolls: (Scroll | undefined)[]
112
+ private readonly scrolls: (Scroll | undefined)[]
142
113
  /**
143
114
  * The routes whose chunks `prefetch` has already asked for.
144
115
  *
145
116
  * `pointerover` bubbles, so a pointer crossing a link's children asks again at every edge, and
146
117
  * after the first time the answer is the module cache's anyway.
147
118
  */
148
- prefetched: Set<Compiled>
119
+ private readonly prefetched: Set<Compiled>
149
120
  /**
150
121
  * Whether a navigation's write is a view transition, as the build read it (39.7, 39.8).
151
122
  *
152
123
  * Read off the compiled `<style>` blocks. Nothing here looks at the document's stylesheets.
153
124
  */
154
- transitions: boolean
125
+ private readonly transitions: boolean
155
126
 
156
127
  constructor(region: Region, routes: Compiled[], transitions: boolean) {
157
128
  this.region = region
158
129
  this.routes = routes
159
- this.levels = []
160
- this.chain = []
130
+ this.levels = new LevelChain(region)
161
131
  this.mounted = undefined
162
132
  this.standing = undefined
163
133
  this.navigation = new AbortController()
@@ -214,10 +184,30 @@ export class Router {
214
184
  * fills itself from the template it carries. One module, both ways in — the `fillEmpty`
215
185
  * the emitter already writes is the whole of the difference.
216
186
  */
217
- async go(
187
+ go(url: URL | string, options: NavigateOptions = {}): Promise<void> {
188
+ return this.travel(url, options, PUSHED)
189
+ }
190
+
191
+ /**
192
+ * Arrives at the entry a back or forward step has already moved the address bar to.
193
+ *
194
+ * The page is mounted where the bar already points and nothing is pushed for it. 30.20 — a step
195
+ * within the standing page, back over an in-page `#anchor` jump, is `follow`'s: nothing asked,
196
+ * nothing remounted. 30.29 — the scroll is put back once the page it belongs to is mounted.
197
+ */
198
+ popped(): Promise<void> {
199
+ return this.travel(
200
+ location.href,
201
+ { replace: true, keepScroll: true },
202
+ this.stepped(),
203
+ )
204
+ }
205
+
206
+ /** `go`, arriving as `step` says: a push, or a history step with the scroll it restores. */
207
+ private async travel(
218
208
  url: URL | string,
219
- options: NavigateOptions = {},
220
- step: Step = PUSHED,
209
+ options: NavigateOptions,
210
+ step: Step,
221
211
  ): Promise<void> {
222
212
  const target =
223
213
  typeof url === 'string' ? new URL(url, location.href) : url
@@ -354,7 +344,7 @@ export class Router {
354
344
  * browser pushed itself — a link to a fragment — so it is stamped as the next entry, and
355
345
  * arrived at as a push.
356
346
  */
357
- stepped(): Step {
347
+ private stepped(): Step {
358
348
  this.scrolls[this.index] = [scrollX, scrollY]
359
349
  const state = history.state as Partial<Entry> | null
360
350
  if (typeof state?.index !== 'number') {
@@ -468,37 +458,15 @@ export class Router {
468
458
  // honest is which fraction of click -> paint the router's WORK is.
469
459
  beginLayer(LAYER_NAVIGATE)
470
460
  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)
461
+ // 41.11 — WHAT THE TWO ADDRESSES SHARE IS NOT REBUILT.
462
+ const kept = this.levels.sharedWith(arriving)
463
+ // Read before anything is written: read after a region was emptied, it forces a layout
464
+ // and reads the offset that layout clamped.
480
465
  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)
466
+ this.levels.replace(kept, arriving, () => {
467
+ this.writeAddress(target, options.replace === true, left)
468
+ this.arriveAt(target, found)
469
+ })
502
470
  // 30.29 — where the entry was left, on a history step; else the top of the page, or —
503
471
  // as the browser does on a load — the fragment's element.
504
472
  if (step.scroll !== undefined)
@@ -509,134 +477,6 @@ export class Router {
509
477
  }
510
478
  }
511
479
 
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
480
  /**
641
481
  * Hydrates the page the server painted, wiring it where it stands.
642
482
  *
@@ -663,38 +503,22 @@ export class Router {
663
503
  if (ticket.signal.aborted) return
664
504
  // THE HYDRATE SHARE. Same name as the navigation's mount because it is the same work on the
665
505
  // same chain — what differs is the op it is a fraction of.
666
- this.mountChain(new URL(location.href), found, standing, 0, this.region)
506
+ this.arriveAt(new URL(location.href), found)
507
+ this.levels.hydrate(standing)
667
508
  // 30.30 — A RELOAD lands on an entry that carries the scroll `remember` wrote on the way out.
668
509
  const scroll = (history.state as Partial<Entry> | null)?.scroll
669
510
  if (scroll != null) scrollTo(scroll[0], scroll[1])
670
511
  }
671
512
 
672
513
  /**
673
- * Records the arriving chain as standing and mounts it from level `from` into `into`.
514
+ * Records the arriving route as standing at `target`.
674
515
  *
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.
516
+ * 23.1 — WRITTEN BEFORE THE ARRIVING PAGE'S SETUP, which reads it.
679
517
  */
680
- private mountChain(
681
- target: URL,
682
- found: Found,
683
- modules: PageModule[],
684
- from: number,
685
- into: Region | undefined,
686
- ): void {
518
+ private arriveAt(target: URL, found: Found): void {
687
519
  arrive(target.href, found.route.pattern, found.params)
688
- this.chain = modules
689
520
  this.standing = found.route
690
521
  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
522
  }
699
523
  }
700
524
 
@@ -764,21 +588,6 @@ async function ask(
764
588
  }
765
589
  }
766
590
 
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
591
  function entryAt(index: number, scroll: Scroll | null): Entry {
783
592
  return { index, scroll }
784
593
  }
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
@@ -19,13 +19,8 @@ import { type Disposer, watchTracked } from '#shared/reactive/watch.ts'
19
19
 
20
20
  import { type Arm, type ArmMakers, ArmRegion } from './ArmRegion.ts'
21
21
  import { Mismatch } from './Mismatch.ts'
22
- import {
23
- fillRegion,
24
- isEmptyRegion,
25
- type Region,
26
- removeUntil,
27
- } from './regions.ts'
28
- import { BOUNDARY, type Scope, type Stoppable } from './Scope.ts'
22
+ import { fillRegion, type Region, removeUntil } from './regions.ts'
23
+ import { type Scope, type Stoppable, underBoundary } from './Scope.ts'
29
24
 
30
25
  /**
31
26
  * The body until something in it fails, then the catch until the failure clears. Arm `0` is the
@@ -40,7 +35,7 @@ import { BOUNDARY, type Scope, type Stoppable } from './Scope.ts'
40
35
  * anchor names as arm 1 (`namedArm`) — it keeps until the body has been tried behind it
41
36
  * (`mountBehind`), and adopts where the body fails here too, so the catch the server sent is the
42
37
  * one on the page. A body that succeeds here after failing there replaces it. So 41.24 is already
43
- * this region's shape — its open names the arm — and `paintedOther` is never asked here.
38
+ * this region's shape — its open names the arm — and `paintedOther` answers no.
44
39
  */
45
40
  export class TryRegion extends ArmRegion {
46
41
  readonly failure: Reactive<unknown>
@@ -114,17 +109,13 @@ export class TryRegion extends ArmRegion {
114
109
  * `again` IS WHAT A RETRY RERUNS: the binding that threw, or the body mounted again.
115
110
  */
116
111
  guard<T>(run: () => T, again: () => unknown): T | undefined {
117
- const previous = BOUNDARY.current
118
- BOUNDARY.current = this
119
112
  try {
120
- return run()
113
+ return underBoundary(this, run)
121
114
  } catch (thrown) {
122
115
  if (thrown instanceof Mismatch || thrown instanceof NotLanded)
123
116
  throw thrown
124
117
  this.fail(thrown, again)
125
118
  return undefined
126
- } finally {
127
- BOUNDARY.current = previous
128
119
  }
129
120
  }
130
121
 
@@ -139,11 +130,9 @@ export class TryRegion extends ArmRegion {
139
130
  const scope = this.scope as Scope
140
131
  let next = first
141
132
  while (next !== this.at) {
142
- const painted = this.at === undefined && !isEmptyRegion(region)
133
+ const filling = this.fills(next)
143
134
  this.at = next
144
- const arm = (this.arms[next as number] as ArmMakers[number])(
145
- this.failure.peek(),
146
- )
135
+ const arm = this.armOf(next)
147
136
  const standing = next === 0 ? this.standing : undefined
148
137
  if (standing !== undefined) {
149
138
  this.standing = undefined
@@ -151,7 +140,7 @@ export class TryRegion extends ArmRegion {
151
140
  scope.own(standing)
152
141
  continue
153
142
  }
154
- if (!painted) {
143
+ if (filling) {
155
144
  scope.stop()
156
145
  if (
157
146
  next === 0 &&
@@ -175,6 +164,17 @@ export class TryRegion extends ArmRegion {
175
164
  }
176
165
  }
177
166
 
167
+ /** The catch is handed the failure it binds. */
168
+ override armOf(next: unknown): Arm {
169
+ return (this.arms[next as number] as ArmMakers[number])(
170
+ this.failure.peek(),
171
+ )
172
+ }
173
+
174
+ override paintedOther(): boolean {
175
+ return false
176
+ }
177
+
178
178
  private fail(error: unknown, again: () => unknown): void {
179
179
  if (this.trying) {
180
180
  if (this.triedFailed) return
@@ -238,14 +238,14 @@ export class TryRegion extends ArmRegion {
238
238
  open: region.close.previousSibling as Comment,
239
239
  close: region.close,
240
240
  }
241
- const previous = BOUNDARY.current
242
- BOUNDARY.current = this
243
241
  this.trying = true
244
242
  this.triedFailed = false
245
243
  let mounted: Stoppable | undefined
246
244
  try {
247
- fillRegion(body, arm.build)
248
- mounted = arm.mount(body)
245
+ mounted = underBoundary(this, () => {
246
+ fillRegion(body, arm.build)
247
+ return arm.mount(body)
248
+ })
249
249
  } catch (thrown) {
250
250
  fillRegion(body)
251
251
  if (thrown instanceof Mismatch || thrown instanceof NotLanded)
@@ -254,7 +254,6 @@ export class TryRegion extends ArmRegion {
254
254
  this.triedError = thrown
255
255
  this.triedAgain = undefined
256
256
  } finally {
257
- BOUNDARY.current = previous
258
257
  this.trying = false
259
258
  }
260
259
  if (!this.triedFailed) {
@@ -5,7 +5,7 @@
5
5
  import { spreadEntries } from '#shared/spread.ts'
6
6
 
7
7
  import type { Scope } from './Scope.ts'
8
- import { addAttribute } from './setAttribute.ts'
8
+ import { writeAttribute } from './setAttribute.ts'
9
9
 
10
10
  /** Keeps an element's spread attributes and `on<event>` listeners written as the value changes. */
11
11
  export function bindSpread(
@@ -21,9 +21,7 @@ export function bindSpread(
21
21
  const next = new Set<string>()
22
22
  for (const [name, text] of spreadEntries(read(), explicit, wanted)) {
23
23
  next.add(name)
24
- const standing = element.getAttribute(name)
25
- if (standing === null) addAttribute(element, name, text)
26
- else if (standing !== text) element.setAttribute(name, text)
24
+ writeAttribute(element, name, text)
27
25
  }
28
26
  for (const name of written)
29
27
  if (!next.has(name)) element.removeAttribute(name)
@@ -29,11 +29,8 @@ export function listenForRoutes(router: Router): () => void {
29
29
  if (url !== undefined) router.prefetch(url.pathname)
30
30
  }
31
31
 
32
- // BACK AND FORWARD HAVE ALREADY MOVED THE ADDRESS BAR by the time this runs, so the page is
33
- // mounted where the bar already points and nothing is pushed for it. 30.20 — a step within the
34
- // standing page, back over an in-page `#anchor` jump, is `go`'s to keep: nothing asked, nothing
35
- // remounted. 30.29 — the scroll is the router's to put back, once the page it belongs to is
36
- // mounted: the browser's own restoration runs before that page exists.
32
+ // BACK AND FORWARD HAVE ALREADY MOVED THE ADDRESS BAR by the time this runs (`popped`). 30.29 —
33
+ // the browser's own scroll restoration runs before the page it restores exists, so it is off.
37
34
  const onPop = (): void => {
38
35
  if (router.match(location.pathname) === undefined) {
39
36
  // The bar already stands at the entry being loaded, so the `pagehide` this reload
@@ -42,11 +39,7 @@ export function listenForRoutes(router: Router): () => void {
42
39
  location.reload()
43
40
  return
44
41
  }
45
- void router.go(
46
- location.href,
47
- { replace: true, keepScroll: true },
48
- router.stepped(),
49
- )
42
+ void router.popped()
50
43
  }
51
44
 
52
45
  const listening = new AbortController()
@@ -69,9 +69,9 @@ let askedToPersist = false
69
69
  */
70
70
  const VALUES = new Map<string, Reactive<unknown>>()
71
71
  /** What wants to hear the line empty of calls not yet delivered (18.35). */
72
- const DRAINED: (() => void)[] = []
72
+ const DRAINED = new Set<() => void>()
73
73
 
74
- const shelf: Shelf =
74
+ let shelf: Shelf =
75
75
  typeof indexedDB === 'undefined' ? memoryShelf() : databaseShelf()
76
76
  const channel =
77
77
  typeof BroadcastChannel === 'undefined'
@@ -87,7 +87,25 @@ export function undelivered(): number {
87
87
 
88
88
  /** Runs `listener` each time the line holds no call waiting to be delivered (18.35). */
89
89
  export function whenDelivered(listener: () => void): void {
90
- DRAINED.push(listener)
90
+ DRAINED.add(listener)
91
+ }
92
+
93
+ /**
94
+ * Empties the line and its timers onto a fresh in-memory shelf.
95
+ *
96
+ * A page never does; a test does between cases, rather than discarding a call at a time and waiting
97
+ * out each discard's write.
98
+ */
99
+ export function resetOutbox(): void {
100
+ clearTimeout(retrying)
101
+ retrying = undefined
102
+ backoff = RETRY_FLOOR_MS
103
+ held = false
104
+ waiting = false
105
+ LINE = []
106
+ VALUES.clear()
107
+ shelf = memoryShelf()
108
+ publish()
91
109
  }
92
110
 
93
111
  /**
@@ -1,4 +1,4 @@
1
- import { PATCHES } from '#shared/reactive/PATCHES.ts'
1
+ import { patchEpoch, writtenSince } from '#shared/reactive/patchMarks.ts'
2
2
 
3
3
  import { LAYER_RECONCILE } from './LAYER_NAMES.ts'
4
4
  import type { List } from './List.ts'
@@ -116,7 +116,7 @@ export function reconcile(list: List, data: unknown[]): void {
116
116
  const value = data[at]
117
117
  if (!Object.is(existing.data, value) || patchedSince(existing))
118
118
  updateRow(existing, value)
119
- existing.seen = PATCHES.epoch
119
+ existing.seen = patchEpoch()
120
120
  survivor[at] = existing
121
121
  from[at] = existing.at
122
122
  if (existing.at < lastSurvivor) ordered = false
@@ -210,10 +210,8 @@ function clear(list: List): void {
210
210
  * one integer compare a row and never reaches the map.
211
211
  */
212
212
  function patchedSince(row: Row): boolean {
213
- if (row.seen === PATCHES.epoch) return false
214
- const item = row.data
215
- if (item === null || typeof item !== 'object') return false
216
- return (PATCHES.written.get(item) ?? 0) > row.seen
213
+ if (row.seen === patchEpoch()) return false
214
+ return writtenSince(row.data, row.seen)
217
215
  }
218
216
 
219
217
  /**