@abide/abide 0.38.0 → 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 (82) hide show
  1. package/AGENTS.md +3 -3
  2. package/CHANGELOG.md +24 -0
  3. package/package.json +10 -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/COMPONENT_WRAPPER_PREFIX.ts +4 -3
  21. package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
  22. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +6 -0
  23. package/src/lib/ui/compile/asOutlet.ts +6 -5
  24. package/src/lib/ui/compile/compileModule.ts +18 -11
  25. package/src/lib/ui/compile/compileSSR.ts +32 -9
  26. package/src/lib/ui/compile/compileShadow.ts +11 -3
  27. package/src/lib/ui/compile/composeProps.ts +53 -0
  28. package/src/lib/ui/compile/desugarSignals.ts +45 -17
  29. package/src/lib/ui/compile/generateBuild.ts +100 -61
  30. package/src/lib/ui/compile/generateSSR.ts +226 -73
  31. package/src/lib/ui/compile/isAnchorPositioned.ts +19 -0
  32. package/src/lib/ui/compile/isControlFlow.ts +3 -1
  33. package/src/lib/ui/compile/isTextLeaf.ts +1 -1
  34. package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
  35. package/src/lib/ui/compile/parseTemplate.ts +44 -1
  36. package/src/lib/ui/compile/skeletonContext.ts +19 -20
  37. package/src/lib/ui/compile/skeletonable.ts +3 -2
  38. package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
  39. package/src/lib/ui/compile/staticAttr.ts +1 -1
  40. package/src/lib/ui/compile/staticTextPart.ts +1 -1
  41. package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
  42. package/src/lib/ui/compile/types/TemplateNode.ts +3 -1
  43. package/src/lib/ui/dom/commentData.ts +14 -0
  44. package/src/lib/ui/dom/disposeRange.ts +21 -0
  45. package/src/lib/ui/dom/fillBoundary.ts +38 -0
  46. package/src/lib/ui/dom/fillRange.ts +30 -0
  47. package/src/lib/ui/dom/hydrate.ts +11 -21
  48. package/src/lib/ui/dom/mergeProps.ts +32 -0
  49. package/src/lib/ui/dom/mount.ts +16 -25
  50. package/src/lib/ui/dom/mountChild.ts +27 -14
  51. package/src/lib/ui/dom/mountRange.ts +45 -0
  52. package/src/lib/ui/dom/outlet.ts +62 -0
  53. package/src/lib/ui/dom/readCall.ts +27 -0
  54. package/src/lib/ui/dom/restProps.ts +32 -0
  55. package/src/lib/ui/dom/scopeLabel.ts +8 -6
  56. package/src/lib/ui/dom/skeleton.ts +11 -30
  57. package/src/lib/ui/dom/spreadAttrs.ts +34 -0
  58. package/src/lib/ui/dom/spreadProps.ts +32 -0
  59. package/src/lib/ui/dom/withScope.ts +33 -0
  60. package/src/lib/ui/installHotBridge.ts +12 -0
  61. package/src/lib/ui/remoteProxy.ts +68 -36
  62. package/src/lib/ui/renderChain.ts +49 -39
  63. package/src/lib/ui/renderToStream.ts +69 -61
  64. package/src/lib/ui/resumeSeedScript.ts +17 -0
  65. package/src/lib/ui/router.ts +155 -85
  66. package/src/lib/ui/runtime/OUTLET_MARKER.ts +10 -0
  67. package/src/lib/ui/runtime/OUTLET_TAG.ts +5 -6
  68. package/src/lib/ui/runtime/PENDING_OUTLET.ts +8 -0
  69. package/src/lib/ui/runtime/captureModelDoc.ts +12 -13
  70. package/src/lib/ui/runtime/createEffectNode.ts +5 -0
  71. package/src/lib/ui/runtime/hotReplace.ts +14 -10
  72. package/src/lib/ui/runtime/localStoragePersistence.ts +8 -1
  73. package/src/lib/ui/runtime/toTeardown.ts +10 -5
  74. package/src/lib/ui/runtime/types/HotInstance.ts +11 -7
  75. package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
  76. package/src/lib/ui/runtime/types/Route.ts +6 -5
  77. package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
  78. package/src/lib/ui/runtime/types/UiComponent.ts +12 -1
  79. package/src/lib/ui/compile/componentWrapperTag.ts +0 -15
  80. package/src/lib/ui/compile/escapeHtml.ts +0 -15
  81. package/src/lib/ui/runtime/firstOutlet.ts +0 -22
  82. /package/src/lib/{server/runtime → shared}/safeJsonForScript.ts +0 -0
