@abide/abide 0.38.1 → 0.39.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 (54) hide show
  1. package/AGENTS.md +3 -3
  2. package/CHANGELOG.md +16 -0
  3. package/package.json +9 -1
  4. package/src/build.ts +14 -0
  5. package/src/lib/bundle/exitWithParent.ts +4 -2
  6. package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
  7. package/src/lib/server/runtime/createAppAssetServer.ts +37 -11
  8. package/src/lib/server/runtime/createPublicAssetServer.ts +14 -5
  9. package/src/lib/server/runtime/createUiPageRenderer.ts +53 -42
  10. package/src/lib/server/runtime/internalErrorResponse.ts +5 -2
  11. package/src/lib/server/runtime/snapshotEntryFromCache.ts +4 -1
  12. package/src/lib/server/sockets/createSocketDispatcher.ts +7 -0
  13. package/src/lib/shared/createRemoteFunction.ts +20 -11
  14. package/src/lib/shared/escapeHtml.ts +15 -0
  15. package/src/lib/shared/markFrameworkSourcesIgnored.ts +47 -0
  16. package/src/lib/shared/streamResponse.ts +8 -1
  17. package/src/lib/shared/types/RemoteCallable.ts +12 -3
  18. package/src/lib/shared/types/RpcOptions.ts +22 -0
  19. package/src/lib/shared/types/SourceMap.ts +14 -0
  20. package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
  21. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +5 -0
  22. package/src/lib/ui/compile/compileModule.ts +5 -2
  23. package/src/lib/ui/compile/compileSSR.ts +32 -9
  24. package/src/lib/ui/compile/compileShadow.ts +11 -3
  25. package/src/lib/ui/compile/composeProps.ts +53 -0
  26. package/src/lib/ui/compile/desugarSignals.ts +45 -17
  27. package/src/lib/ui/compile/generateBuild.ts +46 -18
  28. package/src/lib/ui/compile/generateSSR.ts +196 -52
  29. package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
  30. package/src/lib/ui/compile/parseTemplate.ts +44 -1
  31. package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
  32. package/src/lib/ui/compile/staticAttr.ts +1 -1
  33. package/src/lib/ui/compile/staticTextPart.ts +1 -1
  34. package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
  35. package/src/lib/ui/compile/types/TemplateNode.ts +3 -1
  36. package/src/lib/ui/dom/mergeProps.ts +32 -0
  37. package/src/lib/ui/dom/readCall.ts +27 -0
  38. package/src/lib/ui/dom/restProps.ts +32 -0
  39. package/src/lib/ui/dom/spreadAttrs.ts +34 -0
  40. package/src/lib/ui/dom/spreadProps.ts +32 -0
  41. package/src/lib/ui/installHotBridge.ts +10 -0
  42. package/src/lib/ui/remoteProxy.ts +68 -36
  43. package/src/lib/ui/renderChain.ts +39 -37
  44. package/src/lib/ui/renderToStream.ts +69 -61
  45. package/src/lib/ui/resumeSeedScript.ts +17 -0
  46. package/src/lib/ui/router.ts +81 -51
  47. package/src/lib/ui/runtime/createEffectNode.ts +5 -0
  48. package/src/lib/ui/runtime/localStoragePersistence.ts +8 -1
  49. package/src/lib/ui/runtime/toTeardown.ts +10 -5
  50. package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
  51. package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
  52. package/src/lib/ui/runtime/types/UiComponent.ts +7 -1
  53. package/src/lib/ui/compile/escapeHtml.ts +0 -15
  54. /package/src/lib/{server/runtime → shared}/safeJsonForScript.ts +0 -0
@@ -224,8 +224,10 @@ export function router(
224
224
  return
225
225
  }
226
226
  const target = event.target as Element
227
+ /* `closest?.` is undefined when the target is a non-Element (text node, document)
228
+ that has no `closest`; `== null` catches both that and a genuine no-match null. */
227
229
  const link = target.closest?.('a[href]') as HTMLAnchorElement | null
