@abide/abide 0.31.1 → 0.32.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 (41) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +26 -0
  3. package/package.json +1 -2
  4. package/src/checkAbide.ts +11 -6
  5. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +4 -3
  6. package/src/lib/server/runtime/createServer.ts +28 -23
  7. package/src/lib/server/runtime/createUiPageRenderer.ts +1 -1
  8. package/src/lib/ui/compile/AbideCompileError.ts +16 -0
  9. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +0 -1
  10. package/src/lib/ui/compile/abideUiPlugin.ts +25 -2
  11. package/src/lib/ui/compile/bindListenEvent.ts +19 -0
  12. package/src/lib/ui/compile/compileShadow.ts +65 -52
  13. package/src/lib/ui/compile/componentWrapperTag.ts +20 -0
  14. package/src/lib/ui/compile/generateBuild.ts +114 -212
  15. package/src/lib/ui/compile/generateSSR.ts +54 -88
  16. package/src/lib/ui/compile/lowerContext.ts +64 -0
  17. package/src/lib/ui/compile/lowerDocAccess.ts +6 -1
  18. package/src/lib/ui/compile/offsetToLineColumn.ts +16 -0
  19. package/src/lib/ui/compile/scopeAttr.ts +9 -0
  20. package/src/lib/ui/compile/staticAttr.ts +11 -0
  21. package/src/lib/ui/compile/staticTextPart.ts +12 -0
  22. package/src/lib/ui/compile/unwrapParens.ts +10 -0
  23. package/src/lib/ui/dom/applyResolved.ts +9 -6
  24. package/src/lib/ui/dom/awaitBlock.ts +27 -21
  25. package/src/lib/ui/dom/clearBetween.ts +16 -0
  26. package/src/lib/ui/dom/each.ts +64 -38
  27. package/src/lib/ui/dom/eachAsync.ts +41 -54
  28. package/src/lib/ui/dom/fillBefore.ts +16 -0
  29. package/src/lib/ui/dom/moveRange.ts +19 -0
  30. package/src/lib/ui/dom/openMarker.ts +22 -0
  31. package/src/lib/ui/dom/removeRange.ts +18 -0
  32. package/src/lib/ui/dom/switchBlock.ts +32 -40
  33. package/src/lib/ui/dom/tryBlock.ts +31 -35
  34. package/src/lib/ui/dom/types/EachRow.ts +10 -3
  35. package/src/lib/ui/dom/types/SwitchCase.ts +3 -2
  36. package/src/lib/ui/dom/when.ts +34 -43
  37. package/src/lib/ui/installHotBridge.ts +0 -2
  38. package/src/lib/ui/renderToStream.ts +14 -11
  39. package/src/lib/ui/state.ts +14 -5
  40. package/src/lib/ui/compile/branchElements.ts +0 -50
  41. package/src/lib/ui/dom/openRoot.ts +0 -20
@@ -1,3 +1,10 @@
1
- /* A live row in a keyed list: its top node (for placement/removal) and the
2
- disposer for the bindings created in its ownership scope. */
3
- export type EachRow = { node: Node; dispose: () => void }
1
+ /* A live row in a keyed list: a content RANGE bounded by two comment markers (so a
2
+ row holds any content, not just one node), plus the disposer for the bindings
3
+ created in its ownership scope. `pending` holds a freshly built row's nodes in a
4
+ fragment until first placement inserts them. */
5
+ export type EachRow = {
6
+ start: Node
7
+ end: Node
8
+ dispose: () => void
9
+ pending?: DocumentFragment
10
+ }
@@ -1,6 +1,7 @@
1
1
  /* One branch of a `switch`: `match` returns the value this case selects on
2
- (undefined = the default branch); `render` builds the branch's element roots. */
2
+ (undefined = the default branch); `render` builds the branch's content into the
3
+ parent (the block tracks it as a range between markers). */
3
4
  export type SwitchCase = {
4
5
  match: (() => unknown) | undefined
5
- render: (parent: Node) => Node[]
6
+ render: (parent: Node) => void
6
7
  }
@@ -1,51 +1,51 @@
1
1
  import { effect } from '../effect.ts'
2
- import { claimChild } from '../runtime/claimChild.ts'
3
2
  import { RENDER } from '../runtime/RENDER.ts'
4
3
  import { scope } from '../runtime/scope.ts'
