@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.
- package/CHANGELOG.md +24 -0
- package/docs/REGISTRY.md +14 -14
- package/docs/RULEBOOK.md +35 -6
- package/docs/abide.md +54 -48
- package/package.json +1 -1
- package/src/compiler/Lowering.ts +5 -0
- package/src/compiler/appLayout.ts +16 -1
- package/src/compiler/classifyTemplate.ts +134 -39
- package/src/compiler/emitClient.ts +173 -16
- package/src/compiler/renderMarkup.ts +14 -8
- package/src/server/commands.ts +2 -1
- package/src/server/config.ts +13 -1
- package/src/server/createApp.ts +18 -6
- package/src/server/preload.ts +5 -0
- package/src/server/principal.ts +14 -0
- package/src/server/render.ts +2 -2
- package/src/shared/ASCII_WHITESPACE.ts +6 -0
- package/src/shared/FOREIGN_ATTRIBUTES.ts +24 -0
- package/src/shared/INTEGRATION_POINTS.ts +20 -0
- package/src/shared/IN_PROCESS_ORIGIN.ts +5 -0
- package/src/shared/reactive/Keyed.ts +41 -14
- package/src/shared/reactive/Reactive.ts +34 -2
- package/src/shared/rpc.ts +2 -1
- package/src/ui/Level.ts +43 -95
- package/src/ui/Router.ts +12 -13
- package/src/ui/bindGroup.ts +41 -0
- package/src/ui/bindProperty.ts +1 -21
- package/src/ui/bindSpread.ts +4 -2
- package/src/ui/index.ts +7 -5
- package/src/ui/parseMarkup.ts +20 -25
- package/src/ui/reconcileHead.ts +215 -0
- package/src/ui/setAttribute.ts +25 -1
- package/src/ui/setClassGroup.ts +79 -0
- package/src/ui/setStyleGroup.ts +130 -0
- package/src/ui/applyHead.ts +0 -214
package/src/server/createApp.ts
CHANGED
|
@@ -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
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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)
|
package/src/server/preload.ts
CHANGED
|
@@ -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.
|
package/src/server/principal.ts
CHANGED
|
@@ -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
|
*
|
package/src/server/render.ts
CHANGED
|
@@ -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 `
|
|
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 `
|
|
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`
|
|
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
|
|
274
|
+
function handed<Args>(
|
|
277
275
|
body: (args: Args) => unknown,
|
|
278
|
-
|
|
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* (
|
|
283
|
-
yield* (body as (args: Args) => AsyncIterable<unknown>)(
|
|
279
|
+
return async function* () {
|
|
280
|
+
yield* (body as (args: Args) => AsyncIterable<unknown>)(accepted)
|
|
284
281
|
}
|
|
285
|
-
return (
|
|
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(
|
|
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
|
-
|
|
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)
|
|
4697
|
-
|
|
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,
|
|
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 {
|
|
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
|
-
*
|
|
47
|
-
* once its `mount` returns; `undefined` outside one, where
|
|
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:
|
|
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
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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;
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
246
|
-
*
|
|
247
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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
|
/**
|