228
- if (link === null) {
230
+ if (link == null) {
229
231
  return
230
232
  }
231
233
  /* Defer to the browser for links it should own: a new-tab target, a
@@ -341,76 +343,104 @@ export function router(
341
343
  resolvePage(key),
342
344
  Promise.all(chainKeys.map((layoutKey) => resolveLayout(layoutKey))),
343
345
  verdict,
344
- ]).then(([pageView, resolvedLayouts, decision]) => {
345
- if (token !== sequence || disposed) {
346
- return
347
- }
348
- /* handle() redirected: go where it pointed, replacing the blocked
346
+ ])
347
+ .then(([pageView, resolvedLayouts, decision]) => {
348
+ if (token !== sequence || disposed) {
349
+ return
350
+ }
351
+ /* handle() redirected: go where it pointed, replacing the blocked
349
352
  URL so back doesn't trap on it. The router re-probes the target. */
350
- if (decision.kind === 'redirect') {
351
- navigate(decision.path, true)
352
- return
353
- }
354
- /* handle() blocked it / redirected off-origin / the probe failed:
353
+ if (decision.kind === 'redirect') {
354
+ navigate(decision.path, true)
355
+ return
356
+ }
357
+ /* handle() blocked it / redirected off-origin / the probe failed:
355
358
  let the browser load the server's real response. */
356
- if (decision.kind === 'reload') {
357
- if (typeof location !== 'undefined') {
358
- location.href = decision.url
359
+ if (decision.kind === 'reload') {
360
+ if (typeof location !== 'undefined') {
361
+ location.href = decision.url
362
+ }
363
+ return
359
364
  }
360
- return
361
- }
362
- const layoutViews = resolvedLayouts.filter(
363
- (view): view is Route => view !== undefined,
364
- )
365
- /* The shared prefix of layouts (same route key at the same depth) stays
365
+ const layoutViews = resolvedLayouts.filter(
366
+ (view): view is Route => view !== undefined,
367
+ )
368
+ /* The shared prefix of layouts (same route key at the same depth) stays
366
369
  mounted; the first divergence and everything inward is rebuilt. */
367
- let divergence = 0
368
- while (
369
- divergence < mountedLayouts.length &&
370
- divergence < chainKeys.length &&
371
- mountedLayouts[divergence]?.key === chainKeys[divergence]
372
- ) {
373
- divergence += 1
374
- }
375
- const hydrating = first && pageView?.hydratable === true
376
- first = false
377
- /* The DOM mutation a navigation makes: tear the divergent chain down
370
+ let divergence = 0
371
+ while (
372
+ divergence < mountedLayouts.length &&
373
+ divergence < chainKeys.length &&
374
+ mountedLayouts[divergence]?.key === chainKeys[divergence]
375
+ ) {
376
+ divergence += 1
377
+ }
378
+ const hydrating = first && pageView?.hydratable === true
379
+ first = false
380
+ /* The DOM mutation a navigation makes: tear the divergent chain down
378
381
  (clearing its content from its boundary) and rebuild into the same
379
382
  boundary (hydration adopts in place). */
380
- const swap = (): void => {
381
- /* Tear the outgoing page + divergent layouts down BEFORE publishing the
383
+ const swap = (): void => {
384
+ /* `startViewTransition` runs this callback in a later frame, so a newer
385
+ navigation may have superseded this one since the token guard above —
386
+ re-check before mutating, or a stale swap clobbers the newer page. */
387
+ if (token !== sequence || disposed) {
388
+ return
389
+ }
390
+ /* Tear the outgoing page + divergent layouts down BEFORE publishing the
382
391
  new snapshot. Publishing first would re-run the doomed leaf page's
383
392
  computeds against the new route's params (a missing `[id]` reads back
384
393
  `undefined`, e.g. `Number(page.params.id)` → NaN → a bogus request)
385
394
  while it's still mounted. Disposing first kills that scope; surviving
386
395
  prefix layouts then update in place on publish. */
387
- disposeFrom(divergence)
388
- const url = resolveUrl(path)
389
- clientPage.value = { route: chainRoute, params, url, navigating: false }
390
- buildFrom(divergence, chainKeys, layoutViews, pageView, key, params, hydrating)
391
- /* Reapply the destination entry's scroll once its DOM exists — a
396
+ disposeFrom(divergence)
397
+ const url = resolveUrl(path)
398
+ clientPage.value = { route: chainRoute, params, url, navigating: false }
399
+ buildFrom(
400
+ divergence,
401
+ chainKeys,
402
+ layoutViews,
403
+ pageView,
404
+ key,
405
+ params,
406
+ hydrating,
407
+ )
408
+ /* Reapply the destination entry's scroll once its DOM exists — a
392
409
  back/forward restores its offset, a fresh nav scrolls to the `#hash`
393
410
  anchor (now built) or the top. Runs on the initial paint too: with
394
411
  `scrollRestoration='manual'` the browser does NOT restore a reload's
395
412
  offset, so first paint recovers it from the persisted `history.state`
396
413
  (a fresh load with no persisted offset falls through to hash/top). */
397
- historyEntries.restore(url.hash)
398
- }
399
- /* Wrap the swap in a View Transition where the browser supports it, so
414
+ historyEntries.restore(url.hash)
415
+ }
416
+ /* Wrap the swap in a View Transition where the browser supports it, so
400
417
  the page change cross-fades (and shared `view-transition-name` elements
401
418
  morph) — the synchronous swap is exactly the mutation the API snapshots
402
419
  around. Skipped while hydrating: the first paint adopts SSR DOM in place,
403
420
  not animate. CSS owns opting out (e.g. prefers-reduced-motion). */
404
- if (
405
- !hydrating &&
406
- typeof document !== 'undefined' &&
407
- 'startViewTransition' in document
408
- ) {
409
- document.startViewTransition(swap)
410
- } else {
411
- swap()
412
- }
413
- })
421
+ if (
422
+ !hydrating &&
423
+ typeof document !== 'undefined' &&
424
+ 'startViewTransition' in document
425
+ ) {
426
+ document.startViewTransition(swap)
427
+ } else {
428
+ swap()
429
+ }
430
+ })
431
+ .catch(() => {
432
+ /* A page/layout chunk import (or the probe) rejected — offline, a hashed
433
+ chunk filename rotated by a deploy, or a transient asset 5xx. Without
434
+ this the navigating:true latched above never clears (a bound spinner
435
+ spins forever) and the rejection surfaces as an unhandledrejection.
436
+ Fall back to a full browser load so the server serves the target. */
437
+ if (token !== sequence || disposed) {
438
+ return
439
+ }
440
+ if (typeof location !== 'undefined') {
441
+ location.href = resolveUrl(path).href
442
+ }
443
+ })
414
444
  })
