@abide/abide 0.111.0 → 0.113.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.
@@ -7,6 +7,7 @@ import { existsSync } from 'node:fs' // bun exposes no sync existence check of i
7
7
  import { resolve } from 'node:path' // bun exposes no path helpers of its own
8
8
  import { FAILURE_STATUS_TEXT } from '#shared/FAILURE_STATUS_TEXT.ts'
9
9
  import { framingOf } from '#shared/framing.ts'
10
+ import { IN_PROCESS_ORIGIN } from '#shared/IN_PROCESS_ORIGIN.ts'
10
11
  import { LONGEST_DELAY } from '#shared/LONGEST_DELAY.ts'
11
12
  import { namedLogger } from '#shared/log.ts'
12
13
  import { compose } from '#shared/Middleware.ts'
@@ -32,6 +33,7 @@ import { IDLE_TIMEOUT_SECONDS } from './IDLE_TIMEOUT_SECONDS.ts'
32
33
  import { installServer } from './installServer.ts'
33
34
  import { admitsMux, muxSocketOptions, upgraded } from './muxUpgrade.ts'
34
35
  import { plainAnswer } from './plainAnswer.ts'
36
+ import { presumePrincipal } from './principal.ts'
35
37
  import { realOrAsGiven } from './realOrAsGiven.ts'