4
+ import { clearBetween } from './clearBetween.ts'
5
+ import { fillBefore } from './fillBefore.ts'
6
+ import { openMarker } from './openMarker.ts'
5
7
 
6
8
  /*
7
- Conditional binding — the runtime for `<template if>` (with optional `else`). An
8
- effect tracks `condition()` and mounts the matching branch (`render` truthy,
9
- `renderElse` falsy), anchored for placement; only a truthy↔falsy flip swaps. A
10
- branch is a RANGE of element roots (one or more), tracked as a node array so a
11
- multi-root branch inserts/removes as a unit.
9
+ Conditional binding — the runtime for `<template if>` (with optional `else`). The
10
+ branch's content lives in a RANGE bounded by two comment markers, so a branch may
11
+ hold anything elements, components, text, nested control-flow, snippets not
12
+ just element roots. An effect tracks `condition()` and swaps the range's content
13
+ on a truthy↔falsy flip (`render` truthy, `renderElse` falsy); an unchanged
14
+ condition is a no-op.
12
15
 
13
- On hydrate it adopts the branch the server rendered: it runs the matching render
14
- in place (its roots claim the existing nodes), then inserts an anchor after them
15
- for future toggles. The effect's first run sees the same branch and is a no-op;
16
- later toggles (after hydration ends) build fresh.
16
+ On hydrate it adopts the server-rendered range: claim the start marker, run the
17
+ matching render in place (its content claims the existing nodes), then claim the
18
+ end marker. The effect's first run sees the same branch and is a no-op; later
19
+ toggles clear the range and build fresh into a fragment.
17
20
  */
18
21
  // @readme plumbing
