@abide/abide 0.37.0 → 0.38.1

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 (65) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +38 -0
  3. package/package.json +2 -1
  4. package/src/abideResolverPlugin.ts +3 -17
  5. package/src/appEntry.ts +18 -8
  6. package/src/controlServerWorker.ts +8 -1
  7. package/src/lib/cli/parseArgvForRpc.ts +8 -3
  8. package/src/lib/mcp/createMcpServer.ts +8 -0
  9. package/src/lib/mcp/dispatchMcpRequest.ts +20 -2
  10. package/src/lib/mcp/toolResultFromResponse.ts +5 -0
  11. package/src/lib/server/rpc/parseArgs.ts +12 -1
  12. package/src/lib/server/runtime/acceptsGzip.ts +10 -1
  13. package/src/lib/server/runtime/gzipResponse.ts +2 -1
  14. package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -1
  15. package/src/lib/server/runtime/streamFromIterator.ts +8 -0
  16. package/src/lib/server/sockets/createSocketDispatcher.ts +7 -1
  17. package/src/lib/server/sockets/defineSocket.ts +6 -1
  18. package/src/lib/shared/cache.ts +6 -0
  19. package/src/lib/shared/createPushIterator.ts +7 -2
  20. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +5 -0
  21. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  22. package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
  23. package/src/lib/ui/compile/asOutlet.ts +30 -0
  24. package/src/lib/ui/compile/compileModule.ts +13 -9
  25. package/src/lib/ui/compile/generateBuild.ts +83 -72
  26. package/src/lib/ui/compile/generateSSR.ts +56 -37
  27. package/src/lib/ui/compile/isAnchorPositioned.ts +19 -0
  28. package/src/lib/ui/compile/isControlFlow.ts +3 -1
  29. package/src/lib/ui/compile/isTextLeaf.ts +1 -1
  30. package/src/lib/ui/compile/parseTemplate.ts +10 -1
  31. package/src/lib/ui/compile/renameSignalRefs.ts +25 -2
  32. package/src/lib/ui/compile/skeletonContext.ts +97 -35
  33. package/src/lib/ui/compile/skeletonable.ts +3 -2
  34. package/src/lib/ui/compile/types/SkeletonContext.ts +6 -0
  35. package/src/lib/ui/dom/appendSnippet.ts +60 -20
  36. package/src/lib/ui/dom/commentData.ts +14 -0
  37. package/src/lib/ui/dom/disposeRange.ts +21 -0
  38. package/src/lib/ui/dom/fillBefore.ts +10 -0
  39. package/src/lib/ui/dom/fillBoundary.ts +38 -0
  40. package/src/lib/ui/dom/fillRange.ts +30 -0
  41. package/src/lib/ui/dom/hydrate.ts +11 -21
  42. package/src/lib/ui/dom/mount.ts +16 -25
  43. package/src/lib/ui/dom/mountChild.ts +27 -14
  44. package/src/lib/ui/dom/mountRange.ts +45 -0
  45. package/src/lib/ui/dom/mountSlot.ts +7 -2
  46. package/src/lib/ui/dom/outlet.ts +62 -0
  47. package/src/lib/ui/dom/scopeLabel.ts +13 -7
  48. package/src/lib/ui/dom/skeleton.ts +11 -27
  49. package/src/lib/ui/dom/withScope.ts +33 -0
  50. package/src/lib/ui/installHotBridge.ts +2 -0
  51. package/src/lib/ui/persist.ts +4 -1
  52. package/src/lib/ui/renderChain.ts +23 -15
  53. package/src/lib/ui/router.ts +78 -38
  54. package/src/lib/ui/runtime/OUTLET_MARKER.ts +10 -0
  55. package/src/lib/ui/runtime/OUTLET_TAG.ts +5 -6
  56. package/src/lib/ui/runtime/PENDING_OUTLET.ts +8 -0
  57. package/src/lib/ui/runtime/captureModelDoc.ts +12 -13
  58. package/src/lib/ui/runtime/createDoc.ts +20 -7
  59. package/src/lib/ui/runtime/hotReplace.ts +14 -10
  60. package/src/lib/ui/runtime/types/HotInstance.ts +11 -7
  61. package/src/lib/ui/runtime/types/Route.ts +6 -5
  62. package/src/lib/ui/runtime/types/UiComponent.ts +5 -0
  63. package/src/lib/ui/seedStreamedResolution.ts +10 -1
  64. package/src/lib/ui/compile/componentWrapperTag.ts +0 -13
  65. package/src/lib/ui/runtime/firstOutlet.ts +0 -22
