@abide/abide 0.29.0 → 0.31.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 (44) hide show
  1. package/AGENTS.md +6 -4
  2. package/CHANGELOG.md +32 -0
  3. package/package.json +2 -1
  4. package/src/lib/bundle/disconnected.abide +82 -82
  5. package/src/lib/cli/dispatchCommand.ts +3 -2
  6. package/src/lib/cli/resolveCliTarget.ts +2 -3
  7. package/src/lib/cli/runCli.ts +2 -3
  8. package/src/lib/cli/runSession.ts +2 -3
  9. package/src/lib/mcp/dispatchMcpRequest.ts +3 -2
  10. package/src/lib/mcp/mcpSurface.ts +2 -1
  11. package/src/lib/mcp/toolResultFromResponse.ts +2 -1
  12. package/src/lib/server/rpc/parseArgs.ts +1 -3
  13. package/src/lib/server/runtime/streamFromIterator.ts +3 -1
  14. package/src/lib/server/runtime/warnUnguardedMcp.ts +4 -3
  15. package/src/lib/server/sockets/createSocketDispatcher.ts +5 -7
  16. package/src/lib/shared/cacheEntryFromSnapshot.ts +2 -1
  17. package/src/lib/shared/contentTypeOf.ts +6 -0
  18. package/src/lib/shared/decodeResponse.ts +2 -1
  19. package/src/lib/shared/isCompileTarget.ts +7 -1
  20. package/src/lib/shared/isModuleNotFound.ts +3 -1
  21. package/src/lib/shared/isStreamingResponse.ts +2 -1
  22. package/src/lib/shared/messageFromError.ts +6 -0
  23. package/src/lib/shared/streamResponse.ts +2 -1
  24. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +7 -0
  25. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  26. package/src/lib/ui/compile/VOID_TAGS.ts +4 -3
  27. package/src/lib/ui/compile/compileComponent.ts +12 -2
  28. package/src/lib/ui/compile/compileModule.ts +7 -4
  29. package/src/lib/ui/compile/compileSSR.ts +11 -2
  30. package/src/lib/ui/compile/compileShadow.ts +165 -54
  31. package/src/lib/ui/compile/createShadowLanguageService.ts +2 -1
  32. package/src/lib/ui/compile/createShadowProgram.ts +2 -1
  33. package/src/lib/ui/compile/desugarSignals.ts +41 -14
  34. package/src/lib/ui/compile/parseTemplate.ts +21 -26
  35. package/src/lib/ui/compile/prepareNestedScript.ts +4 -2
  36. package/src/lib/ui/derived.ts +25 -4
  37. package/src/lib/ui/dom/awaitBlock.ts +1 -24
  38. package/src/lib/ui/dom/discardBoundary.ts +27 -0
  39. package/src/lib/ui/dom/tryBlock.ts +7 -26
  40. package/src/lib/ui/installHotBridge.ts +2 -0
  41. package/src/lib/ui/linked.ts +34 -0
  42. package/src/lib/ui/router.ts +23 -6
  43. package/src/lib/ui/state.ts +9 -2
  44. package/template/src/ui/pages/page.abide +1 -1
@@ -2,6 +2,7 @@ import { decodeHtmlEntities } from './decodeHtmlEntities.ts'
2
2
  import type { TemplateAttr } from './types/TemplateAttr.ts'
3
3
  import type { TemplateNode } from './types/TemplateNode.ts'
4
4
  import type { TextPart } from './types/TextPart.ts'
5
+ import { VOID_TAGS } from './VOID_TAGS.ts'
5
6
 
6
7
  /*
7
8
  A minimal compile-time parser for the abide template subset: elements, text with
@@ -19,23 +20,6 @@ text, never mistaken for a real style. Keeping it in the tree lets the front-end
19
20
  scope it to its sibling subtree (`analyzeComponent`); the node emits no DOM/markup.
20
21
  */
21
22
 
22
- const VOID_TAGS = new Set([
23
- 'area',
24
- 'base',
25
- 'br',
26
- 'col',
27
- 'embed',
28
- 'hr',
29
- 'img',
30
- 'input',
31
- 'link',
32
- 'meta',
33
- 'param',
34
- 'source',
35
- 'track',
36
- 'wbr',
37
- ])
38
-
39
23
  /* A braced template expression with the absolute source offset of its first
40
24
  (post-trim) character, so the type-checking shadow can map a diagnostic back. */
