@abide/abide 0.105.1 → 0.107.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/bin/abide.ts +4 -1
  3. package/docs/REGISTRY.md +156 -44
  4. package/docs/RULEBOOK.md +392 -88
  5. package/docs/abide.md +7245 -10248
  6. package/package.json +4 -1
  7. package/scaffold/AGENTS.md +2 -0
  8. package/scaffold/src/getGreeting.ts +6 -0
  9. package/scaffold/src/index.abide +3 -1
  10. package/scaffold/src/tests/getGreeting.test.ts +6 -0
  11. package/scaffold/src/tests/index.test.ts +18 -0
  12. package/src/compiler/DOCS_SITE.ts +2 -0
  13. package/src/compiler/Lowering.ts +234 -14
  14. package/src/compiler/PositionTable.ts +14 -7
  15. package/src/compiler/PositionedRefusal.ts +13 -0
  16. package/src/compiler/Refusals.ts +18 -11
  17. package/src/compiler/SHARED_NAMES.ts +2 -0
  18. package/src/compiler/ShadowMap.ts +37 -0
  19. package/src/compiler/TEMPLATE_BLOCKS.ts +12 -0
  20. package/src/compiler/TypeSession.ts +410 -31
  21. package/src/compiler/appLayout.ts +18 -2
  22. package/src/compiler/argsSchemaOf.ts +55 -9
  23. package/src/compiler/checkApp.ts +33 -0
  24. package/src/compiler/checkedShadow.ts +36 -22
  25. package/src/compiler/citedClauses.ts +14 -0
  26. package/src/compiler/classifyRead.ts +6 -0
  27. package/src/compiler/classifyTemplate.ts +7 -4
  28. package/src/compiler/compileApp.ts +2 -2
  29. package/src/compiler/declaredExports.ts +27 -6
  30. package/src/compiler/emitClient.ts +306 -103
  31. package/src/compiler/handlersUnder.ts +4 -0
  32. package/src/compiler/invocationProps.ts +1 -1
  33. package/src/compiler/isIgnored.ts +27 -0
  34. package/src/compiler/parseAbide.ts +176 -76
  35. package/src/compiler/projectShadow.ts +30 -6
  36. package/src/compiler/readSources.ts +1 -1
  37. package/src/compiler/readsIn.ts +100 -1
  38. package/src/compiler/renderMarkup.ts +36 -5
  39. package/src/compiler/scaffold.ts +19 -6
  40. package/src/compiler/serveLanguage.ts +156 -21
  41. package/src/compiler/templateContext.ts +150 -0
  42. package/src/compiler/tokenize.ts +6 -0
  43. package/src/server/BRIDGE_VERSION.ts +1 -1
  44. package/src/server/Inbox.ts +8 -0
  45. package/src/server/MCP_PROTOCOL_VERSION.ts +1 -1
  46. package/src/server/MOUNTABLE.ts +6 -0
  47. package/src/server/abideModules.ts +8 -1
  48. package/src/server/answerRequest.ts +3 -1
  49. package/src/server/answerRpc.ts +26 -12
  50. package/src/server/answerSocket.ts +26 -1
  51. package/src/server/argsFor.ts +4 -3
  52. package/src/server/assistant.ts +21 -14
  53. package/src/server/bridgeRelay.ts +108 -20
  54. package/src/server/claudeApi.ts +19 -11
  55. package/src/server/claudeCode.ts +19 -14
  56. package/src/server/commands.ts +73 -9
  57. package/src/server/createApp.ts +41 -7
  58. package/src/server/devWorker.ts +3 -3
  59. package/src/server/generatedSurfaces.ts +37 -4
  60. package/src/server/muxUpgrade.ts +47 -0
  61. package/src/server/preload.ts +13 -2
  62. package/src/server/render.ts +4 -1
  63. package/src/server/runCompiled.ts +1 -0
  64. package/src/server/seedBuffer.ts +31 -1
  65. package/src/server/serveDev.ts +83 -72
  66. package/src/server/surfaceOf.ts +3 -0
  67. package/src/server/testMount.ts +53 -0
  68. package/src/shared/ADDRESSES.ts +1 -1
  69. package/src/shared/ERROR_META.ts +3 -0
  70. package/src/shared/Schema.ts +1 -1
  71. package/src/shared/WORK.ts +14 -12
  72. package/src/shared/applyPatch.ts +1 -1
  73. package/src/shared/boundValue.ts +81 -0
  74. package/src/shared/index.ts +7 -2
  75. package/src/shared/jsonSchema.ts +25 -2
  76. package/src/shared/log.ts +11 -1
  77. package/src/shared/reactive/Keyed.ts +30 -6
  78. package/src/shared/reactive/PATCHES.ts +1 -1
  79. package/src/shared/reactive/Reactive.ts +118 -60
  80. package/src/shared/reactive/canonicalKey.ts +1 -1
  81. package/src/shared/reactive/index.ts +13 -5
  82. package/src/shared/reactive/recordWrites.ts +1 -1
  83. package/src/shared/reactive/watch.ts +26 -4
  84. package/src/shared/rpc.ts +6 -1
  85. package/src/shared/url.ts +7 -3
  86. package/src/shared/validateJson.ts +1 -1
  87. package/src/ui/ArmRegion.ts +2 -2
  88. package/src/ui/List.ts +9 -9
  89. package/src/ui/RawRun.ts +1 -1
  90. package/src/ui/Router.ts +194 -36
  91. package/src/ui/Scope.ts +29 -0
  92. package/src/ui/adoptWalk.ts +3 -3
  93. package/src/ui/applyHead.ts +25 -3
  94. package/src/ui/bindElement.ts +4 -0
  95. package/src/ui/bindProperty.ts +70 -22
  96. package/src/ui/index.ts +1 -1
  97. package/src/ui/listenForRoutes.ts +14 -3
  98. package/src/ui/reconcile.ts +23 -28
  99. package/src/ui/regions.ts +9 -2
  100. 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 a newer ticket.
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 it is a counter rather than a flag.
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
- navigation: number
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 = 0
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(url: URL | string, options: NavigateOptions = {}): Promise<void> {
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.navigation += 1
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 (this.navigation !== ticket) return
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 (this.navigation !== ticket) return
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`, which leaves the scroll to the browser's own restoration.
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(target: URL, options: NavigateOptions, found: Found): void {
259
- this.navigation += 1
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 (options.keepScroll !== true && target.hash !== '')
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(ticket: number, commit: () => void): Promise<void> {
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
- await document.startViewTransition(() => {
291
- if (this.navigation === ticket) commit()
292
- }).updateCallbackDone
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
- // The top of the page, or — as the browser does on a load — the fragment's element.
350
- if (options.keepScroll !== true) scrollToFragment(target.hash)
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.navigation += 1
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 (this.navigation !== ticket) return
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(target: URL): Promise<Response | undefined> {
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 writeAddress(target: URL, replace: boolean): void {
589
- const full = `${target.pathname}${target.search}${target.hash}`
590
- if (replace) history.replaceState(null, '', full)
591
- else history.pushState(null, '', full)
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()
@@ -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 (41.10).`,
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 (41.10).",
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 (41.10).",
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
  }
@@ -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
  *
@@ -21,6 +21,10 @@ import { holdWhileLeaving } from './leave.ts'
21
21
  * form is where teardown is decided, and its `Disposer` is the whole of it.
22
22
  *
23
23
  * 32.38 — A `Disposer` HANDING BACK A THENABLE IS AN EXIT: the element's nodes stay until it settles.
24
+ *
25
+ * 32.68 — THE NODE IS CONNECTED AND WHOLE, and nothing here makes it so: this runs from a region's
26
+ * MOUNT, which every caller (`ArmRegion`, `TryRegion`, `makeRow`) calls after `build`'s nodes are
27
+ * inserted, and last in it, after the blocks and components inside the node (`emitRegion`).
24
28
  */
25
29
  export function bindElement(
26
30
  element: Element,
@@ -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 (`emitComponent`), so hydration moves nothing that is
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
- scope.watch(() => {
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
+ }