@@ -1,3 +1,4 @@
1
+ import { fillRange } from '../dom/fillRange.ts'
1
2
  import { captureModelDoc } from './captureModelDoc.ts'
2
3
  import { hotInstances } from './hotInstances.ts'
3
4
  import { seedModelDoc } from './seedModelDoc.ts'
@@ -5,14 +6,15 @@ import type { UiComponent } from './types/UiComponent.ts'
5
6
 
6
7
  /*
7
8
  Swaps every live instance of an edited component to its new factory. Per instance:
8
- snapshot its model, dispose the current scope and its DOM (the mount disposer clears
9
- the host), re-run `next` into the same host with the same props, then re-seed the
10
- fresh model from the snapshot so the user's in-progress `state` survives the edit
11
- (state above the boundary already survives: props are thunks re-reading the parent's
12
- live signals). The hot module calls this on load with its freshly compiled `next`.
13
- Returns whether it swapped at least one instance: false (the edited component has
14
- none mounted — e.g. a router-mounted page, or a hidden branch) tells the caller to
15
- fall back to a full reload, since nothing on screen would update.
9
+ snapshot its model, dispose the current scope and clear its DOM range (the disposer
10
+ leaves the `start`/`end` markers in place), re-fill the SAME range with the new
11
+ module's `build` and the same props, then re-seed the fresh model from the snapshot —
12
+ so the user's in-progress `state` survives the edit (state above the boundary already
13
+ survives: props are thunks re-reading the parent's live signals). The hot module
14
+ calls this on load with its freshly compiled `next`. Returns whether it swapped at
15
+ least one instance: false (the edited component has none mounted — e.g. a
16
+ router-mounted page, or a hidden branch) tells the caller to fall back to a full
17
+ reload, since nothing on screen would update.
16
18
  */
