@abide/abide 0.110.4 → 0.112.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 (55) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/docs/REGISTRY.md +30 -29
  3. package/docs/RULEBOOK.md +98 -22
  4. package/docs/abide.md +46 -32
  5. package/package.json +1 -1
  6. package/src/compiler/Lowering.ts +5 -0
  7. package/src/compiler/classifyTemplate.ts +129 -34
  8. package/src/compiler/compileApp.ts +2 -1
  9. package/src/compiler/emitClient.ts +128 -14
  10. package/src/compiler/renderMarkup.ts +14 -8
  11. package/src/server/BRIDGE_VERSION.ts +1 -1
  12. package/src/server/HEARTBEAT_MILLISECONDS.ts +2 -0
  13. package/src/server/IDLE_TIMEOUT_SECONDS.ts +6 -0
  14. package/src/server/answerRequest.ts +44 -3
  15. package/src/server/answerRpc.ts +14 -41
  16. package/src/server/answerSocket.ts +2 -2
  17. package/src/server/answerValue.ts +15 -3
  18. package/src/server/answerWorker.ts +195 -0
  19. package/src/server/appManifest.ts +48 -0
  20. package/src/server/assistant.ts +1 -1
  21. package/src/server/bridgeRelay.ts +4 -50
  22. package/src/server/claudeApi.ts +1 -1
  23. package/src/server/claudeCode.ts +3 -3
  24. package/src/server/clientBundle.ts +7 -0
  25. package/src/server/config.ts +14 -0
  26. package/src/server/createApp.ts +28 -4
  27. package/src/server/devWorker.ts +4 -1
  28. package/src/server/render.ts +2 -2
  29. package/src/server/requestScope.ts +31 -11
  30. package/src/server/responseRelay.ts +7 -7
  31. package/src/server/serveBuild.ts +2 -0
  32. package/src/server/serveDev.ts +4 -1
  33. package/src/server/streamEvent.ts +60 -1
  34. package/src/shared/ADDRESSES.ts +11 -0
  35. package/src/shared/ASCII_WHITESPACE.ts +6 -0
  36. package/src/shared/LONGEST_DELAY.ts +2 -0
  37. package/src/shared/OFFLINE_CACHES.ts +12 -0
  38. package/src/shared/OFFLINE_META.ts +7 -0
  39. package/src/shared/answerOf.ts +29 -4
  40. package/src/shared/attributeText.ts +1 -1
  41. package/src/shared/reactive/Expiry.ts +13 -1
  42. package/src/shared/reactive/Reactive.ts +13 -3
  43. package/src/shared/safeUrl.ts +8 -1
  44. package/src/ui/Level.ts +43 -95
  45. package/src/ui/Router.ts +37 -13
  46. package/src/ui/bindGroup.ts +41 -0
  47. package/src/ui/bindProperty.ts +1 -21
  48. package/src/ui/index.ts +5 -3
  49. package/src/ui/reconcileHead.ts +215 -0
  50. package/src/ui/registerWorker.ts +34 -0
  51. package/src/ui/routing.ts +1 -0
  52. package/src/ui/setClassGroup.ts +79 -0
  53. package/src/ui/setStyleGroup.ts +130 -0
  54. package/src/ui/socketWire.ts +33 -2
  55. package/src/ui/applyHead.ts +0 -214
@@ -25,7 +25,7 @@ export type ToRelay =
25
25
  | { kind: 'pull'; id: number }
26
26
  | { kind: 'cancel'; id: number }
27
27
 
28
- /** What the worker's half sends: a request's idle timeout raised, and its head, each chunk, and its end. */
28
+ /** What the worker's half sends: a request's idle timeout set, and its head, each chunk, and its end. */
29
29
  export type FromRelay =
30
30
  | { kind: 'timeout'; id: number; seconds: number }
