@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
@@ -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()
@@ -19,14 +19,8 @@ import { failedFrom, isFailed, problemOf } from '#shared/Failed.ts'
19
19
  import type { Problem } from '#shared/Failed.ts'
20
20
  import { showLine, type ShownCall, showSending } from '#shared/outbox.ts'
21
21
  import { onPrincipalRotated } from '#shared/principalRotated.ts'
22
- import {
23
- isReactive,
24
- produceInto,
25
- type Reactive,
26
- reactive,
27
- refuseInto,
28
- standIn,
29
- } from '#shared/reactive/index.ts'
22
+ import { isReactive, type Reactive, reactive } from '#shared/reactive/index.ts'
23
+ import { produceInto, refuseInto, standIn } from '#shared/reactive/producer.ts'
30
24
  import { rpcRequest } from '#shared/rpcRequest.ts'
31
25
 
32
26
  const DATABASE = 'abide-outbox'
@@ -69,9 +63,9 @@ let askedToPersist = false
69
63
  */
70
64
  const VALUES = new Map<string, Reactive<unknown>>()
71
65
  /** What wants to hear the line empty of calls not yet delivered (18.35). */
72
- const DRAINED: (() => void)[] = []
66
+ const DRAINED = new Set<() => void>()
73
67
 
74
- const shelf: Shelf =
68
+ let shelf: Shelf =
75
69
  typeof indexedDB === 'undefined' ? memoryShelf() : databaseShelf()
76
70
  const channel =
77
71
  typeof BroadcastChannel === 'undefined'