41
25
  type Braced = { code: string; loc: number }
@@ -245,18 +229,29 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
245
229
  return { nodes: roots }
246
230
  }
247
231
 
248
- /* Turns a component's attributes into props: a static value becomes a string
249
- literal, an expression keeps its code (event/bind on components ignored). */
232
+ /* Turns a component's attributes into props. A component has no directives
233
+ every attribute is a prop under its written name, so `on*`/`bind:`/`attach`
234
+ round-trip to their original names (the kinds the tag-blind attribute parser
235
+ assigned) instead of being dropped. A static value becomes a string literal;
236
+ every other kind keeps its `code`, letting a prop hold any value, functions
237
+ included (e.g. an `onclick` callback). */
250
238
  function toProps(attrs: TemplateAttr[]): { name: string; code: string; loc?: number }[] {
251
- const props: { name: string; code: string; loc?: number }[] = []
252
- for (const attr of attrs) {
239
+ return attrs.map((attr) => {
253
240
  if (attr.kind === 'static') {
254
- props.push({ name: attr.name, code: JSON.stringify(attr.value) })
255
- } else if (attr.kind === 'expression') {
256
- props.push({ name: attr.name, code: attr.code, loc: attr.loc })
241
+ return { name: attr.name, code: JSON.stringify(attr.value) }
257
242
  }
258
- }
259
- return props
243
+ /* Every non-static kind keeps its `code`/`loc`; only the prop name differs —
244
+ a directive (`event`/`bind`/`attach`) round-trips to its written name. */
245
+ const name =
246
+ attr.kind === 'event'
247
+ ? `on${attr.event}`
248
+ : attr.kind === 'bind'
249
+ ? `bind:${attr.property}`
250
+ : attr.kind === 'attach'
251
+ ? 'attach'
252
+ : attr.name
253
+ return { name, code: attr.code, loc: attr.loc }
254
+ })
260
255
  }
261
256
 
262
257
  /* The literal text of an attribute (a static value or an expression's code);
@@ -1,8 +1,9 @@
1
1
  import ts from 'typescript'
2
+ import { REACTIVE_CALLEES } from './REACTIVE_CALLEES.ts'
2
3
 
3
4
  /*
4
5
  The signal binding names a `<script>` nested in a control-flow branch declares
5
- (`state`/`derived`/`prop`). The back-end adds them to the deref scope so both the
6
+ (`state`/`linked`/`derived`/`prop`). The back-end adds them to the deref scope so both the
6
7
  script body and the branch's markup rewrite `{a}` → `a.value` — these stay PLAIN
7
8
  signals (local to the branch's render, owned by its scope, re-seeded from the
8
9
  in-scope data each mount), unlike the top-level component script which desugars to
@@ -18,7 +19,8 @@ export function nestedBindingNames(code: string): Set<string> {
18
19
  for (const declaration of statement.declarationList.declarations) {
19
20
  const callee = signalCallee(declaration)
20
21
  if (
21
- (callee === 'state' || callee === 'derived' || callee === 'prop') &&
22
+ callee !== undefined &&
23
+ REACTIVE_CALLEES.has(callee) &&
22
24
  ts.isIdentifier(declaration.name)
23
25
  ) {
24
26
  names.add(declaration.name.text)
@@ -2,15 +2,26 @@ import { createComputedNode } from './runtime/createComputedNode.ts'
2
2
  import { OWNER } from './runtime/OWNER.ts'
3
3
  import { readNode } from './runtime/readNode.ts'
4
4
  import type { Derived } from './runtime/types/Derived.ts'
5
+ import type { State } from './runtime/types/State.ts'
5
6
  import { unlinkDeps } from './runtime/unlinkDeps.ts'
6
7
 
7
8
  /*
8
- A read-only reactive cell computed from other cells — the abide replacement for
9
- `$derived`. Lazy: it recomputes on read only when a dependency has changed, and
10
- never serializes (it is re-derived from its inputs on resume). Read via `.value`.
9
+ A reactive cell computed from other cells — the abide replacement for `$derived`.
10
+ Lazy: it recomputes on read only when a dependency has changed, and never
11
+ serializes (it is re-derived from its inputs on resume). Read via `.value`.
12
+
13
+ With a `set`, it becomes a writable lens (Vue/Svelte's writable computed): the
14
+ value still derives from upstream, but assigning `.value` runs `set`, whose job
15
+ is to write *through* to the upstream sources. The write retriggers those
16
+ sources, marking this computed dirty, so the next read recomputes — there is no
17
+ local store, upstream stays the single source of truth. `set` is imperative
18
+ (void): it writes an external target, unlike `state`/`linked`'s `transform`,
19
+ which returns a value into their own store.
11
20
  */