@@ -2,28 +2,27 @@ import { PATCH_BUS } from './PATCH_BUS.ts'
2
2
  import type { Doc } from './types/Doc.ts'
3
3
 
4
4
  /*
5
- Runs a component `build` and returns its disposer alongside the component's own
6
- `model` document — the serializable `state` doc, needed so a hot swap can carry
7
- its value across (see `hotReplace`). The model is found, not threaded: a component
8
- seeds its `model` first (the desugared `const model = doc({})` + its init patches
9
- run before any child mounts or control-flow blocks), so the FIRST patch announced
10
- on the bus during the build names it. A component with no `state` mints no model
11
- and emits nothing first `model` is then `undefined` and there is nothing to
12
- preserve. Used only on the hot path; the subscription is torn down with the build.
5
+ Runs a component `build` and returns its result (the mount handle / disposer)
6
+ alongside the component's own `model` document — the serializable `state` doc,
7
+ needed so a hot swap can carry its value across (see `hotReplace`). The model is
8
+ found, not threaded: a component seeds its `model` first (the desugared
9
+ `const model = doc({})` + its init patches run before any child mounts or
10
+ control-flow blocks), so the FIRST patch announced on the bus during the build names
11
+ it. A component with no `state` mints no model and emits nothing first — `model` is
12
+ then `undefined` and there is nothing to preserve. Used only on the hot path; the
13
+ subscription is torn down with the build.
13
14
  */
14
- export function captureModelDoc(build: () => () => void): {
15
- dispose: () => void
15
+ export function captureModelDoc<T>(build: () => T): {
16
+ value: T
16
17
  model: Doc | undefined
17
18
  } {
18
19
  let model: Doc | undefined
19
20
  const unsubscribe = PATCH_BUS.subscribe((event) => {
20
21
  model ??= event.doc
21
22
  })
22
- let dispose: () => void = () => undefined
23
23
  try {
24
- dispose = build()
24
+ return { value: build(), model }
25
25
  } finally {
26
26
  unsubscribe()
27
27
  }
28
- return { dispose, model }
29
28
  }
@@ -14,6 +14,13 @@ import { unescapeKey } from './unescapeKey.ts'
14
14
  import { walkPath } from './walkPath.ts'
15
15
  import { writeNode } from './writeNode.ts'
16
16
 
17
+ /* `path` minus its last segment — the parent container's path, '' at the root.
18
+ The same string `segments.slice(0, -1).join('/')` rebuilds, by one slice. */
19
+ function parentPathOf(path: string): string {
20
+ const lastSlash = path.lastIndexOf('/')
21
+ return lastSlash === -1 ? '' : path.slice(0, lastSlash)
22
+ }
23
+
17
24
  /*
18
25
  Builds a reactive document over `initial`. Each path read for the first time
19
26
  mints a signal node; the node is the notification token, the (mutable) tree is
@@ -132,10 +139,7 @@ export function createDoc(initial: unknown): Doc {
132
139
  remove (computed post-apply, below, to resolve an array append's index). */
133
140
  const before = PATCH_BUS.active ? walkPath(tree, patch.path) : undefined
134
141
  tree = applyPatchToTree(tree, patch, segments)
135
- /* parentPath is patch.path minus its last segment — the same string
136
- `segments.slice(0, -1).join('/')` rebuilds, taken by one slice instead. */
137
- const lastSlash = patch.path.lastIndexOf('/')
138
- const parentPath = lastSlash === -1 ? '' : patch.path.slice(0, lastSlash)
142
+ const parentPath = parentPathOf(patch.path)
139
143
  const parentValue = walkPath(tree, parentPath).value
140
144
  const leafKey = segments[segments.length - 1] as string | undefined
141
145
  /* A structural change (add/remove, or an array element replaced by index)
@@ -194,8 +198,7 @@ export function createDoc(initial: unknown): Doc {
194
198
  before: ReturnType<typeof walkPath> | undefined,
195
199
  ): Patch | undefined {
196
200
  if (patch.op === 'add') {
197
- const lastSlash = patch.path.lastIndexOf('/')
198
- const parentPath = lastSlash === -1 ? '' : patch.path.slice(0, lastSlash)
201
+ const parentPath = parentPathOf(patch.path)
199
202
  const parent = walkPath(tree, parentPath).value
200
203
  const resolved =
201
204
  Array.isArray(parent) && patch.path.endsWith('/-')
@@ -223,9 +226,19 @@ export function createDoc(initial: unknown): Doc {
223
226
  const node = nodeFor(path)
224
227
  const segments = path.split('/').map(unescapeKey)
225
228
  const leafKey = segments[segments.length - 1] as string
229
+ /* Auto-vivify missing ancestor objects so binding a nested path on a doc
230
+ booted shallow (e.g. `state({})`) doesn't crash, and a later `set` writes
231
+ into the LIVE tree (so snapshot/persist see it). Mirrors the container
232
+ assumption applyPatchToTree makes — except the patch path is authored, this
233
+ walk is compiler-emitted, so the intermediates may not exist yet. */
226
234
  let parent = tree as Record<string, unknown>
227
235
  for (const segment of segments.slice(0, -1)) {
228
- parent = parent[segment] as Record<string, unknown>
236
+ let next = parent[segment]
237
+ if (next === null || typeof next !== 'object') {
238
+ next = {}
239
+ parent[segment] = next
240
+ }
241
+ parent = next as Record<string, unknown>
229
242
  }
230
243
  return {
231
244
  get: () => readNode(node) as T,
@@ -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)
@@ -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
@@ -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
  }