@@ -87,7 +81,25 @@ export function undelivered(): number {
87
81
 
88
82
  /** Runs `listener` each time the line holds no call waiting to be delivered (18.35). */
89
83
  export function whenDelivered(listener: () => void): void {
90
- DRAINED.push(listener)
84
+ DRAINED.add(listener)
85
+ }
86
+
87
+ /**
88
+ * Empties the line and its timers onto a fresh in-memory shelf.
89
+ *
90
+ * A page never does; a test does between cases, rather than discarding a call at a time and waiting
91
+ * out each discard's write.
92
+ */
93
+ export function resetOutbox(): void {
94
+ clearTimeout(retrying)
95
+ retrying = undefined
96
+ backoff = RETRY_FLOOR_MS
97
+ held = false
98
+ waiting = false
99
+ LINE = []
100
+ VALUES.clear()
101
+ shelf = memoryShelf()
102
+ publish()
91
103
  }
92
104
 
93
105
  /**
@@ -0,0 +1,120 @@
1
+ import type { Params } from '#shared/matchRoute.ts'
2
+
3
+ /** A route an address matched, and the params it matched with. */
4
+ export type Found<RouteRow> = { route: RouteRow; params: Params }
5
+
6
+ /**
7
+ * What a navigation knows before it asks anything: where it is going, and what stands.
8
+ *
9
+ * `match` is the router's own, so 16.10's mount and 30.9's ranking are decided where they always
10
+ * were.
11
+ */
12
+ export type Bearing<RouteRow> = {
13
+ target: URL
14
+ /** The document's origin, which an address outside of is no route (30.10). */
15
+ origin: string
16
+ /** 35.18 — the document is an error page's, so leaving it is a document load. */
17
+ errorPage: boolean
18
+ /** The route the standing page was matched by. */
19
+ standing: RouteRow | undefined
20
+ /** The path the standing chain was mounted for. */
21
+ mounted: string | undefined
22
+ match: (pathname: string) => Found<RouteRow> | undefined
23
+ }
24
+
25
+ /** What the ask and the load came back with (30.15). */
26
+ export type Heard<RouteRow> = {
27
+ /** The route the ask was made for — the `ask` outcome's `found`. */
28
+ found: Found<RouteRow>
29
+ /** The server's answer; `undefined` where the fetch failed. */
30
+ answer: { status: number; url: string; redirected: boolean } | undefined
31
+ /** Whether the asked-for route's chunks loaded. */
32
+ loaded: boolean
33
+ online: boolean
34
+ /** Whether the navigation pushes, rather than arriving at a history step the bar already took. */
35
+ pushed: boolean
36
+ }
37
+
38
+ /**
39
+ * How a navigation ends.
40
+ *
41
+ * `document` hands `href` to a document load. `follow` moves the address within the standing page.
42
+ * `ask` is the first call's answer where the server must be asked first. `offline` is 30.32's: the
43
+ * page stays, unless the service worker saved `href` (45.12). `mount` mounts `found` at `at`.
44
+ */
45
+ type Outcome<RouteRow> =
46
+ | Settled<RouteRow>
47
+ | { kind: 'ask'; found: Found<RouteRow> }
48
+ | { kind: 'offline'; href: string }
49
+ | { kind: 'mount'; at: URL; found: Found<RouteRow> }
50
+
51
+ /** An outcome that loads nothing, which either call can answer. */
52
+ export type Settled<RouteRow> =
53
+ | { kind: 'document'; href: string }
54
+ | { kind: 'follow'; at: URL; found: Found<RouteRow> }
55
+
56
+ /**
57
+ * Decides how a navigation ends, from what it knows: called once before the ask, and once more
58
+ * with what was `heard` where the first call answered `ask`.
59
+ *
60
+ * Decides and touches nothing — the router executes the answer. A `mount` whose `found` is not
61
+ * `heard.found` lands on a route whose chunks were not loaded yet, and the router hands that
62
+ * address to the document where they will not load (30.27).
63
+ */
64
+ export function outcomeOf<RouteRow>(
65
+ bearing: Bearing<RouteRow>,
66
+ ): Settled<RouteRow> | Extract<Outcome<RouteRow>, { kind: 'ask' }>
67
+ export function outcomeOf<RouteRow>(
68
+ bearing: Bearing<RouteRow>,
69
+ heard: Heard<RouteRow>,
70
+ ): Settled<RouteRow> | Extract<Outcome<RouteRow>, { kind: 'offline' | 'mount' }>
71
+ export function outcomeOf<RouteRow>(
72
+ bearing: Bearing<RouteRow>,
73
+ heard?: Heard<RouteRow>,
74
+ ): Outcome<RouteRow> {
75
+ const target = bearing.target
76
+ if (heard === undefined) {
77
+ // A CROSS-ORIGIN ADDRESS IS NOT A ROUTE. Handing it to `pushState` throws, and treating
78
+ // it as a miss would leave the caller with a navigation that silently did nothing.
79
+ if (target.origin !== bearing.origin || bearing.errorPage)
80
+ return { kind: 'document', href: target.href }
81
+ const found = bearing.match(target.pathname)
82
+ // 30.10 — NOT A ROUTE THIS BUILD KNOWS, so it is the server's to answer — which is a
83
+ // document load, and the one case where 30.7 does not apply because there is no route.
84
+ if (found === undefined) return { kind: 'document', href: target.href }
85
+ // 30.21, 30.23, 30.24 — THE ROUTE STANDING, AT ITS OWN PATH: another query, another
86
+ // fragment or the same address. The page reads its address through `route`, so it follows
87
+ // rather than being rebuilt, and there is no path for `middleware` to decide anything new
88
+ // about.
89
+ if (
90
+ found.route === bearing.standing &&
91
+ bearing.mounted === target.pathname
92
+ )
93
+ return { kind: 'follow', at: target, found }
94
+ return { kind: 'ask', found }
95
+ }
96
+ const answer = heard.answer
97
+ // 30.32 — OFFLINE, THE DOCUMENT LOAD BELOW CAN ONLY PAINT THE BROWSER'S ERROR PAGE over a page
98
+ // that works. A history step has already moved the bar, so it is left to the document.
99
+ if (answer === undefined && !heard.online && heard.pushed)
100
+ return { kind: 'offline', href: target.href }
101
+ // 30.18 — A REFUSAL, A RESPONSE MIDDLEWARE ANSWERED ITSELF, or no answer at all: the document
102
+ // asks again and paints whatever the server says. A fetch that failed — offline, or a redirect
103
+ // to another origin that CORS will not let it follow — goes to the address it was sent to.
104
+ // 30.27 — AND CHUNKS THAT WOULD NOT LOAD, with the server down or a deploy that removed them.
105
+ if (answer?.status !== 204 || !heard.loaded)
106
+ return { kind: 'document', href: answer?.url ?? target.href }
107
+ // 30.17 — REDIRECTED, and the server already said yes to where it landed: the answer is the
108
+ // 204 at the end of the chain. That address is mounted in place of the one asked for, and a
109
+ // route the client has no row for is the document's (30.10).
110
+ const landed = answer.redirected ? new URL(answer.url) : target
111
+ const landing = answer.redirected
112
+ ? bearing.match(landed.pathname)
113
+ : heard.found
114
+ if (landing === undefined) return { kind: 'document', href: landed.href }
115
+ // 30.23 — THE ROUTE STANDING, AT ANOTHER PATH: `middleware` has said yes to the path, and the
116
+ // page it matched is the one already mounted, so it follows.
117
+ if (landing.route === bearing.standing)
118
+ return { kind: 'follow', at: landed, found: landing }
119
+ return { kind: 'mount', at: landed, found: landing }
120
+ }
@@ -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
  /**
package/src/ui/routing.ts CHANGED
@@ -16,4 +16,5 @@
16
16
 
17
17
  export { listenForRoutes } from './listenForRoutes.ts'
18
18
  export { registerWorker } from './registerWorker.ts'
19
- export { type PageMount, type Route, Router, startRouter } from './Router.ts'
19
+ export { type PageMount } from './LevelChain.ts'
20
+ export { type Route, Router, startRouter } from './Router.ts'
package/src/ui/rows.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { REGION_CLOSE, ROW_OPEN } from '#shared/ANCHORS.ts'
2
2
  import { moveItem, rowItem } from '#shared/reactive/index.ts'
3
- import { PATCHES } from '#shared/reactive/PATCHES.ts'
3
+ import { patchEpoch } from '#shared/reactive/patchMarks.ts'
4
4
 
5
5
  import { leave } from './leave.ts'
6
6
  import { moveNode } from './moveNode.ts'
@@ -291,7 +291,7 @@ function newRow(key: unknown, data: unknown, node: ChildNode, at: number): Row {
291
291
  // for the record's whole life.
292
292
  scope: EMPTY_SCOPE,
293
293
  at,
294
- seen: PATCHES.epoch,
294
+ seen: patchEpoch(),
295
295
  }
296
296
  }
297
297
 
@@ -35,10 +35,19 @@ export function setAttribute(
35
35
  if (element.hasAttribute(name)) element.removeAttribute(name)
36
36
  return
37
37
  }
38
+ writeAttribute(element, name, next)
39
+ }
40
+
41
+ /** Writes `text` to `element`'s `name` unless it already reads `text` — `setAttribute`'s compare, shared with a spread. */
42
+ export function writeAttribute(
43
+ element: Element,
44
+ name: string,
45
+ text: string,
46
+ ): void {
38
47
  const standing = element.getAttribute(name)
39
- if (standing === next) return
40
- if (standing === null) addAttribute(element, name, next)
41
- else element.setAttribute(name, next)
48
+ if (standing === text) return
49
+ if (standing === null) addAttribute(element, name, text)
50
+ else element.setAttribute(name, text)
42
51
  }
43
52
 
44
53
  /**
@@ -7,16 +7,16 @@ import { applyPatch } from '#shared/applyPatch.ts'
7
7
  import { mounted } from '#shared/mount.ts'
8
8
  import type { BrowserFrame, ServerFrame } from '#shared/muxFrames.ts'
9
9
  import { onPrincipalRotated } from '#shared/principalRotated.ts'
10
- import { canonicalKey } from '#shared/reactive/canonicalKey.ts'
10
+ import { canonicalKey } from '#shared/reactive/argsContract.ts'
11
+ import { type Reactive } from '#shared/reactive/index.ts'
12
+ import { patchEpoch, writtenSince } from '#shared/reactive/patchMarks.ts'
11
13
  import {
12
14
  heldGenerationOf,
13
15
  loadOf,
14
16
  patchInto,
15
17
  produceInto,
16
- type Reactive,
17
18
  writtenLocally,
18
- } from '#shared/reactive/index.ts'
19
- import { PATCHES } from '#shared/reactive/PATCHES.ts'
19
+ } from '#shared/reactive/producer.ts'
20
20
 
21
21
  import { HYDRATE_LOG } from './Mismatch.ts'
22
22
  import { undelivered, whenDelivered } from './outboxQueue.ts'
@@ -64,18 +64,13 @@ class Following {
64
64
  inStep(): void {
65
65
  const copy = this.entry()
66
66
  this.generation = loadOf(copy) ?? heldGenerationOf(copy)
67
- this.epoch = PATCHES.epoch
67
+ this.epoch = patchEpoch()
68
68
  }
69
69
 
70
70
  /** Whether the copy was written to since it was last in step. 18.21. */
71
71
  diverged(copy: unknown): boolean {
72
72
  if (heldGenerationOf(copy) !== this.generation) return true
73
- const held = (copy as Reactive<unknown>).peek()
74
- return (
75
- held !== null &&
76
- typeof held === 'object' &&
77
- (PATCHES.written.get(held) ?? 0) > this.epoch
78
- )
73
+ return writtenSince((copy as Reactive<unknown>).peek(), this.epoch)
79
74
  }
80
75
 
81
76
  /**
@@ -241,10 +236,17 @@ class Mux {
241
236
  receive(frame: ServerFrame): void {
242
237
  this.subscriptions.get(frame.id)?.receive(frame)
243
238
  }
239
+
240
+ /** Lets go of every subscription and the socket, so a sign-in or a retry reaches nothing. */
241
+ dispose(): void {
242
+ clearTimeout(this.retrying)
243
+ this.retrying = undefined
244
+ this.subscriptions.clear()
245
+ this.reopen()
246
+ }
244
247
  }