12
21
  // @readme plumbing
13
- export function derived<T>(compute: () => T): Derived<T> {
22
+ export function derived<T>(compute: () => T): Derived<T>
23
+ export function derived<T>(compute: () => T, set: (next: T) => void): State<T>
24
+ export function derived<T>(compute: () => T, set?: (next: T) => void): Derived<T> | State<T> {
14
25
  const node = createComputedNode(compute as () => unknown)
15
26
  /* Tear down with the enclosing scope, the way an effect does. A computed only
16
27
  unlinks from its sources when it re-runs (`runNode` re-tracking); one read
@@ -20,9 +31,19 @@ export function derived<T>(compute: () => T): Derived<T> {
20
31
  if (OWNER.current !== undefined) {
21
32
  OWNER.current.push(() => unlinkDeps(node))
22
33
  }
34
+ if (set === undefined) {
35
+ return {
36
+ get value(): T {
37
+ return readNode(node) as T
38
+ },
39
+ }
40
+ }
23
41
  return {
24
42
  get value(): T {
25
43
  return readNode(node) as T
26
44
  },
45
+ set value(next: T) {
46
+ set(next)
47
+ },
27
48
  }
28
49
  }
@@ -3,6 +3,7 @@ import { claimChild } from '../runtime/claimChild.ts'
3
3
  import { RENDER } from '../runtime/RENDER.ts'
4
4
  import { RESUME } from '../runtime/RESUME.ts'
5
5
  import { scope } from '../runtime/scope.ts'
6
+ import { discardBoundary } from './discardBoundary.ts'
6
7
 
7
8
  /*
8
9
  Async binding — the runtime for `<template await>`. Renders the pending branch,
@@ -198,27 +199,3 @@ export function awaitBlock(
198
199
  function isThenable(value: unknown): value is Promise<unknown> {
199
200
  return value !== null && typeof (value as { then?: unknown })?.then === 'function'
200
201
  }
201
-
202
- /* Remove the SSR boundary — open marker through close marker (inclusive) — and
203
- park the hydration cursor on the node after it, so a fresh run replaces it
204
- without duplicating the server's pending shell. */
205
- function discardBoundary(
206
- parent: Node,
207
- open: Node | null,
208
- closeData: string,
209
- hydration: NonNullable<(typeof RENDER)['hydration']>,
210
- ): void {
211
- let node = open
212
- let after: Node | null = null
213
- while (node !== null) {
214
- const next = node.nextSibling
215
- const isClose = (node as { data?: string }).data === closeData
216
- parent.removeChild(node)
217
- if (isClose) {
218
- after = next
219
- break
220
- }
221
- node = next
222
- }
223
- hydration.next.set(parent, after)
224
- }
@@ -0,0 +1,27 @@
1
+ import type { RENDER } from '../runtime/RENDER.ts'
2
+
3
+ /* Remove an SSR boundary — open marker through close marker (inclusive) — and park
4
+ the hydration cursor on the node after it, returning that node. A fresh run then
5
+ replaces the boundary in place without duplicating the server's pending shell.
6
+ Shared by the await and try blocks (await ignores the return). */
7
+ export function discardBoundary(
8
+ parent: Node,
9
+ open: Node | null,
10
+ closeData: string,
11
+ hydration: NonNullable<(typeof RENDER)['hydration']>,
12
+ ): Node | null {
13
+ let node = open
14
+ let after: Node | null = null
15
+ while (node !== null) {
16
+ const next = node.nextSibling
17
+ const isClose = (node as { data?: string }).data === closeData
18
+ parent.removeChild(node)
19
+ if (isClose) {
20
+ after = next
21
+ break
22
+ }
23
+ node = next
24
+ }
25
+ hydration.next.set(parent, after)
26
+ return after
27
+ }
@@ -1,6 +1,7 @@
1
1
  import { claimChild } from '../runtime/claimChild.ts'
2
2
  import { OWNER } from '../runtime/OWNER.ts'
3
3
  import { RENDER } from '../runtime/RENDER.ts'
4
+ import { discardBoundary } from './discardBoundary.ts'
4
5
 
5
6
  /*
6
7
  Synchronous error boundary — the runtime for `<template try>`. Builds the guarded
@@ -26,7 +27,12 @@ export function tryBlock(
26
27
  renderCatch?: (parent: Node, error: unknown) => Node[],
27
28
  ): void {
28
29
  /* Run a build under a fresh ownership scope; on throw, tear down the partial
29
- effects/listeners it registered and rethrow so the caller can fall back. */
30
+ effects/listeners it registered and rethrow so the caller can fall back.
31
+ Deliberately not `scope()`: that returns a deferred disposer and leaks the
32
+ partial scope on throw (it only restores the owner), whereas an error boundary
33
+ must dispose eagerly when the guarded build throws and hand back the built
34
+ `Node[]` (not a disposer) on success — those nodes belong to the enclosing
35
+ scope. Different return type, different throw semantics; merging would be wrong. */
30
36
  const buildScoped = (build: () => Node[]): Node[] => {
31
37
  const previous = OWNER.current
32
38
  const disposers: Array<() => void> = []
@@ -85,28 +91,3 @@ export function tryBlock(
85
91
  parent.appendChild(node)
86
92
  }
87
93
  }
88
-
89
- /* Remove the SSR boundary — open marker through close marker (inclusive) — and
90
- park the hydration cursor on the node after it, returning that node so a fresh
91
- catch can be inserted in the boundary's place. */
92
- function discardBoundary(
93
- parent: Node,
94
- open: Node | null,
95
- closeData: string,
96
- hydration: NonNullable<(typeof RENDER)['hydration']>,
97
- ): Node | null {
98
- let node = open
99
- let after: Node | null = null
100
- while (node !== null) {
101
- const next = node.nextSibling
102
- const isClose = (node as { data?: string }).data === closeData
103
- parent.removeChild(node)
104
- if (isClose) {
105
- after = next
106
- break
107
- }
108
- node = next
109
- }
110
- hydration.next.set(parent, after)
111
- return after
112
- }
@@ -21,6 +21,7 @@ import { switchBlock } from './dom/switchBlock.ts'
21
21
  import { tryBlock } from './dom/tryBlock.ts'
22
22
  import { when } from './dom/when.ts'
23
23
  import { effect } from './effect.ts'
24
+ import { linked } from './linked.ts'
24
25
  import { enterRenderPass } from './runtime/enterRenderPass.ts'
25
26
  import { exitRenderPass } from './runtime/exitRenderPass.ts'
26
27
  import { hotReloadEnabled } from './runtime/hotReloadEnabled.ts'
@@ -44,6 +45,7 @@ export function installHotBridge(): void {
44
45
  snippet,
45
46
  doc,
46
47
  state,
48
+ linked,
47
49
  derived,
48
50
  effect,
49
51
  mount,
@@ -0,0 +1,34 @@
1
+ import { createEffectNode } from './runtime/createEffectNode.ts'
2
+ import type { State } from './runtime/types/State.ts'
3
+ import { state } from './state.ts'
4
+
5
+ /*
6
+ A writable cell seeded reactively from upstream — the abide form of Angular's
7
+ `linkedSignal`. Like `state` it owns a local value and can diverge from its
8
+ source (an edit-form draft, a local working copy); unlike `state` the seed is a
9
+ reactive thunk, so the cell reseeds whenever the thunk's dependencies change.
10
+ Between reseeds it holds whatever was written to it, and edits never flow upstream
11
+ (the seed reads, it does not write). The thunk is mandatory: it *is* the
12
+ reactivity — a bare value can never reseed, which would just make this `state`.
13
+
14
+ `transform` is the same coercion gate as on `state`, and it runs on *every* value
15
+ entering the store — explicit `.value =` writes and reseeds alike — so the store
16
+ never holds an un-coerced value (`return previous` rejects via the `Object.is`
17
+ no-op). The seed is captured by reference: callers clone in the thunk
18
+ (`linked(() => structuredClone(x))`) when they want isolation.
19
+ */
20
+ // @readme plumbing
21
+ export function linked<T>(seed: () => T, transform?: (next: T, previous: T) => T): State<T> {
22
+ /* The cell is a plain `state` — same store, same write path, so `transform` gates
23
+ reseeds and explicit writes identically. */
24
+ const cell = state<T>(undefined as T, transform)
25
+ /* Reactive reseed: the effect tracks the seed thunk and writes the cell when its
26
+ sources change. The cell is only written (its setter reads the store as a plain
27
+ field, never `readNode`), so it stays off the effect's dependency list — only
28
+ what `seed` reads can retrigger it. `createEffectNode` registers the disposer
29
+ with the enclosing scope. */
30
+ createEffectNode(() => {
31
+ cell.value = seed()
32
+ })
33
+ return cell
34
+ }
@@ -240,7 +240,7 @@ export function router(
240
240
  }
241
241
  /* Publish the active page so the `page` proxy resolves route/params/url. */
242
242
  clientPage.value = {
243
- route: matched?.route ?? pathname,
243
+ route: chainRoute,
244
244
  params,
245
245
  url:
246
246
  typeof location === 'undefined'
@@ -264,12 +264,29 @@ export function router(
264
264
  const hydrating =
265
265
  first && pageView?.hydratable === true && pageView.hydrate !== undefined
266
266
  first = false
267
- const base = disposeFrom(divergence)
268
- /* A fresh mount clears the torn-down DOM; hydration adopts it in place. */
269
- if (!hydrating) {
270
- base.textContent = ''
267
+ /* The DOM mutation a navigation makes: tear the divergent chain down,
268
+ clear its DOM (a fresh mount; hydration adopts in place), rebuild. */
269
+ const swap = (): void => {
270
+ const base = disposeFrom(divergence)
271
+ if (!hydrating) {
272
+ base.textContent = ''
273
+ }
274
+ buildFrom(base, divergence, chainKeys, layoutViews, pageView, params, hydrating)
275
+ }
276
+ /* Wrap the swap in a View Transition where the browser supports it, so
277
+ the page change cross-fades (and shared `view-transition-name` elements
278
+ morph) — the synchronous swap is exactly the mutation the API snapshots
279
+ around. Skipped while hydrating: the first paint adopts SSR DOM in place,
280
+ not animate. CSS owns opting out (e.g. prefers-reduced-motion). */
281
+ if (
282
+ !hydrating &&
283
+ typeof document !== 'undefined' &&
284
+ 'startViewTransition' in document
285
+ ) {
286
+ document.startViewTransition(swap)
287
+ } else {
288
+ swap()
271
289
  }
272
- buildFrom(base, divergence, chainKeys, layoutViews, pageView, params, hydrating)
273
290
  })
274
291
  })
275
292
  })