17
19
  export function hotReplace(moduleId: string, next: UiComponent): boolean {
18
20
  const set = hotInstances.get(moduleId)
@@ -23,8 +25,10 @@ export function hotReplace(moduleId: string, next: UiComponent): boolean {
23
25
  const saved = instance.model?.snapshot()
24
26
  instance.dispose()
25
27
  instance.factory = next
26
- const { dispose, model } = captureModelDoc(() => next(instance.host, instance.props))
27
- instance.dispose = dispose
28
+ const { value: handle, model } = captureModelDoc(() =>
29
+ fillRange(instance.start, instance.end, next.build, instance.props, instance.label),
30
+ )
31
+ instance.dispose = handle.dispose
28
32
  instance.model = model
29
33
  if (saved !== undefined && model !== undefined) {
30
34
  seedModelDoc(model, saved)
@@ -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
@@ -2,15 +2,19 @@ import type { Doc } from './Doc.ts'
2
2
  import type { UiComponent } from './UiComponent.ts'
3
3
 
4
4
  /*
5
- A live component instance the hot-reload registry tracks: its wrapper host, the
6
- factory that built it, the props (thunks re-read on a swap so the parent's live
7
- state still flows through), the disposer for its current scope + DOM, and its own
8
- `model` document (`undefined` when the component has no `state`) snapshotted
9
- before a swap and re-seeded after, so the user's in-progress state survives the
10
- edit. A swap mutates `factory`/`dispose`/`model` in place (see `hotReplace`).
5
+ A live component instance the hot-reload registry tracks: the `start`/`end` markers
6
+ bounding its DOM range (the wrapper-free mount, see `mountRange`), its `label` (the
7
+ scope name), the factory that built it, the props (thunks re-read on a swap so the
8
+ parent's live state still flows through), the disposer for its current scope + DOM,
9
+ and its own `model` document (`undefined` when the component has no `state`)
10
+ snapshotted before a swap and re-seeded after, so the user's in-progress state
11
+ survives the edit. A swap re-fills the SAME range and mutates `factory`/`dispose`/
12
+ `model` in place (see `hotReplace`).
11
13
  */
12
14
  export type HotInstance = {
13
- host: Element
15
+ start: Comment
16
+ end: Comment
17
+ label: string | undefined
14
18
  factory: UiComponent
15
19
  props: Parameters<UiComponent>[1]
16
20
  dispose: () => void
@@ -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,8 +1,9 @@
1
- /* A routable page: mounts into a host (optionally with props) and may return a
2
- disposer the router calls when navigating away. A compiled `.abide` default
3
- export also carries `hydrate` (adopt SSR in place) and `hydratable` (false when
4
- the page has an `await` block), which the router uses for the initial render. */
1
+ /* A routable page/layout. Callable to mount directly into a host (the direct-mount
2
+ API), but the router instead uses `build` the bare client build — to fill the
3
+ layer into its outlet boundary as a marker range (no `<abide-outlet>` wrapper; see
4
+ `fillBoundary`/`outlet`). `hydratable` (false when the page has an `await` block)
5
+ tells the router whether the first paint adopts the SSR DOM in place. */
5
6
  export type Route = ((host: Element, props?: unknown) => (() => void) | undefined) & {
6
- hydrate?: (host: Element, props?: unknown) => () => void
7
+ build: (host: Node, props?: unknown) => void
7
8
  hydratable?: boolean
8
9
  }
@@ -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,8 +8,18 @@ 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
18
+ /* The bare client build (`(host, props) => void`) — appends the component's nodes
19
+ to `host`. A nested child mounts it into a marker range (`mountRange`/`mountChild`)
20
+ instead of the wrapped `mount`, and `hotReplace` re-fills a range with the new
21
+ module's `build` on edit. */
22
+ build: (host: Node, props?: UiProps) => void
12
23
  hydratable?: boolean
13
24
  /* Stable module id (project-relative source path) stamped by `compileModule`,
14
25
  keying the component in the hot-reload registry — see `mountChild`. */
@@ -1,15 +0,0 @@
1
- /*
2
- The element tag a component instance mounts into: always `abide-<name>` lowercased. The
3
- `abide-` prefix makes every wrapper a valid custom element (contains a hyphen) — never
4
- void, no content model — so it holds the component's own markup untouched and hydrates
5
- cleanly, regardless of whether the name collides with an HTML element (`Button`, `Input`).
6
- Emitted with `display:contents` (see the back-ends) so the wrapper stays out of layout: a
7
- pure mount host whose real root lays out as a direct child of the parent, keeping the
8
- component invisible to `grid`/`subgrid`/`flex`. Both back-ends call this so the SSR string
9
- and the client build agree on the wrapper.
10
- */
11
- import { COMPONENT_WRAPPER_PREFIX } from '../COMPONENT_WRAPPER_PREFIX.ts'
12
-
13
- export function componentWrapperTag(name: string): string {
14
- return `${COMPONENT_WRAPPER_PREFIX}${name.toLowerCase()}`
15
- }
@@ -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
- }
@@ -1,22 +0,0 @@
1
- import { OUTLET_TAG } from './OUTLET_TAG.ts'
2
-
3
- /*
4
- The first `<abide-outlet>` in `root`'s subtree, in document order — the position
5
- the router fills with the next layer of a route's layout chain. A depth-first walk
6
- over `children` rather than `querySelector`: the router runs against real DOM and
7
- the test mini-DOM alike, and only the former has `querySelector`. Tag comparison is
8
- case-insensitive (the real DOM uppercases `tagName`, the mini-DOM keeps it as
9
- created). Returns undefined when the layout declares no `<slot/>`.
10
- */
11
- export function firstOutlet(root: Element): Element | undefined {
12
- for (const child of root.children) {
13
- if (child.tagName.toLowerCase() === OUTLET_TAG) {
14
- return child
15
- }
16
- const nested = firstOutlet(child)
17
- if (nested !== undefined) {
18
- return nested
19
- }
20
- }
21
- return undefined
22
- }