@abide/abide 0.105.1 → 0.106.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 +18 -0
- package/bin/abide.ts +4 -1
- package/docs/REGISTRY.md +156 -44
- package/docs/RULEBOOK.md +375 -87
- package/docs/abide.md +7243 -10256
- package/package.json +4 -1
- package/scaffold/AGENTS.md +2 -0
- package/scaffold/src/getGreeting.ts +6 -0
- package/scaffold/src/index.abide +3 -1
- package/scaffold/src/tests/getGreeting.test.ts +6 -0
- package/scaffold/src/tests/index.test.ts +18 -0
- package/src/compiler/DOCS_SITE.ts +2 -0
- package/src/compiler/Lowering.ts +234 -14
- package/src/compiler/PositionTable.ts +14 -7
- package/src/compiler/PositionedRefusal.ts +13 -0
- package/src/compiler/Refusals.ts +18 -11
- package/src/compiler/SHARED_NAMES.ts +2 -0
- package/src/compiler/ShadowMap.ts +37 -0
- package/src/compiler/TEMPLATE_BLOCKS.ts +12 -0
- package/src/compiler/TypeSession.ts +410 -31
- package/src/compiler/appLayout.ts +18 -2
- package/src/compiler/argsSchemaOf.ts +55 -9
- package/src/compiler/checkApp.ts +33 -0
- package/src/compiler/checkedShadow.ts +36 -22
- package/src/compiler/citedClauses.ts +14 -0
- package/src/compiler/classifyRead.ts +6 -0
- package/src/compiler/classifyTemplate.ts +2 -1
- package/src/compiler/compileApp.ts +2 -2
- package/src/compiler/declaredExports.ts +27 -6
- package/src/compiler/emitClient.ts +304 -103
- package/src/compiler/handlersUnder.ts +4 -0
- package/src/compiler/invocationProps.ts +1 -1
- package/src/compiler/isIgnored.ts +27 -0
- package/src/compiler/parseAbide.ts +176 -76
- package/src/compiler/projectShadow.ts +30 -6
- package/src/compiler/readSources.ts +1 -1
- package/src/compiler/readsIn.ts +100 -1
- package/src/compiler/renderMarkup.ts +36 -5
- package/src/compiler/scaffold.ts +19 -6
- package/src/compiler/serveLanguage.ts +156 -21
- package/src/compiler/templateContext.ts +150 -0
- package/src/compiler/tokenize.ts +6 -0
- package/src/server/BRIDGE_VERSION.ts +1 -1
- package/src/server/Inbox.ts +8 -0
- package/src/server/MCP_PROTOCOL_VERSION.ts +1 -1
- package/src/server/MOUNTABLE.ts +6 -0
- package/src/server/abideModules.ts +8 -1
- package/src/server/answerRequest.ts +3 -1
- package/src/server/answerRpc.ts +26 -12
- package/src/server/answerSocket.ts +26 -1
- package/src/server/argsFor.ts +4 -3
- package/src/server/assistant.ts +19 -13
- package/src/server/bridgeRelay.ts +108 -20
- package/src/server/claudeApi.ts +7 -7
- package/src/server/claudeCode.ts +11 -11
- package/src/server/commands.ts +73 -9
- package/src/server/createApp.ts +41 -7
- package/src/server/devWorker.ts +3 -3
- package/src/server/generatedSurfaces.ts +37 -4
- package/src/server/muxUpgrade.ts +47 -0
- package/src/server/preload.ts +13 -2
- package/src/server/render.ts +4 -1
- package/src/server/runCompiled.ts +1 -0
- package/src/server/seedBuffer.ts +31 -1
- package/src/server/serveDev.ts +83 -72
- package/src/server/surfaceOf.ts +3 -0
- package/src/server/testMount.ts +53 -0
- package/src/shared/ADDRESSES.ts +1 -1
- package/src/shared/ERROR_META.ts +3 -0
- package/src/shared/Schema.ts +1 -1
- package/src/shared/WORK.ts +14 -12
- package/src/shared/applyPatch.ts +1 -1
- package/src/shared/boundValue.ts +81 -0
- package/src/shared/index.ts +7 -2
- package/src/shared/jsonSchema.ts +25 -2
- package/src/shared/log.ts +11 -1
- package/src/shared/reactive/Keyed.ts +30 -6
- package/src/shared/reactive/PATCHES.ts +1 -1
- package/src/shared/reactive/Reactive.ts +118 -60
- package/src/shared/reactive/canonicalKey.ts +1 -1
- package/src/shared/reactive/index.ts +13 -5
- package/src/shared/reactive/recordWrites.ts +1 -1
- package/src/shared/reactive/watch.ts +26 -4
- package/src/shared/rpc.ts +6 -1
- package/src/shared/url.ts +7 -3
- package/src/shared/validateJson.ts +1 -1
- package/src/ui/ArmRegion.ts +2 -2
- package/src/ui/List.ts +9 -9
- package/src/ui/RawRun.ts +1 -1
- package/src/ui/Router.ts +194 -36
- package/src/ui/Scope.ts +29 -0
- package/src/ui/adoptWalk.ts +3 -3
- package/src/ui/applyHead.ts +25 -3
- package/src/ui/bindProperty.ts +70 -22
- package/src/ui/index.ts +1 -1
- package/src/ui/listenForRoutes.ts +14 -3
- package/src/ui/reconcile.ts +23 -28
- package/src/ui/regions.ts +9 -2
- package/src/ui/rows.ts +108 -28
package/src/ui/Router.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CURRENT_ROUTER, type NavigateOptions } from '#shared/CURRENT_ROUTER.ts'
|
|
2
2
|
import { decodedOrKept } from '#shared/decodedOrKept.ts'
|
|
3
|
+
import { ERROR_META } from '#shared/ERROR_META.ts'
|
|
3
4
|
import { firstMatch, type Params, partsOf } from '#shared/matchRoute.ts'
|
|
4
5
|
import { unmounted } from '#shared/mount.ts'
|
|
5
6
|
import { NAVIGATION_HEADER } from '#shared/NAVIGATION_HEADER.ts'
|
|
@@ -51,6 +52,29 @@ type Compiled = Route & { segments: Segment[] }
|
|
|
51
52
|
|
|
52
53
|
type Found = { route: Compiled; params: Params }
|
|
53
54
|
|
|
55
|
+
/** A window's scroll, `[scrollX, scrollY]`. */
|
|
56
|
+
type Scroll = readonly [number, number]
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What the router writes to `history.state`: the entry's place in this document's history, and the
|
|
60
|
+
* scroll it was left at where the router could write it before leaving (30.29, 30.30).
|
|
61
|
+
*/
|
|
62
|
+
type Entry = { index: number; scroll: Scroll | null }
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* How a navigation arrives at its entry: the view transition type it carries (39.18), and the scroll
|
|
66
|
+
* it restores on a history step (30.29).
|
|
67
|
+
*/
|
|
68
|
+
type Step = {
|
|
69
|
+
direction: 'push' | 'back' | 'forward'
|
|
70
|
+
scroll: Scroll | undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const PUSHED: Step = { direction: 'push', scroll: undefined }
|
|
74
|
+
|
|
75
|
+
/** What a superseded navigation's ticket is aborted with. */
|
|
76
|
+
const SUPERSEDED = 'superseded'
|
|
77
|
+
|
|
54
78
|
/**
|
|
55
79
|
* The client router: matches addresses, and mounts and replaces the page chain.
|
|
56
80
|
*
|
|
@@ -83,19 +107,36 @@ export class Router {
|
|
|
83
107
|
*/
|
|
84
108
|
standing: Compiled | undefined
|
|
85
109
|
/**
|
|
86
|
-
* The ticket of the live navigation; a superseded one stops when it sees
|
|
110
|
+
* The ticket of the live navigation; a superseded one stops when it sees its own aborted.
|
|
87
111
|
*
|
|
88
112
|
* Both ways in await the destination's module before they touch anything, and a dynamic import
|
|
89
113
|
* resolves in whatever order the chunks arrive — so two clicks, or a click landing while
|
|
90
114
|
* `hydrate` is still loading, each mounted a page: the second one to finish overwrote the
|
|
91
115
|
* standing scope and the first page's bindings were left awake, writing into nodes the other
|
|
92
116
|
* page had replaced. Nothing about the screen showed it — the newest markup was on top — which
|
|
93
|
-
* is why
|
|
117
|
+
* is why each navigation takes a ticket of its own rather than sharing a flag.
|
|
94
118
|
*
|
|
95
119
|
* TAKEN BEFORE THE AWAIT AND CHECKED AFTER IT, so a superseded navigation stops before it
|
|
96
120
|
* stops a scope, empties the region or pushes a history entry for a page nobody will see.
|
|
121
|
+
*
|
|
122
|
+
* 30.31 — AN `AbortController`, so superseding a navigation also aborts the `middleware` request
|
|
123
|
+
* it is still waiting on, rather than leaving it to finish for nobody.
|
|
124
|
+
*/
|
|
125
|
+
navigation: AbortController
|
|
126
|
+
/**
|
|
127
|
+
* 35.18 — whether the document is an error page's. Its markup is no page this router has, so
|
|
128
|
+
* nothing hydrates over it, and leaving it is a document load.
|
|
97
129
|
*/
|
|
98
|
-
|
|
130
|
+
errorPage: boolean
|
|
131
|
+
/** The place in this document's history of the entry the address bar stands at (`Entry`). */
|
|
132
|
+
index: number
|
|
133
|
+
/**
|
|
134
|
+
* 30.29 — the scroll each entry was left at, by its `index`.
|
|
135
|
+
*
|
|
136
|
+
* HELD HERE AS WELL AS IN `history.state`, because a back or forward step has already moved the
|
|
137
|
+
* bar when `popstate` arrives, so the entry it left can no longer be written to.
|
|
138
|
+
*/
|
|
139
|
+
scrolls: (Scroll | undefined)[]
|
|
99
140
|
/**
|
|
100
141
|
* The routes whose chunks `prefetch` has already asked for.
|
|
101
142
|
*
|
|
@@ -117,7 +158,16 @@ export class Router {
|
|
|
117
158
|
this.chain = []
|
|
118
159
|
this.mounted = undefined
|
|
119
160
|
this.standing = undefined
|
|
120
|
-
this.navigation =
|
|
161
|
+
this.navigation = new AbortController()
|
|
162
|
+
this.errorPage =
|
|
163
|
+
document.querySelector(`meta[name="${ERROR_META}"]`) !== null
|
|
164
|
+
const state = history.state as Partial<Entry> | null
|
|
165
|
+
if (typeof state?.index === 'number') this.index = state.index
|
|
166
|
+
else {
|
|
167
|
+
this.index = 0
|
|
168
|
+
history.replaceState(entryAt(this.index, null), '')
|
|
169
|
+
}
|
|
170
|
+
this.scrolls = []
|
|
121
171
|
this.prefetched = new Set()
|
|
122
172
|
this.transitions = transitions
|
|
123
173
|
}
|
|
@@ -162,12 +212,16 @@ export class Router {
|
|
|
162
212
|
* fills itself from the template it carries. One module, both ways in — the `fillEmpty`
|
|
163
213
|
* the emitter already writes is the whole of the difference.
|
|
164
214
|
*/
|
|
165
|
-
async go(
|
|
215
|
+
async go(
|
|
216
|
+
url: URL | string,
|
|
217
|
+
options: NavigateOptions = {},
|
|
218
|
+
step: Step = PUSHED,
|
|
219
|
+
): Promise<void> {
|
|
166
220
|
const target =
|
|
167
221
|
typeof url === 'string' ? new URL(url, location.href) : url
|
|
168
222
|
// A CROSS-ORIGIN ADDRESS IS NOT A ROUTE. Handing it to `pushState` throws, and treating
|
|
169
223
|
// it as a miss would leave the caller with a navigation that silently did nothing.
|
|
170
|
-
if (target.origin !== location.origin) {
|
|
224
|
+
if (target.origin !== location.origin || this.errorPage) {
|
|
171
225
|
location.href = target.href
|
|
172
226
|
return
|
|
173
227
|
}
|
|
@@ -182,11 +236,10 @@ export class Router {
|
|
|
182
236
|
// the same address. The page reads its address through `route`, so it follows rather than
|
|
183
237
|
// being rebuilt, and there is no path for `middleware` to decide anything new about.
|
|
184
238
|
if (found.route === this.standing && this.mounted === target.pathname) {
|
|
185
|
-
this.follow(target, options, found)
|
|
239
|
+
this.follow(target, options, found, step)
|
|
186
240
|
return
|
|
187
241
|
}
|
|
188
|
-
this.
|
|
189
|
-
const ticket = this.navigation
|
|
242
|
+
const ticket = this.supersede()
|
|
190
243
|
route.navigating.set(true)
|
|
191
244
|
// EVERY WAY OUT CLEARS WHAT THE WAY IN SET: a load that fails, a setup that throws and a
|
|
192
245
|
// navigation that completes all leave `navigating` false and every layer closed. A
|
|
@@ -195,13 +248,13 @@ export class Router {
|
|
|
195
248
|
// 30.15 — THE SERVER IS ASKED ALONGSIDE THE LOAD, so its `middleware` decides the
|
|
196
249
|
// navigation for the price of the one round trip the chunks already cost.
|
|
197
250
|
const [answer, loaded] = await Promise.all([
|
|
198
|
-
ask(target),
|
|
251
|
+
ask(target, ticket.signal),
|
|
199
252
|
loadOrMiss(found.route),
|
|
200
253
|
])
|
|
201
254
|
// SUPERSEDED, so this navigation is over. Nothing has been touched yet, which is the
|
|
202
255
|
// whole reason the ticket is checked here rather than after the mount — and
|
|
203
256
|
// `navigating` stays the newer navigation's to clear.
|
|
204
|
-
if (
|
|
257
|
+
if (ticket.signal.aborted) return
|
|
205
258
|
// 30.18 — A REFUSAL, A RESPONSE MIDDLEWARE ANSWERED ITSELF, or no answer at all: the
|
|
206
259
|
// document asks again and paints whatever the server says. A fetch that failed —
|
|
207
260
|
// offline, or a redirect to another origin that CORS will not let it follow — goes to
|
|
@@ -226,18 +279,18 @@ export class Router {
|
|
|
226
279
|
// 30.23 — THE ROUTE STANDING, AT ANOTHER PATH: `middleware` has said yes to the path, and
|
|
227
280
|
// the page it matched is the one already mounted, so it follows.
|
|
228
281
|
if (landing.route === this.standing) {
|
|
229
|
-
this.follow(landed, options, landing)
|
|
282
|
+
this.follow(landed, options, landing, step)
|
|
230
283
|
return
|
|
231
284
|
}
|
|
232
285
|
const arriving =
|
|
233
286
|
landing === found ? loaded : await loadOrMiss(landing.route)
|
|
234
|
-
if (
|
|
287
|
+
if (ticket.signal.aborted) return
|
|
235
288
|
if (arriving === undefined) {
|
|
236
289
|
location.href = landed.href
|
|
237
290
|
return
|
|
238
291
|
}
|
|
239
|
-
await this.write(ticket, () =>
|
|
240
|
-
this.commit(landed, landing, arriving, options),
|
|
292
|
+
await this.write(ticket, step.direction, () =>
|
|
293
|
+
this.commit(landed, landing, arriving, options, step),
|
|
241
294
|
)
|
|
242
295
|
} finally {
|
|
243
296
|
if (this.navigation === ticket) route.navigating.set(false)
|
|
@@ -250,26 +303,99 @@ export class Router {
|
|
|
250
303
|
* A STEP WITHIN THE STANDING PAGE: the address written and `route` moved, and the page, its
|
|
251
304
|
* scope and every call it made left where they are. It supersedes a navigation in flight the
|
|
252
305
|
* way a click on another link would. Back and forward arrive here with the bar already moved
|
|
253
|
-
* and `keepScroll`,
|
|
306
|
+
* and `keepScroll`, and with the scroll the entry was left at (30.29).
|
|
254
307
|
*
|
|
255
308
|
* 30.25 — THE SCROLL STAYS where no page was replaced, except to a fragment the address names:
|
|
256
309
|
* a reader choosing a season is not sent back to the top of the series.
|
|
257
310
|
*/
|
|
258
|
-
follow(
|
|
259
|
-
|
|
311
|
+
follow(
|
|
312
|
+
target: URL,
|
|
313
|
+
options: NavigateOptions,
|
|
314
|
+
found: Found,
|
|
315
|
+
step: Step,
|
|
316
|
+
): void {
|
|
317
|
+
this.supersede()
|
|
260
318
|
route.navigating.set(false)
|
|
261
319
|
// THE SAME ADDRESS REPLACES rather than pushing, which is what the browser does for a
|
|
262
320
|
// link to the fragment it is already at.
|
|
263
|
-
writeAddress(
|
|
321
|
+
this.writeAddress(
|
|
264
322
|
target,
|
|
265
323
|
options.replace === true || target.href === location.href,
|
|
324
|
+
[scrollX, scrollY],
|
|
266
325
|
)
|
|
267
326
|
arrive(target.href, found.route.pattern, found.params)
|
|
268
327
|
this.mounted = target.pathname
|
|
269
|
-
if (
|
|
328
|
+
if (step.scroll !== undefined) scrollTo(step.scroll[0], step.scroll[1])
|
|
329
|
+
else if (options.keepScroll !== true && target.hash !== '')
|
|
270
330
|
scrollToFragment(target.hash)
|
|
271
331
|
}
|
|
272
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Records a back or forward step the browser has already taken; answers how to arrive.
|
|
335
|
+
*
|
|
336
|
+
* 30.29 — THE ENTRY LEFT keeps the scroll it stood at, and the one arrived at hands back its own.
|
|
337
|
+
* 39.18 — the direction is read off the two entries' `index`. An entry with none is one the
|
|
338
|
+
* browser pushed itself — a link to a fragment — so it is stamped as the next entry, and
|
|
339
|
+
* arrived at as a push.
|
|
340
|
+
*/
|
|
341
|
+
stepped(): Step {
|
|
342
|
+
this.scrolls[this.index] = [scrollX, scrollY]
|
|
343
|
+
const state = history.state as Partial<Entry> | null
|
|
344
|
+
if (typeof state?.index !== 'number') {
|
|
345
|
+
this.index += 1
|
|
346
|
+
this.scrolls.length = this.index
|
|
347
|
+
history.replaceState(entryAt(this.index, null), '')
|
|
348
|
+
return PUSHED
|
|
349
|
+
}
|
|
350
|
+
const direction = state.index < this.index ? 'back' : 'forward'
|
|
351
|
+
this.index = state.index
|
|
352
|
+
return {
|
|
353
|
+
direction,
|
|
354
|
+
scroll: this.scrolls[state.index] ?? state.scroll ?? undefined,
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Writes the scroll the standing entry is at into its `history.state`, where a reload finds it
|
|
360
|
+
* (30.30) — the one way out of an entry the router cannot write to after it leaves.
|
|
361
|
+
*/
|
|
362
|
+
remember(scroll: Scroll = [scrollX, scrollY]): void {
|
|
363
|
+
this.scrolls[this.index] = scroll
|
|
364
|
+
history.replaceState(entryAt(this.index, scroll), '')
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Aborts the live navigation and answers the ticket of the one replacing it.
|
|
369
|
+
*
|
|
370
|
+
* ABORTED WITH A REASON, because `abort()` with none builds a `DOMException`, stack trace and
|
|
371
|
+
* all — 1.1 ms of every page load in Chromium, where hydrate supersedes a ticket nothing holds.
|
|
372
|
+
*/
|
|
373
|
+
private supersede(): AbortController {
|
|
374
|
+
this.navigation.abort(SUPERSEDED)
|
|
375
|
+
this.navigation = new AbortController()
|
|
376
|
+
return this.navigation
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Writes `target` to the address bar, pushing a new entry or replacing the standing one.
|
|
381
|
+
*
|
|
382
|
+
* 30.29 — A PUSH LEAVES AN ENTRY, so the scroll it stood at is written to it first, and every
|
|
383
|
+
* entry after it is gone from the history the push forked. `left` is that scroll, read by the
|
|
384
|
+
* caller before it wrote anything: read after a region was emptied, it forces a layout and
|
|
385
|
+
* reads the offset that layout clamped.
|
|
386
|
+
*/
|
|
387
|
+
private writeAddress(target: URL, replace: boolean, left: Scroll): void {
|
|
388
|
+
const full = `${target.pathname}${target.search}${target.hash}`
|
|
389
|
+
if (replace) {
|
|
390
|
+
history.replaceState(entryAt(this.index, null), '', full)
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
this.remember(left)
|
|
394
|
+
this.index += 1
|
|
395
|
+
this.scrolls.length = this.index
|
|
396
|
+
history.pushState(entryAt(this.index, null), '', full)
|
|
397
|
+
}
|
|
398
|
+
|
|
273
399
|
/**
|
|
274
400
|
* Runs a navigation's commit, inside a view transition where one is wanted.
|
|
275
401
|
*
|
|
@@ -280,16 +406,27 @@ export class Router {
|
|
|
280
406
|
* THE TICKET IS CHECKED AGAIN INSIDE THE CALLBACK, because the browser runs it a frame later,
|
|
281
407
|
* after capturing the outgoing page — and a transition another navigation skipped still runs
|
|
282
408
|
* its callback, which would mount the page the reader clicked away from.
|
|
409
|
+
*
|
|
410
|
+
* 39.18 — TYPED WITH THE DIRECTION, so the stylesheet decides what a back step looks like
|
|
411
|
+
* (`:active-view-transition-type(back)`). 39.19 — a browser whose transitions take no types is
|
|
412
|
+
* handed the callback alone: the options form is a `TypeError` there.
|
|
283
413
|
*/
|
|
284
|
-
private async write(
|
|
414
|
+
private async write(
|
|
415
|
+
ticket: AbortController,
|
|
416
|
+
direction: Step['direction'],
|
|
417
|
+
commit: () => void,
|
|
418
|
+
): Promise<void> {
|
|
285
419
|
if (
|
|
286
420
|
!this.transitions ||
|
|
287
421
|
typeof document.startViewTransition !== 'function'
|
|
288
422
|
)
|
|
289
423
|
return commit()
|
|
290
|
-
|
|
291
|
-
if (
|
|
292
|
-
}
|
|
424
|
+
const update = (): void => {
|
|
425
|
+
if (!ticket.signal.aborted) commit()
|
|
426
|
+
}
|
|
427
|
+
await document.startViewTransition(
|
|
428
|
+
takesTypes() ? { update, types: [direction] } : update,
|
|
429
|
+
).updateCallbackDone
|
|
293
430
|
}
|
|
294
431
|
|
|
295
432
|
/**
|
|
@@ -303,6 +440,7 @@ export class Router {
|
|
|
303
440
|
found: Found,
|
|
304
441
|
arriving: PageModule[],
|
|
305
442
|
options: NavigateOptions,
|
|
443
|
+
step: Step,
|
|
306
444
|
): void {
|
|
307
445
|
// OPENED WHERE THE NAVIGATION COMMITS, which is after the last early exit and after the
|
|
308
446
|
// `await` above. The awaited load is a NETWORK wait, and a layer that contained it would
|
|
@@ -322,6 +460,7 @@ export class Router {
|
|
|
322
460
|
shared,
|
|
323
461
|
this.levels.length,
|
|
324
462
|
)
|
|
463
|
+
const left: Scroll = [scrollX, scrollY]
|
|
325
464
|
this.stopFrom(shared)
|
|
326
465
|
// The region the arriving part goes into: the page region where nothing is kept, and
|
|
327
466
|
// the deepest kept layout's own slot where something is.
|
|
@@ -336,7 +475,7 @@ export class Router {
|
|
|
336
475
|
// build is this removal, and it carries the 41.10 refusal for a region no longer in a
|
|
337
476
|
// document that a hand-written walk swallows.
|
|
338
477
|
if (into !== undefined) fillRegion(into)
|
|
339
|
-
writeAddress(target, options.replace === true)
|
|
478
|
+
this.writeAddress(target, options.replace === true, left)
|
|
340
479
|
// 39.6 — each arriving level applies its own head as it mounts into its emptied region,
|
|
341
480
|
// outermost first; a kept layout is not mounted again, so neither is its head.
|
|
342
481
|
this.mountChain(target, found, arriving, shared, into)
|
|
@@ -346,8 +485,11 @@ export class Router {
|
|
|
346
485
|
Level.headsOf(this.levels, 0, shared),
|
|
347
486
|
Level.headsOf(this.levels, shared, this.levels.length),
|
|
348
487
|
])
|
|
349
|
-
//
|
|
350
|
-
|
|
488
|
+
// 30.29 — where the entry was left, on a history step; else the top of the page, or —
|
|
489
|
+
// as the browser does on a load — the fragment's element.
|
|
490
|
+
if (step.scroll !== undefined)
|
|
491
|
+
scrollTo(step.scroll[0], step.scroll[1])
|
|
492
|
+
else if (options.keepScroll !== true) scrollToFragment(target.hash)
|
|
351
493
|
} finally {
|
|
352
494
|
endLayer(LAYER_NAVIGATE)
|
|
353
495
|
}
|
|
@@ -482,14 +624,13 @@ export class Router {
|
|
|
482
624
|
* second rendering of it.
|
|
483
625
|
*/
|
|
484
626
|
async hydrate(): Promise<void> {
|
|
485
|
-
const found = this.match(location.pathname)
|
|
627
|
+
const found = this.errorPage ? undefined : this.match(location.pathname)
|
|
486
628
|
if (found === undefined) return
|
|
487
629
|
// A TICKET LIKE ANY OTHER NAVIGATION. `listenForRoutes` is attached before this is called,
|
|
488
630
|
// so a click during the page module's own load is a `go` that supersedes it — and
|
|
489
631
|
// hydrating on top of the markup that navigation had already put in the region wired the
|
|
490
632
|
// arriving page's bindings to the departing page's nodes.
|
|
491
|
-
this.
|
|
492
|
-
const ticket = this.navigation
|
|
633
|
+
const ticket = this.supersede()
|
|
493
634
|
// NO HEAD IS APPLIED FOR A PAINTED PAGE: its region is not empty, so its mount hydrates
|
|
494
635
|
// rather than fills, and the head is the one the server wrote. Re-applying would replace
|
|
495
636
|
// each contribution with an identical copy — a `<link rel=stylesheet>` refetched and a
|
|
@@ -499,10 +640,13 @@ export class Router {
|
|
|
499
640
|
const landing = paintedCallsLanded()
|
|
500
641
|
const standing = await load(found.route)
|
|
501
642
|
if (landing !== undefined) await landing
|
|
502
|
-
if (
|
|
643
|
+
if (ticket.signal.aborted) return
|
|
503
644
|
// THE HYDRATE SHARE. Same name as the navigation's mount because it is the same work on the
|
|
504
645
|
// same chain — what differs is the op it is a fraction of.
|
|
505
646
|
this.mountChain(new URL(location.href), found, standing, 0, this.region)
|
|
647
|
+
// 30.30 — A RELOAD lands on an entry that carries the scroll `remember` wrote on the way out.
|
|
648
|
+
const scroll = (history.state as Partial<Entry> | null)?.scroll
|
|
649
|
+
if (scroll != null) scrollTo(scroll[0], scroll[1])
|
|
506
650
|
}
|
|
507
651
|
|
|
508
652
|
/**
|
|
@@ -559,11 +703,15 @@ function loadOrMiss(route: Compiled): Promise<PageModule[] | undefined> {
|
|
|
559
703
|
* is followed here, carrying the header, so the answer at the end of it is the server's go-ahead
|
|
560
704
|
* for the address it landed on.
|
|
561
705
|
*/
|
|
562
|
-
async function ask(
|
|
706
|
+
async function ask(
|
|
707
|
+
target: URL,
|
|
708
|
+
signal: AbortSignal,
|
|
709
|
+
): Promise<Response | undefined> {
|
|
563
710
|
try {
|
|
564
711
|
return await fetch(target, {
|
|
565
712
|
headers: { [NAVIGATION_HEADER]: '1' },
|
|
566
713
|
cache: 'no-store',
|
|
714
|
+
signal,
|
|
567
715
|
})
|
|
568
716
|
} catch {
|
|
569
717
|
return undefined
|
|
@@ -585,10 +733,20 @@ async function ask(target: URL): Promise<Response | undefined> {
|
|
|
585
733
|
*/
|
|
586
734
|
const ROUTE_PROPS = { params: (): Params => route.params() }
|
|
587
735
|
|
|
588
|
-
function
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
736
|
+
function entryAt(index: number, scroll: Scroll | null): Entry {
|
|
737
|
+
return { index, scroll }
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Whether this browser's view transitions take `types` (39.19).
|
|
742
|
+
*
|
|
743
|
+
* Asked of `ViewTransition`'s prototype, where a browser that knows types puts the member.
|
|
744
|
+
*/
|
|
745
|
+
function takesTypes(): boolean {
|
|
746
|
+
return (
|
|
747
|
+
typeof ViewTransition === 'function' &&
|
|
748
|
+
'types' in ViewTransition.prototype
|
|
749
|
+
)
|
|
592
750
|
}
|
|
593
751
|
|
|
594
752
|
/**
|
package/src/ui/Scope.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type Disposer,
|
|
3
3
|
type Effect,
|
|
4
|
+
rerunWatch,
|
|
4
5
|
watchTracked,
|
|
5
6
|
} from '#shared/reactive/watch.ts'
|
|
6
7
|
|
|
@@ -54,6 +55,13 @@ export class Scope {
|
|
|
54
55
|
* 32.16 — read once at construction.
|
|
55
56
|
*/
|
|
56
57
|
boundary: Boundary | undefined = BOUNDARY.current
|
|
58
|
+
/**
|
|
59
|
+
* 32.67 — where in `stops` a row's item watches start (`itemWatch`), and how many there are:
|
|
60
|
+
* opened one after another by a row's mount, so two numbers find them and the row allocates
|
|
61
|
+
* nothing to be found by.
|
|
62
|
+
*/
|
|
63
|
+
itemsAt = 0
|
|
64
|
+
items = 0
|
|
57
65
|
|
|
58
66
|
watch(effect: Effect): void {
|
|
59
67
|
const boundary = this.boundary
|
|
@@ -66,6 +74,26 @@ export class Scope {
|
|
|
66
74
|
this.stops.push(watchTracked(() => boundary.guard(effect, effect)))
|
|
67
75
|
}
|
|
68
76
|
|
|
77
|
+
/**
|
|
78
|
+
* A watch over a row's item, which reads it off the row's record until the row has a cell
|
|
79
|
+
* (`itemOf`) — `rerunItems` reruns it when the row takes its first new item.
|
|
80
|
+
*
|
|
81
|
+
* A row's mount opens these one after another, so they stand together in `stops`.
|
|
82
|
+
*/
|
|
83
|
+
itemWatch(effect: Effect): void {
|
|
84
|
+
if (this.items === 0) this.itemsAt = this.stops.length
|
|
85
|
+
this.items += 1
|
|
86
|
+
this.watch(effect)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Reruns every `itemWatch`, each reading — and subscribing to — the row's cell this time. */
|
|
90
|
+
rerunItems(): void {
|
|
91
|
+
const stops = this.stops
|
|
92
|
+
const end = this.itemsAt + this.items
|
|
93
|
+
for (let index = this.itemsAt; index < end; index += 1)
|
|
94
|
+
rerunWatch(stops[index] as Disposer)
|
|
95
|
+
}
|
|
96
|
+
|
|
69
97
|
/**
|
|
70
98
|
* Takes ownership of a child scope or list; answers the child it was handed.
|
|
71
99
|
*
|
|
@@ -99,6 +127,7 @@ export class Scope {
|
|
|
99
127
|
for (let index = from; index < stops.length; index += 1)
|
|
100
128
|
(stops[index] as Disposer)()
|
|
101
129
|
stops.length = from
|
|
130
|
+
if (from <= this.itemsAt) this.items = 0
|
|
102
131
|
const children = this.children
|
|
103
132
|
if (children === undefined) return
|
|
104
133
|
for (const child of children) child.stop()
|
package/src/ui/adoptWalk.ts
CHANGED
|
@@ -39,7 +39,7 @@ export function elementHere(
|
|
|
39
39
|
(node as Element).localName !== tag
|
|
40
40
|
)
|
|
41
41
|
throw new Mismatch(
|
|
42
|
-
`abide: expected <${tag}> where the client walked, so the client and the server disagree about this region's shape
|
|
42
|
+
`abide: expected <${tag}> where the client walked, so the client and the server disagree about this region's shape. RULEBOOK 41.10.`,
|
|
43
43
|
)
|
|
44
44
|
return node as Element
|
|
45
45
|
}
|
|
@@ -61,7 +61,7 @@ export function textHere(node: Node | null | undefined): Text | RawRun {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
throw new Mismatch(
|
|
64
|
-
"abide: text was due where the server wrote none, so the client and the server disagree about this region's shape
|
|
64
|
+
"abide: text was due where the server wrote none, so the client and the server disagree about this region's shape. RULEBOOK 41.10.",
|
|
65
65
|
)
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -120,7 +120,7 @@ export function regionHere(node: Node | null | undefined): Region | undefined {
|
|
|
120
120
|
export function regionFound(region: Region | undefined): Region {
|
|
121
121
|
if (region === undefined)
|
|
122
122
|
throw new Mismatch(
|
|
123
|
-
"abide: a region was due where the client walked, so the client and the server disagree about this region's shape
|
|
123
|
+
"abide: a region was due where the client walked, so the client and the server disagree about this region's shape. RULEBOOK 41.10.",
|
|
124
124
|
)
|
|
125
125
|
return region
|
|
126
126
|
}
|
package/src/ui/applyHead.ts
CHANGED
|
@@ -45,11 +45,11 @@ export function applyHead(
|
|
|
45
45
|
if (key === undefined) {
|
|
46
46
|
const standing = replaced[next]
|
|
47
47
|
next += 1
|
|
48
|
-
if (standing === undefined) head.appendChild(node)
|
|
48
|
+
if (standing === undefined) head.appendChild(runnable(node))
|
|
49
49
|
// The same node left standing, as a key's is: a `<style>` put back as itself is not
|
|
50
|
-
// re-parsed into the cascade.
|
|
50
|
+
// re-parsed into the cascade, and a `<script>` is not run twice.
|
|
51
51
|
else if (!standing.isEqualNode(node))
|
|
52
|
-
head.replaceChild(node, standing)
|
|
52
|
+
head.replaceChild(runnable(node), standing)
|
|
53
53
|
continue
|
|
54
54
|
}
|
|
55
55
|
const element = node as Element
|
|
@@ -78,6 +78,28 @@ export function applyHead(
|
|
|
78
78
|
restoreKeys(head, withdrawn, outer())
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* `node`, or — for a `<script>` — the same script made by the document rather than the parser.
|
|
83
|
+
*
|
|
84
|
+
* 39.17 — A PARSED SCRIPT NEVER RUNS. The fragment parser marks every `<script>` it makes "already
|
|
85
|
+
* started", and `parseMarkup` is that parser, so a script it made and then moved into the head is
|
|
86
|
+
* inert where the server's, made by the document's own parser, ran. One made by `createElement`
|
|
87
|
+
* starts clear and runs on insertion. Keyless always (39.11 keys no `<script>`), so this is reached
|
|
88
|
+
* only where a keyless contribution is actually written.
|
|
89
|
+
*/
|
|
90
|
+
function runnable(node: Node): Node {
|
|
91
|
+
if (node.nodeName !== 'SCRIPT') return node
|
|
92
|
+
const parsed = node as HTMLScriptElement
|
|
93
|
+
const script = document.createElement('script')
|
|
94
|
+
const attributes = parsed.attributes
|
|
95
|
+
for (let index = 0; index < attributes.length; index += 1) {
|
|
96
|
+
const attribute = attributes[index] as Attr
|
|
97
|
+
script.setAttribute(attribute.name, attribute.value)
|
|
98
|
+
}
|
|
99
|
+
script.text = parsed.text
|
|
100
|
+
return script
|
|
101
|
+
}
|
|
102
|
+
|
|
81
103
|
/**
|
|
82
104
|
* Puts back, or removes, what the leaving levels keyed and the arriving ones do not.
|
|
83
105
|
*
|
package/src/ui/bindProperty.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// 32.5, 32.7, 32.8 — AN ELEMENT'S PROPERTY AND A VALUE, KEPT IN STEP BOTH WAYS. A watch writes the
|
|
2
2
|
// value into the property; a listener writes the property back into the value. The server wrote the
|
|
3
|
-
// value standing at render as the attribute
|
|
4
|
-
// already right.
|
|
3
|
+
// value standing at render as the attribute, or as a `<textarea>`'s text and an option's `selected`
|
|
4
|
+
// (32.61), so hydration moves nothing that is already right.
|
|
5
5
|
//
|
|
6
6
|
// 32.7 — A BOOLEAN BIND IS A BOOLEAN: the property is set to one, and mirrored as a boolean attribute
|
|
7
7
|
// rather than written as a string.
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
boundValue,
|
|
11
11
|
groupHolds,
|
|
12
12
|
heldValue,
|
|
13
|
+
selectHolds,
|
|
13
14
|
writeBound,
|
|
14
15
|
} from '#shared/boundValue.ts'
|
|
15
16
|
import { numberFromText } from '#shared/numberFromText.ts'
|
|
@@ -25,26 +26,7 @@ export function bindProperty(
|
|
|
25
26
|
): void {
|
|
26
27
|
const bound = element as Bound
|
|
27
28
|
if (kind === 'value') {
|
|
28
|
-
|
|
29
|
-
const value = boundValue(operand)
|
|
30
|
-
const text =
|
|
31
|
-
value === undefined || value === null ? '' : String(value)
|
|
32
|
-
if (bound.value !== text) bound.value = text
|
|
33
|
-
})
|
|
34
|
-
// A `<select>` settles on `change`; everything else that takes text reports each keystroke.
|
|
35
|
-
const event = bound.tagName === 'SELECT' ? 'change' : 'input'
|
|
36
|
-
bound.addEventListener(event, () => {
|
|
37
|
-
// A value that was a number stays one: what the reader typed is text, and the value is
|
|
38
|
-
// not the reader's to retype.
|
|
39
|
-
const standing = heldValue(operand)
|
|
40
|
-
const typed = bound.value
|
|
41
|
-
writeBound(
|
|
42
|
-
operand,
|
|
43
|
-
typeof standing === 'number'
|
|
44
|
-
? (numberFromText(typed) ?? typed)
|
|
45
|
-
: typed,
|
|
46
|
-
)
|
|
47
|
-
})
|
|
29
|
+
bindValue(bound, operand, scope)
|
|
48
30
|
return
|
|
49
31
|
}
|
|
50
32
|
if (kind === 'group') {
|
|
@@ -77,3 +59,69 @@ export function bindProperty(
|
|
|
77
59
|
writeBound(operand, bound[property])
|
|
78
60
|
})
|
|
79
61
|
}
|
|
62
|
+
|
|
63
|
+
// 32.5 — `bind:value`, on an input, a `<textarea>` or a `<select>`.
|
|
64
|
+
function bindValue(bound: Bound, operand: unknown, scope: Scope): void {
|
|
65
|
+
if (
|
|
66
|
+
bound.tagName === 'SELECT' &&
|
|
67
|
+
(bound as unknown as HTMLSelectElement).multiple
|
|
68
|
+
) {
|
|
69
|
+
// 32.62 — the list of what is selected, read and written per option.
|
|
70
|
+
const select = bound as unknown as HTMLSelectElement
|
|
71
|
+
scope.watch(() => {
|
|
72
|
+
const value = boundValue(operand)
|
|
73
|
+
for (const option of select.options)
|
|
74
|
+
option.selected = selectHolds(value, option.value)
|
|
75
|
+
})
|
|
76
|
+
select.addEventListener('change', () => {
|
|
77
|
+
const chosen: string[] = []
|
|
78
|
+
for (const option of select.selectedOptions)
|
|
79
|
+
chosen.push(option.value)
|
|
80
|
+
writeBound(operand, chosen)
|
|
81
|
+
})
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
// 32.58 — NUMERIC ONCE, NUMERIC FROM THEN ON: a blank field writes `undefined`, and the
|
|
85
|
+
// keystroke after it still has to know the value was a number.
|
|
86
|
+
let numeric = bound.type === 'number' || bound.type === 'range'
|
|
87
|
+
scope.watch(() => {
|
|
88
|
+
const value = boundValue(operand)
|
|
89
|
+
if (typeof value === 'number') numeric = true
|
|
90
|
+
// 32.59 — text that already reads as the value is the reader's spelling of it: a trailing
|
|
91
|
+
// decimal point or zero is mid-typing, and rewriting it to the number's own text takes the
|
|
92
|
+
// keystroke back.
|
|
93
|
+
if (typeof value === 'number' && value === numberFromText(bound.value))
|
|
94
|
+
return
|
|
95
|
+
const text = value === undefined || value === null ? '' : String(value)
|
|
96
|
+
if (bound.value !== text) bound.value = text
|
|
97
|
+
})
|
|
98
|
+
const write = () => {
|
|
99
|
+
const typed = bound.value
|
|
100
|
+
if (!numeric) {
|
|
101
|
+
writeBound(operand, typed)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
// 32.58 — text that reads as no number, `-` on its way to `-3`, writes nothing.
|
|
105
|
+
const number = numberFromText(typed)
|
|
106
|
+
if (number !== undefined) writeBound(operand, number)
|
|
107
|
+
else if (typed.trim() === '') writeBound(operand, undefined)
|
|
108
|
+
}
|
|
109
|
+
// A `<select>` settles on `change`; everything else that takes text reports each keystroke.
|
|
110
|
+
if (bound.tagName === 'SELECT') {
|
|
111
|
+
bound.addEventListener('change', write)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
// 32.60 — A COMPOSITION IS ONE WRITE: what an input method shows between its start and its end
|
|
115
|
+
// is not text the reader has chosen yet.
|
|
116
|
+
let composing = false
|
|
117
|
+
bound.addEventListener('compositionstart', () => {
|
|
118
|
+
composing = true
|
|
119
|
+
})
|
|
120
|
+
bound.addEventListener('compositionend', () => {
|
|
121
|
+
composing = false
|
|
122
|
+
write()
|
|
123
|
+
})
|
|
124
|
+
bound.addEventListener('input', () => {
|
|
125
|
+
if (!composing) write()
|
|
126
|
+
})
|
|
127
|
+
}
|
package/src/ui/index.ts
CHANGED
|
@@ -56,7 +56,7 @@ export {
|
|
|
56
56
|
type Region,
|
|
57
57
|
} from './regions.ts'
|
|
58
58
|
export { rowKeyOf } from './rowKeyOf.ts'
|
|
59
|
-
export { replaceRow } from './rows.ts'
|
|
59
|
+
export { itemOf, noUpdate, replaceRow, rowCell } from './rows.ts'
|
|
60
60
|
export { EMPTY_SCOPE, Scope } from './Scope.ts'
|
|
61
61
|
export { setAttribute } from './setAttribute.ts'
|
|
62
62
|
export { setClass } from './setClass.ts'
|