415
445
  })
416
446
 
@@ -49,6 +49,11 @@ export function createEffectNode(fn: () => EffectResult): () => void {
49
49
  const dispose = () => {
50
50
  runCleanup()
51
51
  unlinkDeps(node)
52
+ /* Clearing compute makes runNode a no-op: an effect disposed mid-flush (by an
53
+ earlier effect in the same batch) is still in flushEffects' snapshot array
54
+ after pendingEffects.delete, so it would otherwise re-run its body and
55
+ re-link into the graph — a disposed effect resurrected. */
56
+ node.compute = undefined
52
57
  REACTIVE_CONTEXT.pendingEffects.delete(node)
53
58
  }
54
59
  if (OWNER.current !== undefined) {
@@ -25,7 +25,14 @@ export function localStoragePersistence(): PersistenceStore | undefined {
25
25
  }
26
26
  },
27
27
  save: (key, snapshot) => {
28
- localStorage.setItem(key, JSON.stringify(snapshot))
28
+ /* Swallow a failed write (QuotaExceededError, storage disabled mid-session)
29
+ it fires from a debounced flush / pagehide handler with no caller to catch it,
30
+ and a dropped persist must not crash the app. */
31
+ try {
32
+ localStorage.setItem(key, JSON.stringify(snapshot))
33
+ } catch {
34
+ // best-effort persistence
35
+ }
29
36
  },
30
37
  remove: (key) => {
31
38
  localStorage.removeItem(key)
@@ -15,11 +15,16 @@ export function toTeardown(result: EffectResult): Teardown | undefined {
15
15
  }
16
16
  if (result instanceof Promise) {
17
17
  return () => {
18
- result.then((teardown) => {
19
- if (typeof teardown === 'function') {
20
- teardown()
21
- }
22
- })
18
+ /* Swallow a rejection: an async body that rejected (e.g. an aborted RPC) must
19
+ not surface as an unhandled rejection when the teardown runs at dispose. */
20
+ result.then(
21
+ (teardown) => {
22
+ if (typeof teardown === 'function') {
23
+ teardown()
24
+ }
25
+ },
26
+ () => undefined,
27
+ )
23
28
  }
24
29
  }
25
30
  return undefined
@@ -0,0 +1,8 @@
1
+ /* The per-request SSR render context, threaded through a layout chain and every
2
+ child component a page inlines. `next` is the block-id counter: each `await`/`try`
3
+ block draws from it in depth-first document order — the SAME order the client
4
+ allocates ids during its synchronous hydration walk, so the streamed fragments and
5
+ the `RESUME` manifest line up. Request-local (not a module global) because SSR
6
+ render is async — a blocking `await` yields, and a shared global counter would
7
+ interleave across concurrent requests. */
8
+ export type RenderContext = { next: number }
@@ -1,22 +1,27 @@
1
- /* One pending await block captured during SSR: its boundary id, the promise to
2
- await, and the string-renderers for the resolved value / error. `blocking` (a
3
- `then` on the `await` tag) makes `renderToStream` settle it BEFORE the first flush,
4
- splicing the resolved branch into its empty boundary; absent streamed after.
5
- `catch` is absent when the block has no catch branch a rejection then surfaces
6
- to the stream/error path instead of rendering an empty branch. */
1
+ import type { ResumeEntry } from '../RESUME.ts'
2
+
3
+ /* One STREAMING await block captured during SSR (no `then` on the `await` tag): its
4
+ boundary id, the promise to await, and the async string-renderers for the resolved
5
+ value / error. `renderToStream` flushes each resolved fragment out of order. The
6
+ renderers are async so a nested `await` block inside the branch composes. `catch`
7
+ is absent when the block has no catch branch — a rejection then surfaces to the
8
+ stream/error path instead of rendering an empty branch. (Blocking awaits — a `then`
9
+ on the tag — never land here: they render inline during the async render pass and
10
+ seed `SsrRender.resume`.) */
7
11
  export type SsrAwait = {
8
12
  id: number
9
- blocking?: boolean
10
13
  promise: () => unknown
11
- then: (value: unknown) => string
12
- catch?: (error: unknown) => string
14
+ then: (value: unknown) => Promise<string>
15
+ catch?: (error: unknown) => Promise<string>
13
16
  }
14
17
 
15
18
  /* The result of a component's server `render()`: the pending-shell HTML, the
16
- serializable document snapshot for client resume, and the await blocks to
17
- stream. */
19
+ serializable document snapshot for client resume, the STREAMING await blocks to
20
+ flush out of order, and `resume` — the inline-rendered BLOCKING await values keyed
21
+ by boundary id, seeded into the manifest so hydration adopts them without a refetch. */
18
22
  export type SsrRender = {
19
23
  html: string
20
24
  state: unknown
21
25
  awaits: SsrAwait[]
26
+ resume: Record<number, ResumeEntry>
22
27
  }
@@ -1,3 +1,4 @@
1
+ import type { RenderContext } from './RenderContext.ts'
1
2
  import type { SsrRender } from './SsrRender.ts'
2
3
  import type { UiProps } from './UiProps.ts'
3
4
 
@@ -7,7 +8,12 @@ for SSR and the hydration hooks. This is the shape `compileModule` emits and the
7
8
  page/route registries carry — abide-ui's compiled-component shape.
8
9
  */
9
10
  export type UiComponent = ((host: Element, props?: UiProps) => () => void) & {
10
- render: (props?: UiProps) => SsrRender
11
+ /* `ctx` is the request-local block-id counter, threaded so a child's ids share the
12
+ page's depth-first numbering; omitted at the top level (a fresh counter defaults
13
+ in). Returns a Promise when the component has an inline `await` (a blocking `{#await
14
+ … then}` block or a child render); otherwise renders synchronously. Callers `await`
15
+ it either way (awaiting a sync value just returns it). */
16
+ render: (props?: UiProps, ctx?: RenderContext) => SsrRender | Promise<SsrRender>
11
17
  hydrate?: (host: Element, props?: UiProps) => () => void
12
18
  /* The bare client build (`(host, props) => void`) — appends the component's nodes
13
19
  to `host`. A nested child mounts it into a marker range (`mountRange`/`mountChild`)
@@ -1,15 +0,0 @@
1
- /*
2
- HTML-escapes a compile-time-constant string — a static attribute value or static
3
- text — the same five characters the runtime `$esc` handles. Shared by the SSR
4
- generator and the static-clone skeleton generator so server markup and the client
5
- clone template can't diverge on escaping. Static text reaches here already
6
- entity-decoded (see parseTemplate), so escaping round-trips it through the HTML
7
- parser to the same plain text the client would build directly.
8
- */
9
- export function escapeHtml(value: string): string {
10
- return value.replace(
11
- /[&<>"']/g,
12
- (char) =>
13
- ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char] ?? char,
14
- )
15
- }