@@ -9,6 +9,11 @@ page/route registries carry — abide-ui's compiled-component shape.
9
9
  export type UiComponent = ((host: Element, props?: UiProps) => () => void) & {
10
10
  render: (props?: UiProps) => SsrRender
11
11
  hydrate?: (host: Element, props?: UiProps) => () => void
12
+ /* The bare client build (`(host, props) => void`) — appends the component's nodes
13
+ to `host`. A nested child mounts it into a marker range (`mountRange`/`mountChild`)
14
+ instead of the wrapped `mount`, and `hotReplace` re-fills a range with the new
15
+ module's `build` on edit. */
16
+ build: (host: Node, props?: UiProps) => void
12
17
  hydratable?: boolean
13
18
  /* Stable module id (project-relative source path) stamped by `compileModule`,
14
19
  keying the component in the hot-reload registry — see `mountChild`. */
@@ -18,5 +18,14 @@ export function seedStreamedResolution(resolution: StreamedResolution): void {
18
18
  if ('miss' in resolution) {
19
19
  return
20
20
  }
21
- activeCacheStore().entries.set(resolution.key, cacheEntryFromSnapshot(resolution))
21
+ /* Only seed when nothing live holds the key — or when the existing entry is itself
22
+ an unconsumed hydrated seed (`hydrated === true`, cleared by the first cache()
23
+ read). A live/settled non-hydrated entry is authoritative; clobbering it with a
24
+ stale snapshot would drop a fresher value (e.g. one a live fetch already wrote). */
25
+ const { entries } = activeCacheStore()
26
+ const existing = entries.get(resolution.key)
27
+ if (existing !== undefined && existing.hydrated !== true) {
28
+ return
29
+ }
30
+ entries.set(resolution.key, cacheEntryFromSnapshot(resolution))
22
31
  }
@@ -1,13 +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
- export function componentWrapperTag(name: string): string {
12
- return `abide-${name.toLowerCase()}`
13
- }
@@ -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
- }