@abide/abide 0.38.1 → 0.40.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 (80) hide show
  1. package/AGENTS.md +5 -5
  2. package/CHANGELOG.md +63 -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/parseArgs.ts +10 -1
  7. package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
  8. package/src/lib/server/rpc/runWithVerbTimeout.ts +7 -9
  9. package/src/lib/server/rpc/types/VerbHelper.ts +18 -21
  10. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +8 -7
  11. package/src/lib/server/runtime/createAppAssetServer.ts +37 -11
  12. package/src/lib/server/runtime/createPublicAssetServer.ts +14 -5
  13. package/src/lib/server/runtime/createUiPageRenderer.ts +53 -42
  14. package/src/lib/server/runtime/internalErrorResponse.ts +5 -2
  15. package/src/lib/server/runtime/snapshotEntryFromCache.ts +4 -1
  16. package/src/lib/server/sockets/createSocketDispatcher.ts +19 -3
  17. package/src/lib/server/sockets/defineSocket.ts +3 -1
  18. package/src/lib/shared/REF_JSON_HEADER.ts +9 -0
  19. package/src/lib/shared/REF_JSON_TAGS.ts +31 -0
  20. package/src/lib/shared/buildRpcRequest.ts +8 -1
  21. package/src/lib/shared/createRemoteFunction.ts +20 -11
  22. package/src/lib/shared/decodeRefJson.ts +110 -0
  23. package/src/lib/shared/encodeRefJson.ts +106 -0
  24. package/src/lib/shared/escapeHtml.ts +15 -0
  25. package/src/lib/shared/markFrameworkSourcesIgnored.ts +47 -0
  26. package/src/lib/shared/streamResponse.ts +8 -1
  27. package/src/lib/shared/types/RemoteCallable.ts +12 -3
  28. package/src/lib/shared/types/RpcOptions.ts +22 -0
  29. package/src/lib/shared/types/SourceMap.ts +14 -0
  30. package/src/lib/test/createTestSocketChannel.ts +6 -2
  31. package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
  32. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +5 -0
  33. package/src/lib/ui/compile/compileModule.ts +5 -2
  34. package/src/lib/ui/compile/compileSSR.ts +32 -9
  35. package/src/lib/ui/compile/compileShadow.ts +25 -10
  36. package/src/lib/ui/compile/composeProps.ts +53 -0
  37. package/src/lib/ui/compile/desugarSignals.ts +45 -17
  38. package/src/lib/ui/compile/generateBuild.ts +87 -76
  39. package/src/lib/ui/compile/generateSSR.ts +217 -71
  40. package/src/lib/ui/compile/isWhitespaceText.ts +11 -0
  41. package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
  42. package/src/lib/ui/compile/parseTemplate.ts +118 -1
  43. package/src/lib/ui/compile/renameSignalRefs.ts +12 -0
  44. package/src/lib/ui/compile/resolveBranches.ts +21 -0
  45. package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
  46. package/src/lib/ui/compile/staticAttr.ts +1 -1
  47. package/src/lib/ui/compile/staticTextPart.ts +1 -1
  48. package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
  49. package/src/lib/ui/compile/types/TemplateNode.ts +15 -3
  50. package/src/lib/ui/createScope.ts +14 -0
  51. package/src/lib/ui/dom/appendText.ts +2 -3
  52. package/src/lib/ui/dom/appendTextAt.ts +2 -3
  53. package/src/lib/ui/dom/applyResolved.ts +5 -8
  54. package/src/lib/ui/dom/awaitBlock.ts +14 -1
  55. package/src/lib/ui/dom/mergeProps.ts +32 -0
  56. package/src/lib/ui/dom/on.ts +7 -0
  57. package/src/lib/ui/dom/parseRawNodes.ts +17 -0
  58. package/src/lib/ui/dom/readCall.ts +27 -0
  59. package/src/lib/ui/dom/restProps.ts +32 -0
  60. package/src/lib/ui/dom/spreadAttrs.ts +34 -0
  61. package/src/lib/ui/dom/spreadProps.ts +32 -0
  62. package/src/lib/ui/installHotBridge.ts +10 -0
  63. package/src/lib/ui/navigate.ts +28 -3
  64. package/src/lib/ui/remoteProxy.ts +68 -36
  65. package/src/lib/ui/renderChain.ts +39 -37
  66. package/src/lib/ui/renderToStream.ts +84 -68
  67. package/src/lib/ui/resumeSeedScript.ts +27 -0
  68. package/src/lib/ui/router.ts +81 -51
  69. package/src/lib/ui/runtime/RESUME.ts +13 -6
  70. package/src/lib/ui/runtime/createEffectNode.ts +5 -0
  71. package/src/lib/ui/runtime/localStoragePersistence.ts +21 -8
  72. package/src/lib/ui/runtime/toTeardown.ts +10 -5
  73. package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
  74. package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
  75. package/src/lib/ui/runtime/types/UiComponent.ts +7 -1
  76. package/src/lib/ui/socketChannel.ts +5 -2
  77. package/src/lib/ui/tryEncodeResume.ts +20 -0
  78. package/src/lib/ui/types/Scope.ts +9 -3
  79. package/src/lib/ui/compile/escapeHtml.ts +0 -15
  80. /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, { replace: 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
 
@@ -1,16 +1,23 @@
1
1
  /* The await-resume manifest: the resolved value (or error) of each streamed
2
- `await` block, keyed by its boundary id. The SSR stream serializes each value
2
+ `await` block, keyed by its boundary id. The SSR stream serializes each entry
3
3
  alongside its fragment; the client registers it (via `applyResolved` or the
4
4
  inline swap script), and hydration reads it so an `await` block adopts the
5
5
  resolved branch with the real value instead of re-running the promise.
6
6
 
7
- Backed by `globalThis.__abideResume` so the inline stream-swap script (vanilla,
8
- running during the stream before the bundle loads) and the framework share one
9
- store: whoever runs first creates it, the other adopts the same reference. */
7
+ Each value is the ref-json-encoded ResumeEntry STRING, not the decoded object:
8
+ the entry is encoded with the ref-json codec (so a resolved value carrying cycles
9
+ or shared back-references survives, where JSON would drop it) and decoded lazily
10
+ at the read site in `awaitBlock`. Storing the raw string keeps the inline
11
+ stream-swap script — vanilla, running before the bundle and the codec load — able
12
+ to register an entry without the decoder.
13
+
14
+ Backed by `globalThis.__abideResume` so the inline stream-swap script and the
15
+ framework share one store: whoever runs first creates it, the other adopts the
16
+ same reference. */
10
17
  export type ResumeEntry = { ok: true; value: unknown } | { ok: false; error: unknown }
11
18
 
12
- const globalScope = globalThis as { __abideResume?: Record<number, ResumeEntry> }
19
+ const globalScope = globalThis as { __abideResume?: Record<number, string> }
13
20
  globalScope.__abideResume ??= {}
14
21
 
15
22
  // @documentation plumbing
16
- export const RESUME: Record<number, ResumeEntry> = globalScope.__abideResume
23
+ export const RESUME: Record<number, string> = globalScope.__abideResume
@@ -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) {
@@ -1,12 +1,18 @@
1
+ import { decodeRefJson } from '../../shared/decodeRefJson.ts'
2
+ import { encodeRefJson } from '../../shared/encodeRefJson.ts'
1
3
  import type { PersistenceStore } from '../types/PersistenceStore.ts'
2
4
 
3
5
  /*
4
- The default `persist` backend: `localStorage` keyed by the persistence key, with
5
- JSON as the wire form (which also clones, so a stored snapshot can't alias the
6
- live tree). Returns `undefined` where there is no `localStorage` — the server, or
7
- a browser with storage disabled which `persist` reads as "stay inert". A corrupt
8
- or unparseable entry loads as `undefined` rather than throwing, so one bad write
9
- can't wedge boot.
6
+ The default `persist` backend: `localStorage` keyed by the persistence key, using
7
+ the ref-json codec as the wire form (which also clones on decode, so a stored
8
+ snapshot can't alias the live tree). ref-json over plain JSON because a doc
9
+ snapshot can hold the types JSON silently coerces (Date), drops (undefined) or
10
+ throws on (bigint, cycles, shared references) a throw here is a swallowed save,
11
+ i.e. silent persistence loss. Returns `undefined` where there is no `localStorage`
12
+ — the server, or a browser with storage disabled — which `persist` reads as "stay
13
+ inert". A corrupt or unreadable entry (including one written by an older JSON
14
+ build) loads as `undefined` rather than throwing, so one bad write can't wedge
15
+ boot; the next save rewrites it in ref-json form.
10
16
  */
11
17
  export function localStoragePersistence(): PersistenceStore | undefined {
12
18
  if (typeof localStorage === 'undefined') {
@@ -19,13 +25,20 @@ export function localStoragePersistence(): PersistenceStore | undefined {
19
25
  return undefined
20
26
  }
21
27
  try {
22
- return JSON.parse(raw)
28
+ return decodeRefJson(raw)
23
29
  } catch {
24
30
  return undefined
25
31
  }
26
32
  },
27
33
  save: (key, snapshot) => {
28
- localStorage.setItem(key, JSON.stringify(snapshot))
34
+ /* Swallow a failed write (QuotaExceededError, storage disabled mid-session)
35
+ it fires from a debounced flush / pagehide handler with no caller to catch it,
36
+ and a dropped persist must not crash the app. */
37
+ try {
38
+ localStorage.setItem(key, encodeRefJson(snapshot))
39
+ } catch {
40
+ // best-effort persistence
41
+ }
29
42
  },
30
43
  remove: (key) => {
31
44
  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,6 +1,8 @@
1
1
  import type { SocketClientFrame } from '../server/sockets/types/SocketClientFrame.ts'
2
2
  import type { SocketServerFrame } from '../server/sockets/types/SocketServerFrame.ts'
3
3
  import { createSocketSubRegistry } from '../shared/createSocketSubRegistry.ts'
4
+ import { decodeRefJson } from '../shared/decodeRefJson.ts'
5
+ import { encodeRefJson } from '../shared/encodeRefJson.ts'
4
6
  import { SOCKETS_PATH } from '../shared/SOCKETS_PATH.ts'
5
7
  import type { SocketChannel } from '../shared/types/SocketChannel.ts'
6
8
  import { withBase } from '../shared/withBase.ts'
@@ -55,7 +57,8 @@ export function getSocketChannel(): SocketChannel {
55
57
  }
56
58
 
57
59
  function send(frame: SocketClientFrame): void {
58
- const message = JSON.stringify(frame)
60
+ // ref-json frame so a published message graph with cycles/shared refs survives the wire.
61
+ const message = encodeRefJson(frame)
59
62
  if (ws && ws.readyState === WebSocket.OPEN) {
60
63
  ws.send(message)
61
64
  return
@@ -90,7 +93,7 @@ export function getSocketChannel(): SocketChannel {
90
93
  ws.addEventListener('message', (event) => {
91
94
  let frame: SocketServerFrame
92
95
  try {
93
- frame = JSON.parse(event.data) as SocketServerFrame
96
+ frame = decodeRefJson(event.data) as SocketServerFrame
94
97
  } catch {
95
98
  return
96
99
  }
@@ -0,0 +1,20 @@
1
+ import { encodeRefJson } from '../shared/encodeRefJson.ts'
2
+ import type { ResumeEntry } from './runtime/RESUME.ts'
3
+
4
+ /* ref-json-encode an await-resume entry, or `undefined` if it can't be serialized.
5
+ encodeRefJson is total (cycles become back-references, functions fold to undefined),
6
+ but a pathological throw must not blank the surrounding seed/stream — drop just this
7
+ entry and warn, so the client re-runs that one branch's promise while every other
8
+ branch stays seeded. Shared by the streaming (`renderToStream`) and buffered/seed
9
+ (`resumeSeedScript`) paths so the serialize-or-refetch policy lives in one place. */
10
+ export function tryEncodeResume(entry: ResumeEntry, id: number | string): string | undefined {
11
+ try {
12
+ return encodeRefJson(entry)
13
+ } catch (cause) {
14
+ console.warn(
15
+ `[abide] resume for await ${id} is not serializable; client will refetch it`,
16
+ cause,
17
+ )
18
+ return undefined
19
+ }
20
+ }
@@ -9,9 +9,9 @@ import type { SyncTransport } from './SyncTransport.ts'
9
9
  A lexical scope: the unit that owns a region's reactive data, its lifetime, and
10
10
  the capabilities applied to it. Its data surface MIRRORS `Doc` (read/replace/add/
11
11
  remove/cell/derive/apply/snapshot) so the compiler can target a scope as a
12
- component's data binding directly. It nests (`child`/`root`), and carries the
13
- capability surface as methods so a scope is a passable value:
14
- `<Child parentScope={scope} />`.
12
+ component's data binding directly. It nests (`child`/`root`), passes values
13
+ down the tree as context (`share`/`shared`), and carries the capability surface as
14
+ methods so a scope is a passable value: `<Child parentScope={scope} />`.
15
15
 
16
16
  Capabilities route where the scope's changes go: `record()` to an undo journal,
17
17
  `persist()` to durable storage, `broadcast()` to peers — declared once, then
@@ -46,6 +46,12 @@ export type Scope = {
46
46
  /* tree */
47
47
  child: (initial?: unknown) => Scope
48
48
  root: () => Scope
49
+ /* context — values shared DOWN the tree (not in the reactive doc, which doesn't
50
+ inherit): `share` puts a named value on this scope; `shared` reads the closest
51
+ ancestor (self included) that has the key, undefined if none. The value is held
52
+ by reference, so reactive context = share a `cell`/scope, not a plain object. */
53
+ share: (key: string, value: unknown) => void
54
+ shared: <T>(key: string) => T | undefined
49
55
  /* capabilities — enable where the scope's changes go */
50
56
  record: (options?: { limit?: number }) => void
51
57
  persist: (key?: string) => void
@@ -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
- }