245
248
 
246
- // Exported for a test to forget: a page never does, and a test process outlives one.
247
- export const MUXES = new Map<string, Mux>()
249
+ const MUXES = new Map<string, Mux>()
248
250
 
249
251
  /**
250
252
  * The mux a handler's address is carried over, made on first use.
@@ -276,6 +278,19 @@ function muxFor(address: string): Mux {
276
278
  */
277
279
  const FOLLOWING = new Map<string, Following>()
278
280
 
281
+ /**
282
+ * Forgets every mux and follow this page opened, their timers and sockets with them.
283
+ *
284
+ * A page never does; a test process outlives one, and a later file's sign-in (18.25) would reopen
285
+ * every mux an earlier file left standing.
286
+ */
287
+ export function resetSocketWire(): void {
288
+ for (const following of FOLLOWING.values()) clearTimeout(following.expiry)
289
+ FOLLOWING.clear()
290
+ for (const mux of MUXES.values()) mux.dispose()
291
+ MUXES.clear()
292
+ }
293
+
279
294
  /**
280
295
  * Reads again every followed copy written here, once the outbox holds no call waiting.
281
296
  *
package/src/ui/wire.ts CHANGED
@@ -29,13 +29,13 @@ import { isFailed } from '#shared/Failed.ts'
29
29
  import { answerChunks } from '#shared/framing.ts'
30
30
  import { HandlerRoute } from '#shared/HandlerRoute.ts'
31
31
  import type { JsonValue } from '#shared/JsonValue.ts'
32
- import { LOAD_OFFLINE } from '#shared/LOAD_OFFLINE.ts'
33
- import { LOAD_SIGNAL } from '#shared/LOAD_SIGNAL.ts'
34
- import { reactive, standIn, structural } from '#shared/reactive/index.ts'
32
+ import { standForLoadCall, startingLoadCall } from '#shared/loadCall.ts'
33
+ import { reactive, structural } from '#shared/reactive/index.ts'
35
34
  import {
36
35
  declareDefaults,
37
36
  type Defaults,
38
37
  } from '#shared/reactive/parameterDefaults.ts'
38
+ import { standIn } from '#shared/reactive/producer.ts'
39
39
  import { declare, type Method } from '#shared/rpc.ts'
40
40
  import { type RpcRequest, rpcRequest } from '#shared/rpcRequest.ts'
41
41
 
@@ -72,11 +72,11 @@ export function wire(
72
72
  // ONE PARAMETER, so the value is keyed by args whatever the handler declared (16.6's arity rule):
73
73
  // an unkeyed handler is reached with no args, which is one key.
74
74
  const body = (args: Record<string, unknown> | undefined) => {
75
- // 16.49, 16.81 — the load's own signal and stand-in, set by the call starting it and read
76
- // before anything awaits.
77
- const signal = LOAD_SIGNAL.current
75
+ // 16.49, 16.81 — the load's own signal and stand-in, as the call starting it carried them.
76
+ const starting = startingLoadCall()
77
+ const signal = starting.signal
78
78
  const request = requestOf(args)
79
- let offline = LOAD_OFFLINE.current
79
+ let offline = starting.offline
80
80
  // A reload no call started — a refresh, or the one `whenOnline` asks — that gets no answer
81
81
  // waits before asking again, where asking at once would get none on every tick.
82
82
  const retryIn = offline === undefined ? UNANSWERED_RETRY_MS : 0
@@ -93,8 +93,7 @@ export function wire(
93
93
  ? queuedCall(request, address, args)
94
94
  : undefined
95
95
  let standing: { value: unknown } | undefined
96
- if (queued !== undefined)
97
- LOAD_OFFLINE.standing = standing = { value: undefined }
96
+ if (queued !== undefined) standing = standForLoadCall()
98
97
  const stoodIn = (failed: Answer): unknown => {
99
98
  const value = (offline as (args: unknown) => unknown)(args)
100
99
  if (queued === undefined) {
@@ -145,7 +144,7 @@ export function wire(
145
144
  ): AsyncGenerator<unknown> {
146
145
  // 16.49 — read on the first line: the core's pump takes the first step inside the reach, so
147
146
  // this runs while the call that started the load still has its signal set.
148
- const signal = LOAD_SIGNAL.current
147
+ const signal = startingLoadCall().signal
149
148
  const request = requestOf(args)
150
149
  const answer = await answerOf(request, true, signal)
151
150
  if (answer.ok) answeredOnce.add(request.key)
@@ -1,2 +0,0 @@
1
- /** A warning or a refusal at a projection offset. */
2
- export type Finding = { at: number; message: string }
@@ -1,65 +0,0 @@
1
- import { resolve } from 'node:path' // bun exposes no path helpers of its own
2
-
3
- import { formatDiagnosis } from './formatDiagnosis.ts'
4
- import { diagnosisAt } from './lineOf.ts'
5
- import { PositionedRefusal } from './PositionedRefusal.ts'
6
- import type { Diagnosis } from './TypeSession.ts'
7
-
8
- /**
9
- * Where everything the compile refuses goes, each at the file it was refused in.
10
- *
11
- * TWO MODES AND ONE CALL: a build stops at a refusal, so a throwing sink throws it; `abide
12
- * check` reports every one at its file and goes on (38.33), so a collecting sink keeps each as a
13
- * finding at the file's first line. Every stage takes this and nothing else, so no stage decides for
14
- * itself what a refusal does, and none spells a path its own way: a file is named relative to `root`
15
- * or absolutely, and kept absolute.
16
- */
17
- export class Refusals {
18
- /** Every refusal kept, in the order met; empty in a throwing sink. */
19
- readonly found: Diagnosis[] = []
20
- /** What a file below the root is named relative to. */
21
- readonly root: string
22
- /** Whether a refusal is kept and the compile goes on, rather than thrown. */
23
- readonly collecting: boolean
24
-
25
- constructor(root: string, collecting: boolean) {
26
- this.root = root
27
- this.collecting = collecting
28
- }
29
-
30
- /**
31
- * Refuses at `file`, below the root or absolute: `thrown` is thrown, or kept. Handed the file's
32
- * `text`, a `PositionedRefusal` is placed on its line and column, and thrown as that line.
33
- */
34
- at(file: string, thrown: unknown, text?: string): void {
35
- const positioned =
36
- text !== undefined && thrown instanceof PositionedRefusal
37
- if (!this.collecting && !positioned) throw thrown
38
- const diagnosis = diagnosisAt(
39
- resolve(this.root, file),
40
- positioned ? text : undefined,
41
- positioned ? thrown.offset : 0,
42
- 'error',
43
- thrown instanceof Error ? thrown.message : String(thrown),
44
- )
45
- if (!this.collecting)
46
- throw new Error(formatDiagnosis(diagnosis, process.cwd()))
47
- this.found.push(diagnosis)
48
- }
49
-
50
- /**
51
- * What another stage kept, as this one takes it: every one kept, or every one thrown as one
52
- * error, each at its file — a build that stops names all it found rather than a first one and a
53
- * second run (11.37).
54
- */
55
- adopt(found: readonly Diagnosis[]): void {
56
- if (found.length === 0) return
57
- if (this.collecting) {
58
- this.found.push(...found)
59
- return
60
- }
61
- const lines: string[] = []
62
- for (const one of found) lines.push(formatDiagnosis(one, process.cwd()))
63
- throw new Error(lines.join('\n'))
64
- }
65
- }
@@ -1,20 +0,0 @@
1
- import { relative } from 'node:path' // bun exposes no path helpers of its own
2
-
3
- import type { Diagnosis } from './TypeSession.ts'
4
-
5
- /**
6
- * One finding as one `tsc`-style line.
7
- *
8
- * ONE LINE A FINDING, in the shape `tsc` and every editor's problem matcher already read:
9
- * `file:line:column - error TS2322: …`, the path relative to where the command was run. A finding
10
- * with no line — a whole-file or whole-app refusal — names the file alone.
11
- */
12
- export function formatDiagnosis(diagnosis: Diagnosis, from: string): string {
13
- const path = relative(from, diagnosis.path) || diagnosis.path
14
- const where =
15
- diagnosis.line === 0
16
- ? path
17
- : `${path}:${diagnosis.line}:${diagnosis.column}`
18
- const code = diagnosis.code === 0 ? '' : ` TS${diagnosis.code}`
19
- return `${where} - ${diagnosis.category}${code}: ${diagnosis.message}`
20
- }
@@ -1,9 +0,0 @@
1
- /**
2
- * The directories an app's own files stand in: `root`, and the one above it.
3
- *
4
- * `root` is the app's root — `src/` — and the one above it is the package beside
5
- * it. The app's `package.json` and `bunfig.toml` are found in the first that has one.
6
- */
7
- export function appDirectories(root: string): string[] {
8
- return [root, `${root}/..`]
9
- }