19
22
  export function when(
20
23
  parent: Node,
21
24
  condition: () => unknown,
22
- render: (parent: Node) => Node[],
23
- renderElse?: (parent: Node) => Node[],
25
+ render: (parent: Node) => void,
26
+ renderElse?: (parent: Node) => void,
24
27
  ): void {
25
28
  const hydration = RENDER.hydration
26
- let active: { nodes: Node[]; dispose: () => void } | undefined
27
- let activeBranch: 'then' | 'else' | undefined
28
- let anchor: Node
29
-
30
- const build = (chosen: (parent: Node) => Node[]): { nodes: Node[]; dispose: () => void } => {
31
- let nodes: Node[] = []
32
- const dispose = scope(() => {
33
- nodes = chosen(parent)
34
- })
35
- return { nodes, dispose }
36
- }
29
+ const chosenFor = (branch: 'then' | 'else') => (branch === 'then' ? render : renderElse)
30
+ let dispose: (() => void) | undefined
31
+ let activeBranch: 'then' | 'else'
32
+ let end: Comment
37
33
 
34
+ const start = openMarker(parent, '[')
38
35
  if (hydration !== undefined) {
39
36
  activeBranch = condition() ? 'then' : 'else'
40
- const chosen = activeBranch === 'then' ? render : renderElse
37
+ const chosen = chosenFor(activeBranch)
41
38
  if (chosen !== undefined) {
42
- active = build(chosen) // roots claim the SSR nodes in place
39
+ dispose = scope(() => chosen(parent)) // content claims the SSR nodes in place
43
40
  }
44
- anchor = document.createTextNode('')
45
- parent.insertBefore(anchor, claimChild(hydration, parent))
41
+ end = openMarker(parent, ']')
46
42
  } else {
47
- anchor = document.createTextNode('')
48
- parent.appendChild(anchor)
43
+ end = openMarker(parent, ']')
44
+ activeBranch = condition() ? 'then' : 'else'
45
+ const chosen = chosenFor(activeBranch)
46
+ if (chosen !== undefined) {
47
+ dispose = fillBefore(end, chosen)
48
+ }
49
49
  }
50
50
 
51
51
  effect(() => {
@@ -53,21 +53,12 @@ export function when(
53
53
  if (branch === activeBranch) {
54
54
  return
55
55
  }
56
- if (active !== undefined) {
57
- active.dispose()
58
- for (const node of active.nodes) {
59
- parent.removeChild(node)
60
- }
61
- active = undefined
62
- }
56
+ clearBetween(start, end, dispose)
57
+ dispose = undefined
63
58
  activeBranch = branch
64
- const chosen = branch === 'then' ? render : renderElse
65
- if (chosen === undefined) {
66
- return
67
- }
68
- active = build(chosen)
69
- for (const node of active.nodes) {
70
- parent.insertBefore(node, anchor)
59
+ const chosen = chosenFor(branch)
60
+ if (chosen !== undefined) {
61
+ dispose = fillBefore(end, chosen)
71
62
  }
72
63
  })
73
64
  }
@@ -16,7 +16,6 @@ import { mount } from './dom/mount.ts'
16
16
  import { mountChild } from './dom/mountChild.ts'
17
17
  import { on } from './dom/on.ts'
18
18
  import { openChild } from './dom/openChild.ts'
19
- import { openRoot } from './dom/openRoot.ts'
20
19
  import { switchBlock } from './dom/switchBlock.ts'
21
20
  import { tryBlock } from './dom/tryBlock.ts'
22
21
  import { when } from './dom/when.ts'
@@ -50,7 +49,6 @@ export function installHotBridge(): void {
50
49
  effect,
51
50
  mount,
52
51
  openChild,
53
- openRoot,
54
52
  appendText,
55
53
  appendSnippet,
56
54
  appendStatic,
@@ -5,10 +5,11 @@ import type { SsrAwait, SsrRender } from './runtime/types/SsrRender.ts'
5
5
  Out-of-order SSR streaming. Yields the pending shell first (so the browser paints
6
6
  immediately), then one resolved fragment per await block as its promise settles —
7
7
  in completion order, not source order, so a slow read never blocks a fast one.
8
- Each resolved fragment is a `<abide-resolve data-id="ID" data-resume="">…</abide-resolve>`
9
- that `applyResolved` swaps into the matching `<!--abide:await:ID-->` boundary; the
10
- `data-resume` payload is the JSON-serialized value, registered for hydration so an
11
- `await` block adopts the resolved branch on resume instead of re-running.
8
+ Each resolved fragment is a `<abide-resolve data-id="ID"><script type="application/json">
9
+ …</script>…</abide-resolve>` that `applyResolved` swaps into the matching
10
+ `<!--abide:await:ID-->` boundary; the leading script holds the JSON-serialized value,
11
+ registered for hydration so an `await` block adopts the resolved branch on resume
12
+ instead of re-running.
12
13
 
13
14
  This is the await-block-streams half of the cache rule: a top-level `await` in the
14
15
  script would have blocked the shell (inlined), but an await *block* flushes its
@@ -46,7 +47,9 @@ export async function* renderToStream(render: () => SsrRender): AsyncGenerator<s
46
47
  const resolved = await Promise.race(inflight.values())
47
48
  inflight.delete(resolved.id)
48
49
  const resume = encodeResume(resolved.resume)
49
- yield `<abide-resolve data-id="${resolved.id}" data-resume="${resume}">${resolved.html}</abide-resolve>`
50
+ yield `<abide-resolve data-id="${resolved.id}">` +
51
+ `<script type="application/json">${resume}</script>` +
52
+ `${resolved.html}</abide-resolve>`
50
53
  }
51
54
  }
52
55
 
@@ -94,11 +97,11 @@ function settle(block: SsrAwait): Promise<Settled> {
94
97
  )
95
98
  }
96
99
 