31
31
  | {
@@ -56,8 +56,8 @@ export function describe(thrown: unknown): string {
56
56
 
57
57
  /**
58
58
  * The worker's half: answers each request the listener sends with `fetch`, and its body a chunk per
59
- * pull. `take` is handed every `ToRelay` message; `timeout` raises a request's idle timeout where the
60
- * listener holds its connection (16.22); `hangUpAll` aborts every request still being answered.
59
+ * pull. `take` is handed every `ToRelay` message; `timeout` sets a request's idle timeout where the
60
+ * listener holds its connection (16.23); `hangUpAll` aborts every request still being answered.
61
61
  */
62
62
  export function serveOver(
63
63
  channel: Channel<FromRelay>,
@@ -80,7 +80,7 @@ export function serveOver(
80
80
  number,
81
81
  { reader: ReadableStreamDefaultReader<Uint8Array>; html: boolean }
82
82
  >()
83
- // 16.22 — each request built here, known by the ask it was built from.
83
+ // 16.23 — each request built here, known by the ask it was built from.
84
84
  const asks = new WeakMap<Request, number>()
85
85
 
86
86
  const ended = (id: number): void => {
@@ -203,7 +203,7 @@ export function serveOver(
203
203
  // and, once its head has come back, the stream the body is fed into and the pull waiting on the
204
204
  // worker's next chunk (38.35).
205
205
  type InFlight = {
206
- // 16.22 — the request bun handed the listener, which is the one its idle timeout is raised on.
206
+ // 16.23 — the request bun handed the listener, which is the one its idle timeout is set on.
207
207
  request: Request
208
208
  headers: [string, string][]
209
209
  body: ArrayBuffer | null
@@ -223,7 +223,7 @@ type InFlight = {
223
223
  * compiles; `endAll` ends what the worker that is gone was answering.
224
224
  */
225
225
  export function askOver(
226
- raiseTimeout: (request: Request, seconds: number) => void,
226
+ setIdleTimeout: (request: Request, seconds: number) => void,
227
227
  ) {
228
228
  let channel: Channel<ToRelay> | undefined
229
229
  const inFlight = new Map<number, InFlight>()
@@ -334,7 +334,7 @@ export function askOver(
334
334
  const waiting = inFlight.get(message.id)
335
335
  if (waiting === undefined) return
336
336
  if (message.kind === 'timeout') {
337
- raiseTimeout(waiting.request, message.seconds)
337
+ setIdleTimeout(waiting.request, message.seconds)
338
338
  return
339
339
  }
340
340
  if (message.kind === 'head') {
@@ -24,6 +24,7 @@ import {
24
24
  PUBLIC_DIRECTORY,
25
25
  } from './CLIENT_BUILD.ts'
26
26
  import { bundleServer, type ServedFile } from './clientBundle.ts'
27
+ import { settingOn } from './config.ts'
27
28
  import type { Manifest } from './manifestOf.ts'
28
29
  import { namesUnder } from './namesUnder.ts'
29
30
  import { ENCODINGS } from './negotiateEncoding.ts'
@@ -137,6 +138,7 @@ export function builtSources(parts: BuildParts): Promise<AppSources> {
137
138
  files,
138
139
  'public, max-age=31536000, immutable',
139
140
  ),
141
+ offline: settingOn('ABIDE_OFFLINE', true),
140
142
  },
141
143
  })
142
144
  }
@@ -31,6 +31,7 @@ import { DEV_ADDRESS } from '#shared/ADDRESSES.ts'
31
31
  import type { Compilation } from './abideModules.ts'
32
32
  import { DEFAULT_PORT } from './DEFAULT_PORT.ts'
33
33
  import type { FromWorker, ToWorker } from './devWorker.ts'
34
+ import { IDLE_TIMEOUT_SECONDS } from './IDLE_TIMEOUT_SECONDS.ts'
34
35
  import { muxSocketOptions, sendOrClose, upgraded } from './muxUpgrade.ts'
35
36
  import { placeOf } from './placement.ts'
36
37
  import { plainAnswer } from './plainAnswer.ts'
@@ -77,7 +78,7 @@ export async function serveDev(
77
78
  options: DevOptions,
78
79
  ): Promise<Bun.Server<undefined>> {
79
80
  // 38.24, 38.35 — every request, asked of the worker there is and streamed back a chunk per read.
80
- // 16.22 — a call's idle timeout raised on the request bun handed this listener.
81
+ // 16.23 — a request's idle timeout set on the request bun handed this listener.
81
82
  const relay = askOver((request, seconds) =>
82
83
  served?.timeout(request, seconds),
83
84
  )
@@ -302,6 +303,8 @@ export async function serveDev(
302
303
  ...(port === undefined ? {} : { port }),
303
304
  // 16.54, as `App.listen` lifts it: the worker behind this is where the ceiling is read.
304
305
  maxRequestBodySize: Number.MAX_SAFE_INTEGER,
306
+ // 16.56 — the bound a request received whole lifts and its answer puts back (16.23).
307
+ idleTimeout: IDLE_TIMEOUT_SECONDS,
305
308
  async fetch(request, listening) {
306
309
  const asked = new URL(request.url).pathname
307
310
  // THE MUX STANDS UNDER THE MOUNT, which is the app's configuration and so the worker's to
@@ -5,9 +5,68 @@
5
5
  import type { Problem } from '#shared/Failed.ts'
6
6
  import type { Framed } from '#shared/framing.ts'
7
7
  import { REFUSAL_KEY } from '#shared/REFUSAL_KEY.ts'
8
+ import { HEARTBEAT_MILLISECONDS } from './HEARTBEAT_MILLISECONDS.ts'
8
9
 
9
10
  /** AN EVENT STREAM OPENS ON A COMMENT, so a proxy has bytes to pass before the first event. */
10
- export const EVENT_STREAM_OPENER = ':\n\n'
11
+ export const EVENT_STREAM_OPENER = new TextEncoder().encode(':\n\n')
12
+
13
+ /**
14
+ * `body`, read one chunk a pull; `release` runs once it closes, errors or its reader hangs up.
15
+ *
16
+ * 16.57 — `beats`: a comment wherever a read has waited a heartbeat for its next chunk. A COMMENT
17
+ * RATHER THAN AN EVENT: every reader skips one (18.27), and it gives a proxy in front bytes to
18
+ * pass, so neither it nor the reader takes a quiet stream for a dead one. A bridge's reader goes by
19
+ * it too (44.27), where a frame would sit in the pairing's inbox through a drop.
20
+ *
21
+ * BEATS ONLY WHILE A READ WAITS, at a high-water mark of 0: a reader that stalled or never started
22
+ * pulls nothing, so no comment queues behind it and no timer outlives it, and `body` is pulled no
23
+ * further ahead than it was. A read the beat won is kept for the next pull.
24
+ */
25
+ export function heldBody(
26
+ body: ReadableStream<Uint8Array>,
27
+ release: () => void,
28
+ beats: boolean,
29
+ ): ReadableStream<Uint8Array> {
30
+ const reader = body.getReader()
31
+ let pending: ReturnType<typeof reader.read> | undefined
32
+ return new ReadableStream<Uint8Array>(
33
+ {
34
+ async pull(controller) {
35
+ pending ??= reader.read()
36
+ let beat: ReturnType<typeof setTimeout> | undefined
37
+ try {
38
+ const next = beats
39
+ ? await Promise.race([
40
+ pending,
41
+ new Promise<undefined>((quiet) => {
42
+ beat = setTimeout(
43
+ () => quiet(undefined),
44
+ HEARTBEAT_MILLISECONDS,
45
+ )
46
+ }),
47
+ ])
48
+ : await pending
49
+ if (next === undefined)
50
+ return controller.enqueue(EVENT_STREAM_OPENER)
51
+ pending = undefined
52
+ if (!next.done) return controller.enqueue(next.value)
53
+ controller.close()
54
+ release()
55
+ } catch (thrown) {
56
+ controller.error(thrown)
57
+ release()
58
+ } finally {
59
+ clearTimeout(beat)
60
+ }
61
+ },
62
+ cancel(reason) {
63
+ release()
64
+ return reader.cancel(reason)
65
+ },
66
+ },
67
+ { highWaterMark: 0 },
68
+ )
69
+ }
11
70
 
12
71
  /**
13
72
  * One event: its `event` name and `id` where it has them, and `data` — one line, which JSON is as
@@ -59,3 +59,14 @@ export const SOCKET_ADDRESS = `${ABIDE_ADDRESS}/socket`
59
59
  * 26.2.
60
60
  */
61
61
  export const PRINCIPAL_ADDRESS = `${ABIDE_ADDRESS}/principal`
62
+
63
+ /**
64
+ * Where an app with `ABIDE_OFFLINE` on serves its service worker, and where every other app serves
65
+ * the one that removes it.
66
+ *
67
+ * 45.1, 45.9.
68
+ */
69
+ export const WORKER_ADDRESS = `${ABIDE_ADDRESS}/worker.js`
70
+
71
+ /** The web app manifest an app answers where it keeps its pages offline and has an icon (45.15). */
72
+ export const APP_MANIFEST_ADDRESS = `${ABIDE_ADDRESS}/manifest.webmanifest`
@@ -0,0 +1,6 @@
1
+ /**
2
+ * A run of ASCII whitespace, which is what a `class` value is split into tokens on — read by the
3
+ * compiler finding the classes a static `class` holds and by the runtime writing a `class={expr}`'s
4
+ * tokens (32.71), so both split one way.
5
+ */
6
+ export const ASCII_WHITESPACE = /[\t\n\f\r ]+/
@@ -0,0 +1,2 @@
1
+ /** The longest delay `setTimeout` holds, in milliseconds: it reads anything past a signed 32-bit count as 1 ms. */
2
+ export const LONGEST_DELAY = 2 ** 31 - 1
@@ -0,0 +1,12 @@
1
+ // 45.3, 45.5 — THE CACHES THE SERVICE WORKER KEEPS, named once for the worker the server writes and
2
+ // the router that asks whether a page was saved (45.12). A leaf, for the reason every constant
3
+ // crossing a seam is one.
4
+
5
+ /** What every cache the worker keeps is named under, so a cache an earlier worker named is found. */
6
+ export const OFFLINE_CACHE_PREFIX = 'abide:'
7
+
8
+ /** The pages, the handlers' answers and the public files the worker saved, by address. */
9
+ export const OFFLINE_SAVED = `${OFFLINE_CACHE_PREFIX}saved`
10
+
11
+ /** The build's bundle, saved as the worker installs. */
12
+ export const OFFLINE_BUNDLE = `${OFFLINE_CACHE_PREFIX}bundle`
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The `<meta>` the server writes unless `ABIDE_OFFLINE` is off, and a browser registers the service
3
+ * worker by.
4
+ *
5
+ * 45.1.
6
+ */
7
+ export const OFFLINE_META = 'abide-offline'
@@ -81,15 +81,40 @@ async function fetched(
81
81
  streams: boolean,
82
82
  signal: AbortSignal | undefined,
83
83
  ): Promise<Answer> {
84
- const response = await fetch(
85
- request.url,
86
- signal === undefined ? request.init : { ...request.init, signal },
87
- )
84
+ let response: Response
85
+ try {
86
+ response = await fetch(
87
+ request.url,
88
+ signal === undefined ? request.init : { ...request.init, signal },
89
+ )
90
+ } catch (error) {
91
+ // An abort is the load's own ending (16.49), which the value has already answered.
92
+ if (signal?.aborted === true) throw error
93
+ // 16.79 — NO ANSWER AT ALL, as a status the app can narrow on rather than the browser's
94
+ // own `TypeError`, whose message differs in each.
95
+ return unanswered()
96
+ }
88
97
  // THE HANDLER SIGNED THIS CALLER IN OR OUT, and the cookie is already the browser's.
89
98
  principalRotated(response)
90
99
  return answerFrom(response, undefined, streams)
91
100
  }
92
101
 
102
+ /** The answer a call that reached no server stands in for: an empty 503, which `answered` refuses. */
103
+ function unanswered(): Answer {
104
+ return {
105
+ status: 503,
106
+ statusText: '',
107
+ ok: false,
108
+ framing: null,
109
+ followable: false,
110
+ version: undefined,
111
+ life: undefined,
112
+ text: '',
113
+ bytes: undefined,
114
+ body: undefined,
115
+ }
116
+ }
117
+
93
118
  /**
94
119
  * `response`, classified: a body `read` to its end already (an early call the server painted)
95
120
  * decoded as what it carried, and one unread read as bytes where it is a binary (16.34), as text
@@ -31,7 +31,7 @@ export function attributeText(name: string, value: unknown): string | null {
31
31
  const safe = safeUrl(text)
32
32
  if (safe !== text)
33
33
  RENDER_LOG.warning(
34
- `abide: \`${name}\` was handed a URL whose scheme is not http:, https:, mailto: or tel:, and is written empty. RULEBOOK 32.20.`,
34
+ `abide: \`${name}\` was handed a URL whose scheme is not http:, https:, mailto:, tel:, blob: or data:, and is written empty. RULEBOOK 32.20.`,
35
35
  )
36
36
  return safe
37
37
  }
@@ -38,6 +38,8 @@
38
38
  // nothing is dropped early, and the read-driven backstop in `Reactive.ts` catches up the
39
39
  // moment the clock does.
40
40
 
41
+ import { LONGEST_DELAY } from '../LONGEST_DELAY.ts'
42
+
41
43
  export type Expiry = {
42
44
  /** The life of one production, in milliseconds. */
43
45
  ms: number
@@ -90,13 +92,23 @@ export function expiryFor(
90
92
  * number and has no process to hold open, so the clause is satisfied there by there being nothing
91
93
  * to satisfy. CLAUDE.md's uniformity bias is exactly this shape — one mechanism, inert on the side
92
94
  * that cannot need it.
95
+ *
96
+ * PAST WHAT `setTimeout` HOLDS — 2^31 - 1 ms, near 25 days — a delay is run after 1 ms, and a
97
+ * deadline that fires early finds nothing expired and re-arms: a timer a millisecond for the life of
98
+ * the value. So `Infinity` arms nothing, and a longer finite delay arms at the cap — early, which a
99
+ * deadline re-checks and re-arms from, and which a debounce or throttle window that long takes as
100
+ * its end.
93
101
  */
94
102
  export function armTimer(
95
103
  expiry: { timer: ReturnType<typeof setTimeout> | null },
96
104
  delay: number,
97
105
  fire: () => void,
98
106
  ): void {
99
- const timer = setTimeout(fire, delay > 0 ? delay : 0)
107
+ if (delay === Number.POSITIVE_INFINITY) return
108
+ const timer = setTimeout(
109
+ fire,
110
+ delay > 0 ? Math.min(delay, LONGEST_DELAY) : 0,
111
+ )
100
112
  const unref = (timer as { unref?: () => void }).unref
101
113
  if (typeof unref === 'function') unref.call(timer)
102
114
  expiry.timer = timer
@@ -1998,7 +1998,16 @@ function refused(cell: Cell, failure: unknown): unknown {
1998
1998
  // had set it true, and `error` went from nothing to this. A guard that asked about
1999
1999
  // either is woken here or never.
2000
2000
  probesMoved(cell)
2001
- if (!cell.servable) cascade(cell)
2001
+ if (cell.servable) return failure
2002
+ // 7.19 — A SERVER THAT COULD NOT BE REACHED (16.79) says nothing about the value held, so the
2003
+ // invalidated value is served again rather than lost to a read that throws, as 4.12 serves one.
2004
+ if (
2005
+ cell.landed &&
2006
+ isFailedNamed(failure, 'HttpError') &&
2007
+ (failure as Failed<string, unknown>).status === 503
2008
+ )
2009
+ serveAgain(cell)
2010
+ else cascade(cell)
2002
2011
  return failure
2003
2012
  }
2004
2013
 
@@ -3253,7 +3262,9 @@ function settled(cell: Cell, untilStreaming = false): Promise<unknown> {
3253
3262
  if (untilStreaming && cell.stream !== null)
3254
3263
  return Promise.resolve(STREAMING)
3255
3264
  if (cell.servable && !busyNow(cell)) return Promise.resolve(heldValue(cell))
3256
- if (!cell.landed && !busyNow(cell)) {
3265
+ // 2.8 — UNSERVED, NOT UNLANDED: an invalidated value has landed (7.8), and a reload that failed
3266
+ // over it leaves the failure as the answer, or the await waits on a load that has ended.
3267
+ if (!busyNow(cell)) {
3257
3268
  const failure = failureOf(cell)
3258
3269
  if (failure !== undefined) return Promise.reject(failure)
3259
3270
  }
@@ -3280,7 +3291,6 @@ function settled(cell: Cell, untilStreaming = false): Promise<unknown> {
3280
3291
  settle(heldValue(cell))
3281
3292
  return
3282
3293
  }
3283
- if (cell.landed) return
3284
3294
  const failure = failureOf(cell)
3285
3295
  if (failure !== undefined) {
3286
3296
  waiter.alive = false
@@ -8,7 +8,14 @@
8
8
  // attribute's name.
9
9
  import { textOf } from './Raw.ts'
10
10
 
11
- const SAFE_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:'])
11
+ const SAFE_SCHEMES = new Set([
12
+ 'http:',
13
+ 'https:',
14
+ 'mailto:',
15
+ 'tel:',
16
+ 'blob:',
17
+ 'data:',
18
+ ])
12
19
 
13
20
  /** A URL attribute's text, or `''` where its scheme is outside the safe set. */
14
21
  export function safeUrl(value: unknown): string {
package/src/ui/Level.ts CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  } from '#shared/reactive/share.ts'
7
7
  import { untracked } from '#shared/reactive/tracking.ts'
8
8
  import { type Disposer, watchTracked } from '#shared/reactive/watch.ts'
9
- import { applyHead, withdrawHead } from './applyHead.ts'
9
+ import { reconciled, reconcileHead } from './reconcileHead.ts'
10
10
  import { isEmptyRegion, type Region } from './regions.ts'
11
11
  import type { Scope } from './Scope.ts'
12
12
 
@@ -43,10 +43,10 @@ export class Level {
43
43
  */
44
44
  share: ShareScope | null = null
45
45
  /**
46
- * The head the components built on the client contributed while this level mounts, applied
47
- * once its `mount` returns; `undefined` outside one, where a contribution applies at once.
46
+ * Whether a component built on the client contributed a head while this level mounts, which is
47
+ * reconciled once its `mount` returns; `undefined` outside one, where it is reconciled at once.
48
48
  */
49
- unapplied: string | undefined = undefined
49
+ unapplied: boolean | undefined = undefined
50
50
  /**
51
51
  * This level's own head markup as it reads now; `undefined` for a level with no `<head>`.
52
52
  *
@@ -82,11 +82,31 @@ export class Level {
82
82
  this.share = share
83
83
  }
84
84
 
85
- /** The level's head as it reads now: its own, then every component's, deepest last (39.4). */
86
- headMarkup(): string {
87
- let markup = this.head?.() ?? ''
88
- for (const one of this.contributed) markup += one.head()
89
- return markup
85
+ /** Reconciles the document head from the chain this level stands in (`reconcileHead`). */
86
+ reconcile(): void {
87
+ Level.reconcile(this.levels)
88
+ }
89
+
90
+ /**
91
+ * Reconciles the document head from `levels`: each level's own head, then every component's in
92
+ * the order they mounted, outermost first — so the deepest wins by key (39.4). Read untracked:
93
+ * a head read here is followed by its own watch, not by whatever wrote.
94
+ */
95
+ static reconcile(levels: Level[]): void {
96
+ reconcileHead(untracked(Level.chain, levels, undefined))
97
+ }
98
+
99
+ /** Every contributor in `levels` and the head it reads now, outermost first. */
100
+ static chain(
101
+ levels: Level[],
102
+ _?: undefined,
103
+ ): [who: object, text: string][] {
104
+ const chain: [object, string][] = []
105
+ for (const level of levels) {
106
+ if (level.head !== undefined) chain.push([level, level.head()])
107
+ for (const one of level.contributed) chain.push([one, one.head()])
108
+ }
109
+ return chain
90
110
  }
91
111
 
92
112
  /**
@@ -100,78 +120,18 @@ export class Level {
100
120
  const contribution = new HeadContribution(this, head)
101
121
  this.contributed.add(contribution)
102
122
  contribution.dispose = watchTracked(() => contribution.follow())
103
- // Batched while this level mounts; applied at once by an arm or a row mounted after it,
104
- // under whatever is deeper (39.4) — only the levels below, since it was added last here.
105
- const text = contribution.last ?? ''
123
+ // Batched while this level mounts; reconciled at once for an arm or a row mounted after it.
106
124
  if (isEmptyRegion(region))
107
- if (this.unapplied !== undefined) this.unapplied += text
108
- else
109
- applyHead(
110
- text,
111
- untracked(Level.headsOf, this.levels, this.at + 1),
112
- )
125
+ if (this.unapplied !== undefined) this.unapplied = true
126
+ else this.reconcile()
113
127
  return contribution
114
128
  }
115
129
 
116
- /**
117
- * Writes `text` over the `last` that `contributor` in this level wrote.
118
- *
119
- * 39.4 — IT YIELDS TO EVERY CONTRIBUTOR DEEPER, read as they stand at the write and untracked:
120
- * the components that contributed after it, then every level below, so a layout's
121
- * `<title>{site}</title>` moving does not write over the page's `<title>`, and the page's head
122
- * moving wakes only the page's own watch. 39.15 — its keyless nodes replace the ones it wrote.
123
- */
124
- reapply(text: string, last: string, contributor: HeadContribution): void {
125
- applyHead(
126
- text,
127
- untracked(Level.headsAfter, this, contributor),
128
- last,
129
- // Read only where a key `last` held is withdrawn.
130
- () => untracked(Level.headsBefore, this, contributor),
131
- )
132
- }
133
-
134
- /**
135
- * The heads either side of `contribution` in `level` (`headsBefore`, `headsAfter`). One `level`
136
- * does not hold is the level's own head (`followHead`), which stands before every component's.
137
- */
138
- static headsAround(
139
- level: Level,
140
- contribution: HeadContribution,
141
- ): [string, string] {
142
- return [
143
- Level.headsBefore(level, contribution),
144
- Level.headsAfter(level, contribution),
145
- ]
146
- }
147
-
148
- /** Every level above `level`, then what stands before `contribution` in it. */
149
- static headsBefore(level: Level, contribution: HeadContribution): string {
150
- let outer = Level.headsOf(level.levels, 0, level.at)
151
- if (!level.contributed.has(contribution)) return outer
152
- outer += level.head?.() ?? ''
153
- for (const one of level.contributed) {
154
- if (one === contribution) break
155
- outer += one.head()
156
- }
157
- return outer
158
- }
159
-
160
- /** What stands after `contribution` in `level`, then every level below it. */
161
- static headsAfter(level: Level, contribution: HeadContribution): string {
162
- let inner = ''
163
- let past = !level.contributed.has(contribution)
164
- for (const one of level.contributed)
165
- if (past) inner += one.head()
166
- else if (one === contribution) past = true
167
- return inner + Level.headsOf(level.levels, level.at + 1)
168
- }
169
-
170
130
  /**
171
131
  * 39.13 — A HEAD THAT READS A VALUE FOLLOWS IT for as long as the level is mounted, which is
172
132
  * `scope`'s life. The first run only subscribes: the head it would write is already there,
173
133
  * written by the server or applied by the level's mount ahead of its fill (39.6). Followed as a
174
- * component's is, by a contribution the level does not hold (`headsAround`).
134
+ * component's is, by a contribution the level does not hold.
175
135
  */
176
136
  followHead(scope: Scope): void {
177
137
  const own = new HeadContribution(this, this.head as () => string)
@@ -190,8 +150,8 @@ export class Level {
190
150
  values: new Map(),
191
151
  level: this,
192
152
  })
193
- this.unapplied = ''
194
- let unapplied: string
153
+ this.unapplied = false
154
+ let unapplied: boolean
195
155
  let result: Result
196
156
  try {
197
157
  result = run()
@@ -200,20 +160,9 @@ export class Level {
200
160
  this.unapplied = undefined
201
161
  leaveShare(back)
202
162
  }
203
- // 39.4 — under whatever a level below wrote while this one was mounting: the page in a
204
- // layout's `<slot/>` applied its own head ahead of the components that layout holds.
205
- if (unapplied !== '')
206
- applyHead(unapplied, Level.headsOf(this.levels, this.at + 1))
163
+ if (unapplied) this.reconcile()
207
164
  return result
208
165
  }
209
-
210
- /** The heads of `levels` from `from` to `to`, outermost first. */
211
- static headsOf(levels: Level[], from: number, to = levels.length): string {
212
- let markup = ''
213
- for (let at = from; at < to; at += 1)
214
- markup += (levels[at] as Level).headMarkup()
215
- return markup
216
- }
217
166
  }
218
167
 
219
168
  /**
@@ -236,24 +185,23 @@ export class HeadContribution {
236
185
  follow(): void {
237
186
  const text = this.head()
238
187
  const last = this.last
239
- if (last !== undefined && text !== last)
240
- this.level.reapply(text, last, this)
241
188
  this.last = text
189
+ if (last !== undefined && text !== last) this.level.reconcile()
242
190
  }
243
191
 
244
192
  /**
245
- * 39.14 — WITHDRAWN AS A LEAVING LEVEL'S IS, its keys going back to what stands outside it and
246
- * staying where something deeper holds them. Nothing where the level is leaving too: the router
247
- * withdraws the whole level once, and cleared its contributions before stopping it.
193
+ * 39.14 — WITHDRAWN AS A LEAVING LEVEL'S IS: the chain is reconciled without it. Adopted first
194
+ * where the server painted it and nothing has reconciled since, so what it painted is known to
195
+ * be its own. Nothing where the level is leaving too: the router reconciles the whole chain
196
+ * once, and cleared its contributions before stopping it.
248
197
  */
249
198
  stop(): void {
250
199
  this.dispose?.()
251
200
  const level = this.level
252
201
  if (!level.contributed.has(this)) return
253
- withdrawHead(this.last ?? '', () =>
254
- untracked(Level.headsAround, level, this),
255
- )
202
+ if (!reconciled(this)) level.reconcile()
256
203
  level.contributed.delete(this)
204
+ level.reconcile()
257
205
  }
258
206
  }
259
207