@@ -10,16 +10,23 @@ plain getter/setter over a signal node, so a read/write shows up as exactly that
10
10
  in a stack trace. The compiler's job (later) is only to auto-deref `{cell}` in
11
11
  templates and tag this declaration as a serializable manifest slot; the runtime
12
12
  needs no magic.
13
+
14
+ `transform` is an optional coercion gate on the write path: every `.value =`
15
+ runs it and stores what it returns, with `previous` for clamp-relative writes or
16
+ rejection (`return previous` is an `Object.is` no-op). It is the local-truth
17
+ mirror of `derived`'s write-through `set` — here the value lives in this cell, so
18
+ the gate *returns* what to store rather than writing an external target. The
19
+ construction `initial` is taken verbatim; the gate runs on writes only.
13
20
  */
14
21
  // @readme plumbing
15
- export function state<T>(initial: T): State<T> {
22
+ export function state<T>(initial: T, transform?: (next: T, previous: T) => T): State<T> {
16
23
  const node = createSignalNode(initial)
17
24
  return {
18
25
  get value(): T {
19
26
  return readNode(node) as T
20
27
  },
21
28
  set value(next: T) {
22
- writeNode(node, next)
29
+ writeNode(node, transform === undefined ? next : transform(next, node.value as T))
23
30
  },
24
31
  }
25
32
  }
@@ -10,8 +10,8 @@ replayed on the client during hydration with no second fetch. A `then` *child*
10
10
  instead would stream the resolution in out of order.
11
11
  */
12
12
  import { cache } from '@abide/abide/shared/cache'
13
- import Layout from '../Layout.abide'
14
13
  import { getHello } from '$server/rpc/getHello.ts'
14
+ import Layout from '../Layout.abide'
15
15
  </script>
16
16
 
17
17
  <Layout>