97
- /* JSON for an HTML double-quoted attribute: escape `"` and `&` (and `<` for safety
98
- inside markup). `applyResolved`/the inline swap script decode it via the DOM. */
100
+ /* JSON for a `<script type="application/json">` data block: script content is raw
101
+ text, so only `<` needs neutralizing (emitted as a unicode escape) to keep a
102
+ literal `</script>` from closing the block early — quotes stay raw. Far cheaper
103
+ than attribute escaping (no full-string `"`/`&` passes) and JSON.parse decodes it
104
+ back. `applyResolved`/the inline swap script read it via `.textContent`. */
99
105
  function encodeResume(resume: ResumeEntry): string {
100
- return JSON.stringify(resume)
101
- .replace(/&/g, '&amp;')
102
- .replace(/"/g, '&quot;')
103
- .replace(/</g, '&lt;')
106
+ return JSON.stringify(resume).replace(/</g, '\\u003c')
104
107
  }
@@ -18,15 +18,24 @@ mirror of `derived`'s write-through `set` — here the value lives in this cell,
18
18
  the gate *returns* what to store rather than writing an external target. The
19
19
  construction `initial` is taken verbatim; the gate runs on writes only.
20
20
  */
21
+ /* No-arg form for an undefined initial with a declared type: `state<Foo>()` is
22
+ `State<Foo | undefined>`. Without it `state<Foo>(undefined)` is an arity/assign
23
+ error and `state(undefined)` infers `T = undefined` (every `.value` access then
24
+ narrows to `never`). */
21
25
  // @readme plumbing
22
- export function state<T>(initial: T, transform?: (next: T, previous: T) => T): State<T> {
26
+ export function state<T>(): State<T | undefined>
27
+ export function state<T>(initial: T, transform?: (next: T, previous: T) => T): State<T>
28
+ export function state<T>(
29
+ initial?: T,
30
+ transform?: (next: T, previous: T) => T,
31
+ ): State<T | undefined> {
23
32
  const node = createSignalNode(initial)
24
33
  return {
25
- get value(): T {
26
- return readNode(node) as T
34
+ get value(): T | undefined {
35
+ return readNode(node) as T | undefined
27
36
  },
28
- set value(next: T) {
29
- writeNode(node, transform === undefined ? next : transform(next, node.value as T))
37
+ set value(next: T | undefined) {
38
+ writeNode(node, transform === undefined ? next : transform(next as T, node.value as T))
30
39
  },
31
40
  }
32
41
  }
@@ -1,50 +0,0 @@
1
- import type { TemplateNode } from './types/TemplateNode.ts'
2
-
3
- /*
4
- The element roots of a control-flow branch (`if`/`else`/`switch case`/`await
5
- then|catch`). A branch may hold one or MORE top-level elements — each becomes a
6
- root the block tracks as a range. Whitespace-only text between/around them is
7
- dropped (so SSR and the client build agree on the node set, keeping hydration
8
- aligned). Any other top-level content — meaningful text, a component, or a nested
9
- control-flow `<template>` — must be wrapped in an element; it throws a clear error
10
- rather than silently dropping (full fragment roots are a separate feature).
11
-
12
- Both back-ends call this, so the server HTML and the client build contain exactly
13
- the same roots in the same order.
14
- */
15
- type ElementNode = Extract<TemplateNode, { kind: 'element' }>
16
-
17
- export function branchElements(
18
- children: TemplateNode[],
19
- context: string,
20
- allowEmpty = false,
21
- ): ElementNode[] {
22
- const elements: ElementNode[] = []
23
- for (const child of children) {
24
- if (child.kind === 'element') {
25
- elements.push(child)
26
- continue
27
- }
28
- /* Whitespace-only text is layout noise between roots — drop it. */
29
- if (child.kind === 'text' && isWhitespaceOnly(child)) {
30
- continue
31
- }
32
- /* A scoped `<script>` is emitted as code by the back-end, not a root; a
33
- `<style>` is bundled CSS (its scope already stamped on the roots). */
34
- if (child.kind === 'script' || child.kind === 'style') {
35
- continue
36
- }
37
- throw new Error(
38
- `[abide] ${context} content must be element(s); wrap text / components / nested <template> in an element`,
39
- )
40
- }
41
- if (elements.length === 0 && !allowEmpty) {
42
- throw new Error(`[abide] ${context} must contain at least one element`)
43
- }
44
- return elements
45
- }
46
-
47
- /* A text node whose parts are all whitespace literals (no interpolation). */
48
- function isWhitespaceOnly(node: Extract<TemplateNode, { kind: 'text' }>): boolean {
49
- return node.parts.every((part) => part.kind === 'static' && part.value.trim() === '')
50
- }
@@ -1,20 +0,0 @@
1
- import { claimChild } from '../runtime/claimChild.ts'
2
- import { RENDER } from '../runtime/RENDER.ts'
3
-
4
- /*
5
- Opens the root element of a control-flow branch/row, which the block inserts
6
- itself. In create mode it returns a detached element (the block does the
7
- insert); in hydrate mode it claims the existing server-rendered root from the
8
- parent's claim pointer (in place — the block adopts it). The compiler emits this
9
- for `each`/`if`/`switch`/`await` branch roots so adoption and creation share code.
10
- */
11
- // @readme plumbing
12
- export function openRoot(parent: Node, tag: string): Element {
13
- const hydration = RENDER.hydration
14
- if (hydration !== undefined) {
15
- const current = claimChild(hydration, parent)
16
- hydration.next.set(parent, current === null ? null : current.nextSibling)
17
- return current as unknown as Element
18
- }
19
- return document.createElement(tag)
20
- }