36
38
  import {
37
39
  type AnsweringApp,
@@ -60,7 +62,7 @@ export interface App {
60
62
  fetch(request: Request): Promise<Response>
61
63
  run<Result>(
62
64
  fn: () => Result | Promise<Result>,
63
- options?: { request?: Request },
65
+ options?: { request?: Request; principal?: Record<string, unknown> },
64
66
  ): Promise<Result>
65
67
  listen(options?: {
66
68
  port?: number
@@ -289,11 +291,21 @@ export async function bootEntered(
289
291
  let stopping = false
290
292
  let bound: Bun.Server<unknown> | undefined
291
293
 
292
- const run = async <Result>(
293
- fn: () => Result | Promise<Result>,
294
- options?: { request?: Request },
295
- ): Promise<Result> =>
296
- inReleasedScope(options?.request, async () => await fn(), answering)
294
+ const run: App['run'] = async (fn, options) => {
295
+ const claims = options?.principal
296
+ // 42.19 — a principal is a request's, so one given alone is given a request to be.
297
+ const request =
298
+ options?.request ??
299
+ (claims === undefined ? undefined : new Request(IN_PROCESS_ORIGIN))
300
+ return inReleasedScope(
301
+ request,
302
+ async () => {
303
+ if (claims !== undefined) presumePrincipal(claims)
304
+ return await fn()
305
+ },
306
+ answering,
307
+ )
308
+ }
297
309
 
298
310
  const fetch = async (given: Request): Promise<Response> => {
299
311
  if (stopping) return plainAnswer('the app is stopping', 503)
@@ -5,8 +5,13 @@
5
5
  // one place that runs first. The build is `createApp`'s own under `bun test` (42.16), so a test that
6
6
  // boots the app afterwards reuses it rather than building twice.
7
7
 
8
+ import { addDerived } from './config.ts'
8
9
  import { appRoot, servedBuild } from './createApp.ts'
9
10
 
11
+ // 38.120 — a sign-in seals under test where the app declared no secret: derived, so the app's
12
+ // `onConfig` default and the env both still win (27.2), where an env value would beat the default.
13
+ addDerived({ ABIDE_PRINCIPAL_SECRET: 'abide-test-principal-secret' })
14
+
10
15
  const root = appRoot(undefined)
11
16
  // A run from a directory that holds no app preloads nothing — nor from a workspace's root, whose
12
17
  // packages' `.abide` files are each their own app's rather than one app laid out across them.
@@ -195,6 +195,20 @@ function resolvePrincipal(): Promise<Principal> {
195
195
  return scope.principal
196
196
  }
197
197
 
198
+ /**
199
+ * This request's principal, resolved from `claims` as a sealed cookie's are, with no cookie written.
200
+ *
201
+ * 42.19 — `App.run`'s `principal`, which is why it throws where `principal.set` does.
202
+ */
203
+ export function presumePrincipal(claims: Record<string, unknown>): void {
204
+ const scope = within('App.run principal')
205
+ if (!isRecord(claims))
206
+ throw new Error(
207
+ 'abide: App.run takes a principal of claims, as principal.set does. RULEBOOK 26.11.',
208
+ )
209
+ scope.principal = documentOf(claims, Date.now() + ttl())
210
+ }
211
+
198
212
  /**
199
213
  * Signs this caller in with `claims`, sealing them into the cookie.
200
214
  *
@@ -117,10 +117,10 @@ export async function composePage(
117
117
  * 39.4, 39.11 — THE DEEPEST CONTRIBUTOR WINS, and the markup is in tree order — the shell's head
118
118
  * (its outermost contributor), then each level's, outermost first — so the deepest is the last.
119
119
  * Written end to end instead, a shell's or a layout's `<title>` stands ahead of its page's and a
120
- * browser reads the first. The compiler merges within a file (39.12) and `applyHead` across a
120
+ * browser reads the first. The compiler merges within a file (39.12) and `reconcileHead` across a
121
121
  * navigation; this is the same grammar across the contributors of one render.
122
122
  *
123
- * TOP LEVEL OF THE HEAD ONLY, as `applyHead` keys the head's children and nothing inside them.
123
+ * TOP LEVEL OF THE HEAD ONLY, as `reconcileHead` keys the head's children and nothing inside them.
124
124
  * The attribute is the one WRITTEN rather than decoded — a shell's escaping and the compiler's
125
125
  * agree on every key a shell and a page would plausibly share.
126
126
  *
@@ -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,24 @@
1
+ const XLINK = 'http://www.w3.org/1999/xlink'
2
+ const XML = 'http://www.w3.org/XML/1998/namespace'
3
+ const XMLNS = 'http://www.w3.org/2000/xmlns/'
4
+
5
+ /**
6
+ * The attributes the HTML parser puts in a namespace on an SVG or MathML element — its "adjust
7
+ * foreign attributes" table — each to that namespace. Every other attribute it writes has none.
8
+ *
9
+ * A leaf, because the compiler reads it to know which built attribute needs the client's
10
+ * `addAttribute`, and the client reads it to write one.
11
+ */
12
+ export const FOREIGN_ATTRIBUTES: ReadonlyMap<string, string> = new Map([
13
+ ['xlink:actuate', XLINK],
14
+ ['xlink:arcrole', XLINK],
15
+ ['xlink:href', XLINK],
16
+ ['xlink:role', XLINK],
17
+ ['xlink:show', XLINK],
18
+ ['xlink:title', XLINK],
19
+ ['xlink:type', XLINK],
20
+ ['xml:lang', XML],
21
+ ['xml:space', XML],
22
+ ['xmlns', XMLNS],
23
+ ['xmlns:xlink', XMLNS],
24
+ ])
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The SVG and MathML elements whose children the HTML parser reads as HTML again — the
3
+ * integration points — less `annotation-xml`, which is one only by the encoding it declares.
4
+ *
5
+ * A leaf, because the client's parse and build read it (`namespaceUnder`) and the compiler reads
6
+ * it to know a built element below one is HTML's, whatever its parent's namespace.
7
+ */
8
+ export const INTEGRATION_POINTS: ReadonlySet<string> = new Set([
9
+ 'foreignObject',
10
+ 'desc',
11
+ 'title',
12
+ 'mi',
13
+ 'mo',
14
+ 'mn',
15
+ 'ms',
16
+ 'mtext',
17
+ ])
18
+
19
+ /** HTML's namespace: what the parser gives an element outside SVG and MathML, and below an integration point. */
20
+ export const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'
@@ -0,0 +1,5 @@
1
+ // The origin a request made in process is addressed to, where no listener names a real one: an
2
+ // rpc's `raw` answered by the app this process holds, a CLI command asking the app it booted, and
3
+ // an `App.run` given a principal and no request (42.19). A leaf, because the server and the shared
4
+ // rpc both build one.
5
+ export const IN_PROCESS_ORIGIN = 'http://app.internal'
@@ -34,6 +34,7 @@ import {
34
34
  refuseBothCaps,
35
35
  signalMoved,
36
36
  startDeferred,
37
+ unwatchStore,
37
38
  } from './Reactive.ts'
38
39
  import {
39
40
  detach,
@@ -46,6 +47,7 @@ import {
46
47
  type Tracked,
47
48
  trackProbe,
48
49
  } from './tracking.ts'
50
+ import { owned, owning } from './watch.ts'
49
51
 
50
52
  /**
51
53
  * The keyed face: one `Reactive` per `Args` key, plus the probes and triggers over its entries.
@@ -262,27 +264,22 @@ class StartWindow {
262
264
  }
263
265
 
264
266
  /**
265
- * `body` behind the `args` gate: validate, then run the body with what the gate accepted.
266
- *
267
- * 11.27's LAST TWO STEPS as one body. The gate runs on every run of the entry — a refresh
268
- * re-validates — and throws the refusal, which is what a body failing already does: the entry's
269
- * `error` holds it and its read throws it (2.2), and the body the gate stands in front of is never
270
- * called. What it accepts is what the body is handed (11.68).
267
+ * `body` handed what the `args` gate accepted for one entry (11.68), rather than the args it was
268
+ * reached with.
271
269
  *
272
270
  * A STREAM STAYS A STREAM FROM CONSTRUCTION. 9.6 reads the body's KIND, so the wrapper around an
273
271
  * async generator is an async generator, or a gated keyed stream would be one only from its first
274
272
  * run.
275
273
  */
276
- function gated<Args>(
274
+ function handed<Args>(
277
275
  body: (args: Args) => unknown,
278
- gate: Schema<Args>,
276
+ accepted: Args,
279
277
  ): (args: Args) => unknown {
280
- const check = (args: Args): Args => checkArgs(gate, args)
281
278
  if (isStreamBody(body))
282
- return async function* (args: Args) {
283
- yield* (body as (args: Args) => AsyncIterable<unknown>)(check(args))
279
+ return async function* () {
280
+ yield* (body as (args: Args) => AsyncIterable<unknown>)(accepted)
284
281
  }
285
- return (args: Args): unknown => body(check(args))
282
+ return (): unknown => body(accepted)
286
283
  }
287
284
 
288
285
  /**
@@ -348,7 +345,7 @@ export function createKeyed<
348
345
  (throttle ?? debounce) as number,
349
346
  )
350
347
  const lifetime = ttl === undefined ? null : new Lifetime(ttl)
351
- const entryBody = heldOnce(gate === undefined ? body : gated(body, gate))
348
+ const entryBody = heldOnce(body)
352
349
  // 11.27, 11.29 — the defaults are applied before the key is taken, so the entry is one for a
353
350
  // call that wrote them and one that left them to the parameter. Read once, here: a body with
354
351
  // none pays a `null` test per reach.
@@ -358,6 +355,12 @@ export function createKeyed<
358
355
  // there were, so the key that starts loading next — search-as-you-type — never woke it. One
359
356
  // signal per `Keyed`, moved where an entry is reached and where one is released.
360
357
  const membership = newTracked()
358
+ // 8.21 — A `Keyed` BUILT IN A SETUP OR A REQUEST ends with it, and every store its entries
359
+ // watch is stopped then: the entries are the cache's, so no owner of their own stops them.
360
+ if (options?.store !== undefined && owning())
361
+ owned(() => {
362
+ for (const entry of entries.values()) unwatchStore(entry)
363
+ })
361
364
 
362
365
  function askMembership(): void {
363
366
  const reader = TRACKING.reader
@@ -372,6 +375,7 @@ export function createKeyed<
372
375
  ): void {
373
376
  entries.delete(key)
374
377
  leaveSelections(created)
378
+ unwatchStore(created)
375
379
  signalMoved(membership)
376
380
  }
377
381
 
@@ -385,9 +389,32 @@ export function createKeyed<
385
389
  if (failedTransiently(standing)) standing.refresh()
386
390
  return standing
387
391
  }
392
+ // 11.27's VALIDATE, ONCE PER ENTRY AND BEFORE IT IS FILED: a refused `Args` is answered by a
393
+ // value holding the refusal (2.2) that no map keeps, so args nobody should have sent never
394
+ // take a place among the entries — on a `global` value, a place nothing but `invalidate`
395
+ // would take back (11.35). Built with none of the options: a `store` would restore a value
396
+ // over the refusal and watch for an entry nothing releases.
397
+ let run: (args: Args) => unknown = entryBody
398
+ if (gate !== undefined) {
399
+ let accepted: Args
400
+ try {
401
+ accepted = checkArgs(gate, args)
402
+ } catch (refusal) {
403
+ return createReactive<Stored, Accepted, Failures>(
404
+ `${label}(${key})`,
405
+ () => {
406
+ throw refusal
407
+ },
408
+ args,
409
+ undefined,
410
+ undefined,
411
+ )
412
+ }
413
+ run = handed(entryBody, accepted)
414
+ }
388
415
  const created = createReactive<Stored, Accepted, Failures>(
389
416
  `${label}(${key})`,
390
- entryBody as (args: unknown) => unknown,
417
+ run as (args: unknown) => unknown,
391
418
  args,
392
419
  undefined,
393
420
  entryOptions as ReactiveOptions<Accepted, Stored, Failures>,
@@ -75,6 +75,7 @@ export type Transformer<Accepted, Stored, Failures = never> = (
75
75
  export type Store<Stored> = {
76
76
  get: () => Stored | undefined | Promise<Stored | undefined>
77
77
  set: (value: Stored, retention: { ttl: number }) => void | Promise<void>
78
+ watch?: (changed: () => void) => () => void
78
79
  }
79
80
 
80
81
  /**
@@ -366,6 +367,8 @@ type Persisted = {
366
367
  * what `pending` sends next, and what 8.18 serves a re-read in place of calling `get`.
367
368
  */
368
369
  unconfirmed: unknown
370
+ /** What stops the store's `watch`, where it has one (8.21). */
371
+ unwatch: (() => void) | null
369
372
  }
370
373
 
371
374
  /**
@@ -891,6 +894,7 @@ class Cell {
891
894
  writing: false,
892
895
  pending: false,
893
896
  unconfirmed: undefined,
897
+ unwatch: null,
894
898
  }
895
899
  this.by = (options?.by ?? null) as
896
900
  | string
@@ -3801,6 +3805,32 @@ function rereadStore(cell: Cell, persisted: Persisted): void {
3801
3805
  )
3802
3806
  }
3803
3807
 
3808
+ /**
3809
+ * Follows what the store says changed outside this process, re-reading it each time.
3810
+ *
3811
+ * 8.20 — A RE-READ, NEVER THE BODY: an edit made where the value lives is the store's news, so it
3812
+ * is read back as a refresh of a value whose only producer is its store would read it, and 8.18
3813
+ * still has a write the app made stand over it. 8.21 — stopped with the value: by its owner here,
3814
+ * and by its `Keyed` through `unwatchStore` where an entry is evicted.
3815
+ */
3816
+ function watchStore(cell: Cell, persisted: Persisted, cached: boolean): void {
3817
+ const watch = persisted.store.watch
3818
+ if (watch === undefined) return
3819
+ persisted.unwatch = watch(() => {
3820
+ rereadStore(cell, persisted)
3821
+ })
3822
+ if (!cached && owning()) owned(() => unwatchStore(cell.callable))
3823
+ }
3824
+
3825
+ /** Stops the store's `watch` on `value`, once (8.21). */
3826
+ export function unwatchStore(value: unknown): void {
3827
+ const persisted = (value as Callable)[CELL].store
3828
+ if (persisted === null || persisted.unwatch === null) return
3829
+ const unwatch = persisted.unwatch
3830
+ persisted.unwatch = null
3831
+ unwatch()
3832
+ }
3833
+
3804
3834
  /**
3805
3835
  * Lands a store re-read, letting an unconfirmed write win.
3806
3836
  *
@@ -4693,8 +4723,10 @@ export function createReactive<
4693
4723
  // (2.2, 4.14) rather than serving an `undefined` its own type says is a `Stored`.
4694
4724
  // 8.3 AND 8.14: a store answers first, where the value is constructed, and whatever it misses
4695
4725
  // falls back to what the value would have done without one.
4696
- if (cell.store !== null) restoreFirst(cell, cell.store, initial)
4697
- else if (body === null) {
4726
+ if (cell.store !== null) {
4727
+ restoreFirst(cell, cell.store, initial)
4728
+ watchStore(cell, cell.store, cached)
4729
+ } else if (body === null) {
4698
4730
  if (initial === undefined) landOpening(cell)
4699
4731
  else if (initial !== NO_INITIAL) open(cell, initial)
4700
4732
  } else if (deferred) cell.loading = true
package/src/shared/rpc.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  import { type Failed, isFailedNamed } from './Failed.ts'
17
17
  import { GLOBAL_GETS } from './GLOBAL_GETS.ts'
18
18
  import { HandlerRoute } from './HandlerRoute.ts'
19
+ import { IN_PROCESS_ORIGIN } from './IN_PROCESS_ORIGIN.ts'
19
20
  import { compose, type Middleware } from './Middleware.ts'
20
21
  import { principalRotated } from './principalRotated.ts'
21
22
  import { RPC_SERVER } from './RPC_SERVER.ts'
@@ -442,7 +443,7 @@ export function declare(method: Method): Declare {
442
443
  if (RPC_SERVER.raw !== null)
443
444
  return RPC_SERVER.raw(
444
445
  new Request(
445
- new URL(asked.url, 'http://app.internal'),
446
+ new URL(asked.url, IN_PROCESS_ORIGIN),
446
447
  given,
447
448
  ),
448
449
  )
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
 
package/src/ui/Router.ts CHANGED
@@ -10,7 +10,6 @@ import { arrive, route } from '#shared/route.ts'
10
10
  import { rankRoutes, type Segment } from '#shared/routePattern.ts'
11
11
  import { setUpLevels } from '#shared/setUpLevels.ts'
12
12
  import { paintedCallsLanded } from '#shared/takeEarlyCall.ts'
13
- import { withdrawHead } from './applyHead.ts'
14
13
  import { LAYER_MOUNT, LAYER_NAVIGATE } from './LAYER_NAMES.ts'
15
14
  import { Level } from './Level.ts'
16
15
  import { beginLayer, endLayer } from './measureLayer.ts'
@@ -469,12 +468,9 @@ export class Router {
469
468
  // table written by hand may answer with a fresh record each call. The function a module
470
469
  // exports is the same function either way.
471
470
  const shared = this.sharedLevels(arriving)
472
- // READ BEFORE THE STOP, while the leaving levels' heads read what they read on screen.
473
- const leaving = Level.headsOf(
474
- this.levels,
475
- shared,
476
- this.levels.length,
477
- )
471
+ // RECONCILED BEFORE THE STOP, while the leaving levels' heads read what they read on
472
+ // screen: what the server painted for them is adopted as theirs, so it leaves with them.
473
+ Level.reconcile(this.levels)
478
474
  const left: Scroll = [scrollX, scrollY]
479
475
  this.stopFrom(shared)
480
476
  // The region the arriving part goes into: the page region where nothing is kept, and
@@ -495,11 +491,8 @@ export class Router {
495
491
  // outermost first; a kept layout is not mounted again, so neither is its head.
496
492
  this.mountChain(target, found, arriving, shared, into)
497
493
  // 39.14 — what the leaving levels keyed and the arriving ones did not goes back to the
498
- // nearest kept layout's, else leaves.
499
- withdrawHead(leaving, () => [
500
- Level.headsOf(this.levels, 0, shared),
501
- Level.headsOf(this.levels, shared, this.levels.length),
502
- ])
494
+ // nearest kept layout's, else leaves; their keyless nodes leave with them (39.16).
495
+ Level.reconcile(this.levels)
503
496
  // 30.29 — where the entry was left, on a history step; else the top of the page, or —
504
497
  // as the browser does on a load — the fragment's element.
505
498
  if (step.scroll !== undefined)
@@ -542,11 +535,13 @@ export class Router {
542
535
  private stopFrom(at: number): void {
543
536
  for (let index = this.levels.length - 1; index >= at; index -= 1) {
544
537
  const level = this.levels[index] as Level
538
+ // OUT OF THE CHAIN BEFORE IT STOPS, so a slot stop it fires on the way finds it gone
539
+ // (`release`) and leaves the head to whoever stopped it.
540
+ this.levels.length = index
545
541
  // The level's head leaves whole, so none of its components withdraws its own.
546
542
  level.contributed.clear()
547
543
  level.scope?.stop()
548
544
  }
549
- this.levels.length = at
550
545
  }
551
546
 
552
547
  /**
@@ -629,7 +624,11 @@ export class Router {
629
624
  private release(level: Level, at: number): void {
630
625
  if (this.levels[at] !== level) return
631
626
  level.slot = undefined
627
+ // 39.14, 39.16 — THE LEVELS BELOW LEAVE WITH THE SLOT'S REGION, and their head with them:
628
+ // reconciled first, so what the server painted for them is adopted as theirs.
629
+ Level.reconcile(this.levels)
632
630
  this.stopFrom(at + 1)
631
+ Level.reconcile(this.levels)
633
632
  }
634
633
 
635
634
  /**