@abide/abide 0.36.0 → 0.37.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 (32) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/package.json +1 -1
  4. package/src/lib/server/runtime/buildInFlightSnapshot.ts +35 -0
  5. package/src/lib/server/runtime/buildInspectorSurface.ts +9 -1
  6. package/src/lib/server/runtime/createServer.ts +10 -1
  7. package/src/lib/server/runtime/inFlightRequests.ts +13 -0
  8. package/src/lib/server/runtime/maybeMountInspector.ts +7 -0
  9. package/src/lib/server/runtime/runWithRequestScope.ts +7 -0
  10. package/src/lib/server/runtime/types/InspectorContext.ts +4 -0
  11. package/src/lib/server/runtime/types/InspectorInFlightRequest.ts +20 -0
  12. package/src/lib/server/runtime/types/InspectorInFlightSnapshot.ts +10 -0
  13. package/src/lib/server/runtime/types/InspectorPrompt.ts +15 -0
  14. package/src/lib/server/runtime/types/InspectorSurface.ts +6 -3
  15. package/src/lib/shared/emitLogRecord.ts +18 -5
  16. package/src/lib/ui/compile/componentWrapperTag.ts +10 -20
  17. package/src/lib/ui/compile/generateBuild.ts +18 -9
  18. package/src/lib/ui/compile/generateSSR.ts +2 -2
  19. package/src/lib/ui/createScope.ts +11 -0
  20. package/src/lib/ui/dom/hydrate.ts +2 -1
  21. package/src/lib/ui/dom/mount.ts +2 -1
  22. package/src/lib/ui/dom/scopeLabel.ts +15 -0
  23. package/src/lib/ui/dom/skeleton.ts +13 -1
  24. package/src/lib/ui/installInspectorBridge.ts +138 -0
  25. package/src/lib/ui/navigate.ts +11 -3
  26. package/src/lib/ui/router.ts +77 -9
  27. package/src/lib/ui/runtime/historyEntries.ts +113 -0
  28. package/src/lib/ui/runtime/liveScopes.ts +15 -0
  29. package/src/lib/ui/runtime/types/AbideHistoryState.ts +9 -0
  30. package/src/lib/ui/startClient.ts +6 -0
  31. package/src/lib/ui/types/Scope.ts +3 -0
  32. package/src/lib/ui/compile/HTML_TAGS.ts +0 -132
package/AGENTS.md CHANGED
@@ -100,7 +100,7 @@ Test suites compile `.abide` and rewrite verbs under `bun test` via `preload = [
100
100
 
101
101
  - `abide/server/AppModule` — the type of `src/app.ts`'s optional hooks: `init` (boot + cleanup), `handle` (middleware), `handleError`, `health` (merges into `/__abide/health`), `forwardHeaders`.
102
102
  - `abide/server/agent` — `agent(engine, messages)`: run a model engine against the current request's MCP surface (caller auth forwarded into every tool call), yielding neutral `AgentFrame`s to wrap in `jsonl`/`sse`.
103
- - `abide/server/InspectorContext` — the capability bundle (`app`, `loadSurface`, `cacheSnapshot`, `onRecord`) handed to `@abide/inspector` when `ABIDE_ENABLE_INSPECTOR=true`.
103
+ - `abide/server/InspectorContext` — the capability bundle (`app`, `loadSurface`, `cacheSnapshot`, `inFlightSnapshot`, `onRecord`) handed to `@abide/inspector` when `ABIDE_ENABLE_INSPECTOR=true`.
104
104
 
105
105
  ### Prompts — @documentation plumbing
106
106
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # abide
2
2
 
3
+ ## 0.37.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`dfd9d9e`](https://github.com/briancray/abide/commit/dfd9d9e9149c16761c539f7b45358e9a969174d8) - manual scroll restoration + hash-only navigation ([`1b71ef1`](https://github.com/briancray/abide/commit/1b71ef195186c0cee5a36527307f5f981833b546))
8
+
9
+ - [`dfd9d9e`](https://github.com/briancray/abide/commit/dfd9d9e9149c16761c539f7b45358e9a969174d8) - lead every tsv line with a wall-clock column ([`5784d47`](https://github.com/briancray/abide/commit/5784d47ddd2d52696fb9db111092c6f571270086))
10
+
11
+ - [`dfd9d9e`](https://github.com/briancray/abide/commit/dfd9d9e9149c16761c539f7b45358e9a969174d8) - always-`abide-` component wrapper tag ([`711de46`](https://github.com/briancray/abide/commit/711de46f8b5210cbbff5c73cd7aceda410d87f47))
12
+
13
+ - [`dfd9d9e`](https://github.com/briancray/abide/commit/dfd9d9e9149c16761c539f7b45358e9a969174d8) - inspector in-flight requests + prompts surface ([`823392d`](https://github.com/briancray/abide/commit/823392d0e644b3a84661293f86b28ca10d9a9de9))
14
+
15
+ - [`dfd9d9e`](https://github.com/briancray/abide/commit/dfd9d9e9149c16761c539f7b45358e9a969174d8) - inspector client scope/router bridge ([`c25b45e`](https://github.com/briancray/abide/commit/c25b45e778f6f17eeaf0875a4b7ecdd63fccb978))
16
+
3
17
  ## 0.36.0
4
18
 
5
19
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
@@ -0,0 +1,35 @@
1
+ import { inFlightRequests } from './inFlightRequests.ts'
2
+ import type { InspectorInFlightRequest } from './types/InspectorInFlightRequest.ts'
3
+ import type { InspectorInFlightSnapshot } from './types/InspectorInFlightSnapshot.ts'
4
+ import type { RequestStore } from './types/RequestStore.ts'
5
+
6
+ function projectStore(store: RequestStore, now: number): InspectorInFlightRequest {
7
+ return {
8
+ trace: store.trace.traceId,
9
+ method: store.req.method,
10
+ path: `${store.url.pathname}${store.url.search}`,
11
+ /* store.start is Bun.nanoseconds() at scope entry; elapsed is current at the read. */
12
+ elapsedMs: (now - store.start) / 1e6,
13
+ route: store.route,
14
+ params: store.params,
15
+ }
16
+ }
17
+
18
+ /*
19
+ Snapshots the request scopes currently executing their handler. Read at call
20
+ time so it reflects the in-flight set as it stands; returns empty when the
21
+ inspector hasn't installed the tracking Set or the server is idle. Sorted by
22
+ elapsed ascending — newest (least elapsed) first, so a long-running request
23
+ sinks to the bottom as its elapsed grows.
24
+ */
25
+ export function buildInFlightSnapshot(): InspectorInFlightSnapshot {
26
+ const tracked = inFlightRequests.tracked
27
+ if (!tracked) {
28
+ return { requests: [] }
29
+ }
30
+ const now = Bun.nanoseconds()
31
+ const requests = Array.from(tracked, (store) => projectStore(store, now)).sort(
32
+ (left, right) => left.elapsedMs - right.elapsedMs,
33
+ )
34
+ return { requests }
35
+ }
@@ -1,4 +1,5 @@
1
1
  import { jsonSchemaForSchema } from '../../shared/jsonSchemaForSchema.ts'
2
+ import { promptRegistry } from '../prompts/promptRegistry.ts'
2
3
  import { verbRegistry } from '../rpc/verbRegistry.ts'
3
4
  import { socketOperations } from '../sockets/socketOperations.ts'
4
5
  import { socketRegistry } from '../sockets/socketRegistry.ts'
@@ -33,5 +34,12 @@ export function buildInspectorSurface(): InspectorSurface {
33
34
  restUrl: operation.restUrl,
34
35
  })),
35
36
  }))
36
- return { verbs, sockets }
37
+ /* jsonSchema is already JSON Schema (built from the frontmatter `arguments`
38
+ list by the resolver plugin), so it's passed through, not re-projected. */
39
+ const prompts = Array.from(promptRegistry.values()).map((entry) => ({
40
+ name: entry.prompt.name,
41
+ description: entry.prompt.description,
42
+ inputSchema: entry.jsonSchema,
43
+ }))
44
+ return { verbs, sockets, prompts }
37
45
  }
@@ -186,6 +186,13 @@ export async function createServer({
186
186
  // In dev, append the live-reload client to the shell so every rendered
187
187
  // page reconnects to /__abide/dev and reloads after a restart.
188
188
  const devShell = dev ? shell.replace('</body>', `${DEV_RELOAD_CLIENT_SCRIPT}</body>`) : shell
189
+ // When the inspector is enabled, flag the client so startClient installs the
190
+ // BroadcastChannel bridge that streams scope + router state to the inspector
191
+ // page. Independent of dev — the inspector can run on a build server too.
192
+ const inspectedShell =
193
+ process.env.ABIDE_ENABLE_INSPECTOR === 'true'
194
+ ? devShell.replace('</body>', `<script>window.__abideInspect=true</script></body>`)
195
+ : devShell
189
196
  /*
190
197
  Mount base from APP_URL's pathname (e.g. https://foo.com/v2 → /v2). Install
191
198
  the server-side resolver so url() prefixes SSR-generated links, and rewrite
@@ -197,7 +204,9 @@ export async function createServer({
197
204
  setBaseResolver(() => base)
198
205
  // Rebase the shell's rooted `/_app/` entry refs onto the mount base, matching
199
206
  // either quote style so a custom app.html using single quotes still rewrites.
200
- const activeShell = base ? devShell.replace(/(["'])\/_app\//g, `$1${base}/_app/`) : devShell
207
+ const activeShell = base
208
+ ? inspectedShell.replace(/(["'])\/_app\//g, `$1${base}/_app/`)
209
+ : inspectedShell
201
210
  /*
202
211
  Boot-path disk scans run concurrently — they share no data, and under
203
212
  `abide dev` the worker-swap window is bounded by exactly this boot.
@@ -0,0 +1,13 @@
1
+ import type { RequestStore } from './types/RequestStore.ts'
2
+
3
+ /*
4
+ The set of request scopes currently executing their handler, for the inspector's
5
+ in-flight view. `tracked` stays undefined until the inspector mounts and swaps in
6
+ a live Set — so the request path adds nothing (no allocation, no membership
7
+ churn) when the inspector is off. runWithRequestScope adds a store on scope entry
8
+ and removes it when the handler settles; buildInFlightSnapshot reads it. Mirrors
9
+ the on-demand, opt-in shape of the log/socket tap slots.
10
+ */
11
+ export const inFlightRequests: { tracked: Set<RequestStore> | undefined } = {
12
+ tracked: undefined,
13
+ }
@@ -4,7 +4,9 @@ import { requestScopeSlot } from '../../shared/requestScopeSlot.ts'
4
4
  import { socketTapSlot } from '../../shared/socketTapSlot.ts'
5
5
  import type { LogRecord } from '../../shared/types/LogRecord.ts'
6
6
  import { buildCacheSnapshot } from './buildCacheSnapshot.ts'
7
+ import { buildInFlightSnapshot } from './buildInFlightSnapshot.ts'
7
8
  import { buildInspectorSurface } from './buildInspectorSurface.ts'
9
+ import { inFlightRequests } from './inFlightRequests.ts'
8
10
  import { ensureRegistriesLoaded } from './registryManifests.ts'
9
11
  import type { InspectorContext } from './types/InspectorContext.ts'
10
12
 
@@ -63,6 +65,10 @@ export async function maybeMountInspector(app: {
63
65
  )
64
66
  return undefined
65
67
  }
68
+ /* Arm in-flight tracking now the inspector is mounting: runWithRequestScope
69
+ fills this Set per request, kept process-wide (the events tap teardown is
70
+ per-connection; the Set outlives any single feed). */
71
+ inFlightRequests.tracked = new Set()
66
72
  const context: InspectorContext = {
67
73
  app,
68
74
  loadSurface: async () => {
@@ -70,6 +76,7 @@ export async function maybeMountInspector(app: {
70
76
  return buildInspectorSurface()
71
77
  },
72
78
  cacheSnapshot: buildCacheSnapshot,
79
+ inFlightSnapshot: buildInFlightSnapshot,
73
80
  /*
74
81
  One process-wide tap each for the log and socket chokepoints; the
75
82
  inspector owns both and fans out to its readers. Socket frames arrive
@@ -5,6 +5,7 @@ import { formatTraceparent } from '../../shared/formatTraceparent.ts'
5
5
  import { isStreamingResponse } from '../../shared/isStreamingResponse.ts'
6
6
  import { logClosingRecord } from '../../shared/logClosingRecord.ts'
7
7
  import type { AppModule } from '../AppModule.ts'
8
+ import { inFlightRequests } from './inFlightRequests.ts'
8
9
  import { internalErrorResponse } from './internalErrorResponse.ts'
9
10
  import { requestContext } from './requestContext.ts'
10
11
  import type { RequestStore } from './types/RequestStore.ts'
@@ -34,6 +35,10 @@ export function runWithRequestScope(
34
35
  start: Bun.nanoseconds(),
35
36
  }
36
37
  return requestContext.run(store, async () => {
38
+ /* Register the live handler for the inspector's in-flight view; the Set is
39
+ absent (a no-op) unless the inspector mounted. Removed once the handler
40
+ settles — its compute is done even if a streamed body keeps piping. */
41
+ inFlightRequests.tracked?.add(store)
37
42
  let response: Response
38
43
  try {
39
44
  response = await body(store)
@@ -44,6 +49,8 @@ export function runWithRequestScope(
44
49
  abideLog.error(error)
45
50
  response = internalErrorResponse(error)
46
51
  }
52
+ } finally {
53
+ inFlightRequests.tracked?.delete(store)
47
54
  }
48
55
  /*
49
56
  Flush any cookies the handler set onto the outgoing response. Only when
@@ -1,5 +1,6 @@
1
1
  import type { LogRecord } from '../../../shared/types/LogRecord.ts'
2
2
  import type { InspectorCacheSnapshot } from './InspectorCacheSnapshot.ts'
3
+ import type { InspectorInFlightSnapshot } from './InspectorInFlightSnapshot.ts'
3
4
  import type { InspectorSurface } from './InspectorSurface.ts'
4
5
 
5
6
  /*
@@ -19,6 +20,9 @@ export type InspectorContext = {
19
20
  /* Snapshots the persistent (global) cache store — current entries with their
20
21
  lifecycle state, retention, tags, and a value preview. */
21
22
  cacheSnapshot: () => InspectorCacheSnapshot
23
+ /* Snapshots the requests whose handler is executing right now — the live
24
+ counterpart to the closing records onRecord emits once a request settles. */
25
+ inFlightSnapshot: () => InspectorInFlightSnapshot
22
26
  /*
23
27
  Subscribes to the unified event stream: every emitted log record (the log's
24
28
  structured form, trace context and all) plus published socket frames shaped
@@ -0,0 +1,20 @@
1
+ /*
2
+ One in-flight request projected for the inspector — the serializable facts the
3
+ In-flight tab renders for a handler that's currently executing. The held Request
4
+ and CacheStore aren't included; what an operator wants is which request it is,
5
+ how long it's been running, and where it landed in routing.
6
+ */
7
+ export type InspectorInFlightRequest = {
8
+ /* The W3C trace id, so the row links to the same trace the Logs/Traces tabs group by. */
9
+ trace: string
10
+ /* HTTP method of the inbound request. */
11
+ method: string
12
+ /* Request path with query string (app-space, as logged). */
13
+ path: string
14
+ /* Ms the handler has been running, measured from scope entry to snapshot time. */
15
+ elapsedMs: number
16
+ /* The matched page route pattern, once routing has landed; undefined on rpc/socket requests or before a match. */
17
+ route: string | undefined
18
+ /* The matched route's decoded params, when a page route landed. */
19
+ params: Record<string, string> | undefined
20
+ }
@@ -0,0 +1,10 @@
1
+ import type { InspectorInFlightRequest } from './InspectorInFlightRequest.ts'
2
+
3
+ /*
4
+ The requests executing their handler at snapshot time — a point-in-time read of
5
+ the in-flight set, the live counterpart to the closing records the Logs tab
6
+ shows once a request settles. Empty when the server is idle.
7
+ */
8
+ export type InspectorInFlightSnapshot = {
9
+ requests: InspectorInFlightRequest[]
10
+ }
@@ -0,0 +1,15 @@
1
+ /*
2
+ One declared MCP prompt projected for the inspector: the serializable facts the
3
+ Surface tab renders. The render closure isn't included — what an operator wants
4
+ is the prompt's name, what it's for, and the arguments it interpolates, the same
5
+ trio `prompts/list` advertises. Prompts are MCP-only, so there are no client
6
+ surface flags to show.
7
+ */
8
+ export type InspectorPrompt = {
9
+ /* The prompt's name (stamped from its file path under src/mcp/prompts/). */
10
+ name: string
11
+ /* The frontmatter description, when the prompt declared one. */
12
+ description: string | undefined
13
+ /* The argument shape as JSON Schema (from the frontmatter `arguments` list); undefined when it takes none. */
14
+ inputSchema: Record<string, unknown> | undefined
15
+ }
@@ -1,13 +1,16 @@
1
+ import type { InspectorPrompt } from './InspectorPrompt.ts'
1
2
  import type { InspectorSocket } from './InspectorSocket.ts'
2
3
  import type { InspectorVerb } from './InspectorVerb.ts'
3
4
 
4
5
  /*
5
6
  The app's machine surface projected for the inspector — the static catalog the
6
- UI renders. Built by reading the verb and socket registries after they're
7
- eager-loaded, so a freshly-booted server lists its whole surface, not just the
8
- verbs hit so far. Pages stay out: they're the human surface, already navigable.
7
+ UI renders. Built by reading the verb, socket, and prompt registries after
8
+ they're eager-loaded, so a freshly-booted server lists its whole surface, not
9
+ just the verbs hit so far. Pages stay out: they're the human surface, already
10
+ navigable.
9
11
  */
10
12
  export type InspectorSurface = {
11
13
  verbs: InspectorVerb[]
12
14
  sockets: InspectorSocket[]
15
+ prompts: InspectorPrompt[]
13
16
  }
@@ -66,6 +66,15 @@ function formatElapsed(ms: number): string {
66
66
  return `+${ms.toFixed(2)}ms`
67
67
  }
68
68
 
69
+ /* `14:23:01.072` — local wall-clock to the ms. Every record carries `ts`, so the
70
+ tsv line always leads with one; `+elapsedMs` still trails as the request-relative
71
+ timing — the two are different axes (when it happened vs how long it took). */
72
+ function formatClock(ts: number): string {
73
+ const date = new Date(ts)
74
+ const pad = (value: number, width = 2): string => String(value).padStart(width, '0')
75
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`
76
+ }
77
+
69
78
  /*
70
79
  Builds the record from the emission's own fields plus the ambient request
71
80
  scope (trace, elapsed, verb+path), then renders it through the active
@@ -123,11 +132,12 @@ function consoleFor(level: LogRecord['level']): (...args: unknown[]) => void {
123
132
 
124
133
  /*
125
134
  The unified tsv line (the default format): tab-separated
126
- `<trace8> <verb path> [channel] <message> +0.00ms`. Inside a
127
- request scope the trace column leads and the elapsed-at-emission timing
128
- trails; a closing record emitted outside one (asset hits sidestep the scope)
129
- pads a blank trace column and trails its serve duration instead, so request
130
- lines stay aligned whatever produced them. Every record speaks on a channel
135
+ `<clock> <trace8> <verb path> [channel] <message> +0.00ms`. The
136
+ wall-clock leads every line (each record carries `ts`); inside a request scope
137
+ the trace column follows and the elapsed-at-emission timing trails; a closing
138
+ record emitted outside one (asset hits sidestep the scope) pads a blank trace
139
+ column and trails its serve duration instead, so request lines stay aligned
140
+ whatever produced them. Every record speaks on a channel
131
141
  (the app name, 'abide', or a diagnostic channel), shown as a dim `[name]`
132
142
  tag opening the message field. The verb+path pair is one field — it's the
133
143
  line's anchor unit — and the tag folds into the message field so field
@@ -136,6 +146,9 @@ positions stay stable for cut/awk consumers.
136
146
  function printTsv(record: LogRecord, voice?: LogVoice): void {
137
147
  const fields: string[] = []
138
148
  const closing = record.status !== undefined
149
+ /* Wall-clock leads every line — a stable first column, unlike the conditional
150
+ trace field, and the axis `+elapsedMs` can't give (when, not how-long). */
151
+ fields.push(dim(formatClock(record.ts)))
139
152
  if (record.trace) {
140
153
  fields.push(dim(record.trace.slice(0, 8)))
141
154
  } else if (closing) {
@@ -1,23 +1,13 @@
1
- import { HTML_TAGS } from './HTML_TAGS.ts'
2
-
3
1
  /*
4
- The element tag a component instance mounts into. Normally the component name
5
- lowercased readable in devtools, a real box like any abide wrapper. But a name
6
- that lowercases to a real HTML element (`Button`→`button`, `Input`→`input`) yields a
7
- wrapper with a content model the parser enforces: void elements self-close, and
8
- `<button>`/`<a>`/table/list/select families reject or foster the component's own
9
- markup as the wrapper's siblings so on hydration the skeleton locates the wrapper
10
- empty, claims `null`, and `attr` throws on it. Those names map to a hyphenated
11
- custom-element tag (a custom element is never void and has no content model) made
12
- layout-transparent with `display:contents`, so the component's real root still lays
13
- out as a direct child of the parent the way the (parse-broken) wrapper would have.
14
- A name that is NOT a known HTML element (the common case — `Card`, `Dropdown`) is an
15
- inert unknown tag that holds any content untouched, so it stays as-is. Both back-ends
16
- call this so the SSR string and the client build agree on the wrapper.
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.
17
10
  */
18
- export function componentWrapperTag(name: string): { tag: string; transparent: boolean } {
19
- const lower = name.toLowerCase()
20
- return HTML_TAGS.has(lower)
21
- ? { tag: `abide-${lower}`, transparent: true }
22
- : { tag: lower, transparent: false }
11
+ export function componentWrapperTag(name: string): string {
12
+ return `abide-${name.toLowerCase()}`
23
13
  }
@@ -4,9 +4,9 @@ import { bindListenEvent } from './bindListenEvent.ts'
4
4
  import { componentWrapperTag } from './componentWrapperTag.ts'
5
5
  import { groupBindParts } from './groupBindParts.ts'
6
6
  import { isControlFlow } from './isControlFlow.ts'
7
- import { isTextLeaf } from './isTextLeaf.ts'
8
7
  import { lowerContext } from './lowerContext.ts'
9
8
  import { scopeAttr } from './scopeAttr.ts'
9
+ import { skeletonContext } from './skeletonContext.ts'
10
10
  import { staticAttr } from './staticAttr.ts'
11
11
  import { staticTextPart } from './staticTextPart.ts'
12
12
  import type { TemplateNode } from './types/TemplateNode.ts'
@@ -32,6 +32,13 @@ export function generateBuild(
32
32
  let counter = 0
33
33
  const nextVar = (prefix: string): string => `${prefix}${counter++}`
34
34
 
35
+ /* Per-node skeleton position from the SAME pass the SSR back-end reads — so the client's
36
+ anchor/text-leaf decisions consult one source of truth instead of re-deriving the
37
+ position structurally (the drift the shared context exists to prevent). `asOutlet`
38
+ only rewrites layout `<slot>`s and preserves text-node identity, so the original
39
+ `nodes` key the same text markers the build traversal looks up. */
40
+ const { markText } = skeletonContext(nodes)
41
+
35
42
  /* The shared signal→`model` lowering + branch-scoped nested-script deref scope. */
36
43
  const {
37
44
  expression: lowerExpression,
@@ -135,13 +142,12 @@ export function generateBuild(
135
142
  }
136
143
  if (node.kind === 'component') {
137
144
  /* The wrapper element is a positioned hole in the skeleton; the child mounts
138
- into the located node (idempotent display:contents for a transparent wrap,
139
- static so it lives in the markup). */
140
- const { tag, transparent } = componentWrapperTag(node.name)
145
+ into the located node. display:contents (idempotent, static so it lives in
146
+ the markup) keeps the wrapper out of layout — a pure mount host. */
147
+ const tag = componentWrapperTag(node.name)
141
148
  const { code } = mountComponent(node, `${skVar}.el[${counter.el++}]`)
142
149
  binds.push(code)
143
- const style = transparent ? ' style="display:contents"' : ''
144
- return `<${tag} ${HOLE_ATTRIBUTE}${style}></${tag}>`
150
+ return `<${tag} ${HOLE_ATTRIBUTE} style="display:contents"></${tag}>`
145
151
  }
146
152
  if (node.kind === 'script') {
147
153
  /* A nested `<script>` (scoped reactive block) emits no markup — its lowered body
@@ -172,13 +178,16 @@ export function generateBuild(
172
178
  return '<!--a-->'
173
179
  }
174
180
  const hasReactiveAttr = node.attrs.some((attr) => attr.kind !== 'static')
175
- const hasReactiveText = node.children.some(
181
+ const reactiveTextChild = node.children.find(
176
182
  (child) => child.kind === 'text' && child.parts.some((part) => part.kind !== 'static'),
177
183
  )
178
184
  /* A text-leaf (only text/style children) with reactive text binds marker-free via
179
185
  `generateChildren` on the located element; otherwise reactive text is interleaved
180
- and uses `<!--a-->` anchors during the child recursion below. */
181
- const textLeafBind = hasReactiveText && isTextLeaf(node)
186
+ and uses `<!--a-->` anchors during the child recursion below. The shared context
187
+ records the leaf's text as NOT interleaved (`markText` false) — read that flag the
188
+ SSR back-end also reads, rather than re-deriving leaf-ness via `isTextLeaf` here. */
189
+ const textLeafBind =
190
+ reactiveTextChild !== undefined && markText.get(reactiveTextChild) === false
182
191
  let openTag = `<${node.tag}`
183
192
  let elVar = ''
184
193
  if (hasReactiveAttr || textLeafBind) {
@@ -184,7 +184,7 @@ export function generateSSR(
184
184
  the same wrapper the client mounts into, so SSR and client agree.
185
185
  Props pass as thunks; slot content passes as a string-returning
186
186
  `$children` the child invokes from its <slot>. */
187
- const { tag, transparent } = componentWrapperTag(node.name)
187
+ const tag = componentWrapperTag(node.name)
188
188
  const parts = node.props.map(
189
189
  (prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
190
190
  )
@@ -206,7 +206,7 @@ export function generateSSR(
206
206
  the enclosing render body, including from branch closures.) */
207
207
  const result = nextVar('$child')
208
208
  return (
209
- push(target, `<${tag}${transparent ? ' style="display:contents"' : ''}>`) +
209
+ push(target, `<${tag} style="display:contents">`) +
210
210
  `const ${result} = ${node.name}.render({ ${parts.join(', ')} });\n` +
211
211
  `${target}.push(${result}.html);\n` +
212
212
  `for (const $a of ${result}.awaits) { $awaits.push($a); }\n` +
@@ -3,6 +3,7 @@ import { history } from './history.ts'
3
3
  import { linked } from './linked.ts'
4
4
  import { persist as persistDoc } from './persist.ts'
5
5
  import { createDoc } from './runtime/createDoc.ts'
6
+ import { liveScopes } from './runtime/liveScopes.ts'
6
7
  import type { Doc } from './runtime/types/Doc.ts'
7
8
  import { state } from './state.ts'
8
9
  import { sync } from './sync.ts'
@@ -28,6 +29,7 @@ export function createScope(
28
29
  initial: unknown = {},
29
30
  parent: Scope | undefined = undefined,
30
31
  awaiting = false,
32
+ label: string | undefined = undefined,
31
33
  ): Scope {
32
34
  /* Eager unless awaiting adoption; `data()` lazily mints an empty doc if a body
33
35
  never created one (a stateless component that still reaches for its scope). */
@@ -41,6 +43,7 @@ export function createScope(
41
43
 
42
44
  const self: Scope = {
43
45
  id,
46
+ label,
44
47
  parent,
45
48
  read: (path) => data().read(path),
46
49
  replace: (path, value) => data().replace(path, value),
@@ -85,7 +88,15 @@ export function createScope(
85
88
  persistence = undefined
86
89
  unsync?.()
87
90
  unsync = undefined
91
+ if (liveScopes.enabled) {
92
+ liveScopes.scopes.delete(self)
93
+ }
88
94
  },
89
95
  }
96
+ /* Dev-only: register for the inspector's scope-tree view. Gated, so production
97
+ never touches the set. */
98
+ if (liveScopes.enabled) {
99
+ liveScopes.scopes.add(self)
100
+ }
90
101
  return self
91
102
  }
@@ -4,6 +4,7 @@ import { enterRenderPass } from '../runtime/enterRenderPass.ts'
4
4
  import { exitRenderPass } from '../runtime/exitRenderPass.ts'
5
5
  import { RENDER } from '../runtime/RENDER.ts'
6
6
  import { scope } from '../runtime/scope.ts'
7
+ import { scopeLabel } from './scopeLabel.ts'
7
8
 
8
9
  /*
9
10
  Adopts existing server-rendered DOM instead of rebuilding it. Runs `build(host)`
@@ -28,7 +29,7 @@ export function hydrate(host: Element, build: (host: Element) => void): () => vo
28
29
  /* Same lexical scope establishment as `mount` — a hydrated component owns a scope
29
30
  too, adopting the model its build adopts. */
30
31
  const parentScope = CURRENT_SCOPE.current
31
- const lexical = createScope({}, parentScope, true)
32
+ const lexical = createScope({}, parentScope, true, scopeLabel(host))
32
33
  CURRENT_SCOPE.current = lexical
33
34
  try {
34
35
  const stop = scope(() => {
@@ -3,6 +3,7 @@ import { CURRENT_SCOPE } from '../runtime/CURRENT_SCOPE.ts'
3
3
  import { enterRenderPass } from '../runtime/enterRenderPass.ts'
4
4
  import { exitRenderPass } from '../runtime/exitRenderPass.ts'
5
5
  import { scope } from '../runtime/scope.ts'
6
+ import { scopeLabel } from './scopeLabel.ts'
6
7
 
7
8
  /*
8
9
  Mounts a component into `host`: runs `build(host)` under an ownership scope so
@@ -22,7 +23,7 @@ export function mount(host: Element, build: (host: Element) => void): () => void
22
23
  `scope()` and its capabilities resolve to it during the build; the previous
23
24
  scope is restored after (synchronous build, so the restore is exact). */
24
25
  const parentScope = CURRENT_SCOPE.current
25
- const lexical = createScope({}, parentScope, true)
26
+ const lexical = createScope({}, parentScope, true, scopeLabel(host))
26
27
  CURRENT_SCOPE.current = lexical
27
28
  const stop = scope(() => {
28
29
  try {
@@ -0,0 +1,15 @@
1
+ /*
2
+ The human-readable name for the scope a mount/hydrate establishes, derived from
3
+ its host element's tag. A nested component mounts into its `abide-<name>` wrapper
4
+ (see `componentWrapperTag`), so stripping the `abide-` prefix recovers the
5
+ component name; any other host (a page/layout outlet) yields its lowercased tag.
6
+ Dev-only — feeds the inspector's Reactive tab so a scope reads `<Counter>` rather
7
+ than an opaque counter id. Returns undefined when there's no element to name from.
8
+ */
9
+ export function scopeLabel(host: Element): string | undefined {
10
+ const tag = host.tagName?.toLowerCase()
11
+ if (tag === undefined) {
12
+ return undefined
13
+ }
14
+ return tag.startsWith('abide-') ? tag.slice('abide-'.length) : tag
15
+ }
@@ -26,6 +26,16 @@ function isElement(node: Node): node is Element {
26
26
  return typeof (node as Element).hasAttribute === 'function'
27
27
  }
28
28
 
29
+ /* A child component's mount wrapper (`abide-<name>`, see `componentWrapperTag`). Its
30
+ content is a SEPARATE skeleton (the child's own), so the parent's walks must treat it
31
+ as opaque: in the shallow skeleton it's an empty leaf, so the compiler counts no
32
+ anchors inside it; on hydrate it's populated, so a descent would over-collect the
33
+ child's anchors and shift every parent index past it (same hazard as a block range,
34
+ but bounded by the wrapper element instead of `[`…`]` markers). */
35
+ function isComponentWrapper(node: Node): boolean {
36
+ return isElement(node) && (node.tagName ?? '').toLowerCase().startsWith('abide-')
37
+ }
38
+
29
39
  /* A comment node's data, or undefined for elements/text. A comment is a node that is
30
40
  neither an element (`hasAttribute`) nor a text node (`splitText`); the mini-dom
31
41
  exposes no `nodeType`, so detect by method. */
@@ -113,7 +123,9 @@ function scanAnchors(nodes: ArrayLike<Node>, anchors: Node[]): void {
113
123
  const node = nodes[index] as Node
114
124
  const data = commentData(node)
115
125
  if (data === undefined) {
116
- if (isElement(node) && depth === 0) {
126
+ /* Recurse into this skeleton's own elements, but NOT a child component's
127
+ wrapper — its anchors belong to the child's skeleton (see above). */
128
+ if (isElement(node) && depth === 0 && !isComponentWrapper(node)) {
117
129
  scanAnchors(node.childNodes, anchors)
118
130
  }
119
131
  } else if (isCloseMarker(data)) {
@@ -0,0 +1,138 @@
1
+ import { effect } from './effect.ts'
2
+ import { clientPage } from './runtime/clientPage.ts'
3
+ import { historyEntries } from './runtime/historyEntries.ts'
4
+ import { liveScopes } from './runtime/liveScopes.ts'
5
+ import { PATCH_BUS } from './runtime/PATCH_BUS.ts'
6
+ import { runtimePath } from './runtime/runtimePath.ts'
7
+
8
+ /* Same-origin cross-tab channel the inspector page listens on. The app tab
9
+ publishes scope + router state; the inspector subscribes. Web-standard, so no
10
+ server route or buffer is involved — core publishes, the package consumes. */
11
+ const CHANNEL = 'abide:inspector'
12
+
13
+ /* Coalesce a burst of patches into one snapshot — the post is the cost, the read is cheap. */
14
+ const SNAPSHOT_DEBOUNCE_MS = 60
15
+
16
+ /* JSON round-trip to strip anything structured-clone can't carry (functions,
17
+ class instances, DOM nodes) before postMessage, degrading to undefined rather
18
+ than throwing into the channel. */
19
+ function cloneable(value: unknown): unknown {
20
+ try {
21
+ return JSON.parse(JSON.stringify(value))
22
+ } catch {
23
+ return undefined
24
+ }
25
+ }
26
+
27
+ /* The live scope forest as a flat list — the inspector rebuilds the tree from
28
+ each node's `parent` id (the Scope surface exposes no children accessor). */
29
+ function scopeNodes(): Array<{
30
+ id: string
31
+ label: string | undefined
32
+ parent: string | undefined
33
+ state: unknown
34
+ recorded: boolean
35
+ }> {
36
+ return Array.from(liveScopes.scopes, (scope) => ({
37
+ id: scope.id,
38
+ label: scope.label,
39
+ parent: scope.parent?.id,
40
+ state: cloneable(scope.snapshot()),
41
+ recorded: scope.canUndo() || scope.canRedo(),
42
+ }))
43
+ }
44
+
45
+ /* Router state off the existing reactive holders — read, not subscribed, here;
46
+ the nav effect drives re-reads. */
47
+ function routerState() {
48
+ const page = clientPage.value
49
+ return {
50
+ path: runtimePath.value,
51
+ route: page.route,
52
+ params: page.params,
53
+ navigating: page.navigating,
54
+ url: page.url.href,
55
+ entry: historyEntries.current,
56
+ }
57
+ }
58
+
59
+ /*
60
+ Installs the client→inspector bridge: a BroadcastChannel the inspector page reads
61
+ to render its Reactive + Router tabs. Gated by `__abideInspect` (server-injected
62
+ only when the inspector is enabled) and called from startClient before the router
63
+ builds any scope — so registration is armed before the first scope exists. A
64
+ no-op where BroadcastChannel is absent (SSR, old runtimes). Mirrors
65
+ installHotBridge: dev instrumentation, not public surface.
66
+ */
67
+ export function installInspectorBridge(): void {
68
+ /* No BroadcastChannel means no consumer for the scope registry — bail before
69
+ arming `liveScopes.enabled`, so createScope never pays scope tracking for a
70
+ set nothing reads. */
71
+ if (typeof BroadcastChannel === 'undefined') {
72
+ return
73
+ }
74
+ liveScopes.enabled = true
75
+ const channel = new BroadcastChannel(CHANNEL)
76
+ const tab = typeof crypto !== 'undefined' ? crypto.randomUUID() : String(performance.now())
77
+ const post = (message: object) => {
78
+ try {
79
+ channel.postMessage({ tab, ...message })
80
+ } catch {
81
+ /* A non-cloneable slipped through despite the JSON pass — drop the frame
82
+ rather than throw into a patch/nav handler. */
83
+ }
84
+ }
85
+
86
+ const announce = () =>
87
+ post({
88
+ kind: 'announce',
89
+ url: typeof location !== 'undefined' ? location.href : '',
90
+ app: typeof document !== 'undefined' ? document.title : '',
91
+ })
92
+ const sendSnapshot = () =>
93
+ post({ kind: 'snapshot', scopes: scopeNodes(), router: routerState() })
94
+
95
+ let snapshotTimer: ReturnType<typeof setTimeout> | undefined
96
+ const scheduleSnapshot = () => {
97
+ clearTimeout(snapshotTimer)
98
+ snapshotTimer = setTimeout(sendSnapshot, SNAPSHOT_DEBOUNCE_MS)
99
+ }
100
+
101
+ /* Inspector handshakes: `hello` (it just opened — everyone re-announce + push)
102
+ and `request` (it wants a fresh snapshot of this tab). */
103
+ channel.onmessage = (event) => {
104
+ const message = event.data as { kind?: string; tab?: string }
105
+ if (message.kind === 'hello') {
106
+ announce()
107
+ sendSnapshot()
108
+ } else if (message.kind === 'request' && message.tab === tab) {
109
+ sendSnapshot()
110
+ }
111
+ }
112
+
113
+ /* Every doc mutation flows through PATCH_BUS; forward the patch as a live
114
+ mutation event and re-snapshot (debounced) so values stay current. */
115
+ PATCH_BUS.subscribe((patchEvent) => {
116
+ post({ kind: 'patch', op: patchEvent.patch.op, path: patchEvent.patch.path })
117
+ scheduleSnapshot()
118
+ })
119
+
120
+ /* Reading the page + path States tracks them, so this re-runs on navigation. */
121
+ effect(() => {
122
+ clientPage.value
123
+ runtimePath.value
124
+ post({ kind: 'nav', router: routerState() })
125
+ })
126
+
127
+ if (typeof addEventListener === 'function') {
128
+ addEventListener('pagehide', () => {
129
+ post({ kind: 'bye' })
130
+ channel.close()
131
+ })
132
+ }
133
+
134
+ announce()
135
+ /* First snapshot once the router has built the initial scope tree (this runs
136
+ before router()). */
137
+ setTimeout(sendSnapshot, 0)
138
+ }
@@ -1,16 +1,24 @@
1
+ import { historyEntries } from './runtime/historyEntries.ts'
1
2
  import { runtimePath } from './runtime/runtimePath.ts'
2
3
 
3
4
  /* Navigates to `path`: writes a history entry (when available) and updates the
4
5
  reactive route, which re-mounts the matching page via `router`. `replace` swaps
5
6
  the current entry instead of pushing — used when honouring a server redirect, so
6
- the blocked URL isn't left behind in history. */
7
+ the blocked URL isn't left behind in history. Each entry carries a monotonic
8
+ `abideEntry` id so the router can bucket/restore its scroll offset across the page
9
+ teardown the rebuild does. A push leaves the current entry behind — its scroll is
10
+ bucketed so back restores it — and mints a fresh id. A replace destroys the current
11
+ entry and lands fresh content (a redirect), so its saved scroll no longer applies:
12
+ the bucket is discarded and the id kept, so the new page restores to top/anchor. */
7
13
  // @documentation navigate
8
14
  export function navigate(path: string, replace = false): void {
9
15
  if (typeof history !== 'undefined') {
10
16
  if (replace) {
11
- history.replaceState({}, '', path)
17
+ historyEntries.discard()
18
+ history.replaceState({ abideEntry: historyEntries.current }, '', path)
12
19
  } else {
13
- history.pushState({}, '', path)
20
+ historyEntries.save()
21
+ history.pushState({ abideEntry: historyEntries.next() }, '', path)
14
22
  }
15
23
  }
16
24
  runtimePath.value = path
@@ -6,7 +6,9 @@ import { clientPage } from './runtime/clientPage.ts'
6
6
  import { enterRenderPass } from './runtime/enterRenderPass.ts'
7
7
  import { exitRenderPass } from './runtime/exitRenderPass.ts'
8
8
  import { firstOutlet } from './runtime/firstOutlet.ts'
9
+ import { historyEntries } from './runtime/historyEntries.ts'
9
10
  import { runtimePath } from './runtime/runtimePath.ts'
11
+ import type { AbideHistoryState } from './runtime/types/AbideHistoryState.ts'
10
12
  import type { NavVerdict } from './runtime/types/NavVerdict.ts'
11
13
  import type { Route } from './runtime/types/Route.ts'
12
14
  import type { RouteLoader } from './runtime/types/RouteLoader.ts'
@@ -20,6 +22,15 @@ type MountedLayout = { key: string; dispose: () => void; outlet: Element }
20
22
  /* A layout mount that returns no disposer still needs one for the chain teardown. */
21
23
  const noop = (): void => {}
22
24
 
25
+ /* The destination URL for a navigation `path`. On the server / headless there is no
26
+ `location`, so resolve against a localhost origin; in the browser, against the real
27
+ origin — `location` is already updated to `path` by the time a swap reads it, so this
28
+ matches `new URL(location.href)`. */
29
+ const resolveUrl = (path: string): URL =>
30
+ typeof location === 'undefined'
31
+ ? new URL(`http://localhost${path}`)
32
+ : new URL(path, location.origin)
33
+
23
34
  /*
24
35
  A minimal client router on the History API. `router` matches the current path
25
36
  against the route patterns (literal / `[name]` / `[...rest]`, via matchRoute),
@@ -34,6 +45,11 @@ is disposed and rebuilt, and the page (always the leaf) re-mounts every time. Th
34
45
  reactive `page` proxy means a persisted layout reading route/params updates in place
35
46
  without a remount. Each chunk loads only on first visit, cached after.
36
47
 
48
+ Scroll restoration is manual (`historyEntries`): because the page is rebuilt after the
49
+ browser would restore scroll, each history entry's offset is bucketed by an `abideEntry`
50
+ id and reapplied once the destination DOM exists — back/forward returns to its offset, a
51
+ fresh navigation scrolls to the top.
52
+
37
53
  `probe` (when given) runs each post-boot navigation's destination through the
38
54
  server's app.handle first, so auth/redirect gating applies to client navigation
39
55
  just as it does to a fresh load; its verdict either clears the mount, soft-redirects
@@ -134,9 +150,22 @@ export function router(
134
150
  run()
135
151
  }
136
152
 
153
+ const entryOf = (): number =>
154
+ (history.state as AbideHistoryState | null)?.abideEntry ?? historyEntries.current
137
155
  const onPopState = (): void => {
156
+ /* Bucket the leaving entry's scroll (current still its id) before adopting the
157
+ one back/forward landed on; `swap` restores the adopted entry's offset once
158
+ its DOM is rebuilt. */
159
+ historyEntries.save()
160
+ historyEntries.adopt(entryOf())
138
161
  runtimePath.value = location.pathname + location.search + location.hash
139
162
  }
163
+ const onPageHide = (): void => {
164
+ /* Mirror the live scroll into the active entry's state before it unloads, so a
165
+ reload can recover it — the in-memory bucket is gone and `manual` keeps the
166
+ browser from restoring. */
167
+ historyEntries.persist()
168
+ }
140
169
  const onClick = (event: MouseEvent): void => {
141
170
  /* Let the browser own anything that isn't a plain primary-button click:
142
171
  modified clicks (open in a new tab/window), middle/right buttons, and
@@ -175,7 +204,22 @@ export function router(
175
204
  navigate(destination.pathname + destination.search + destination.hash)
176
205
  }
177
206
  if (typeof window !== 'undefined') {
207
+ /* Own scroll restoration: the browser would restore against the pre-teardown
208
+ DOM. Adopt the initial entry's id (survives a reload) and stamp it onto the
209
+ landing entry — merging so any `scroll` a prior unload persisted into this
210
+ entry's state stays put for the first-paint `restore` to recover. */
211
+ if ('scrollRestoration' in history) {
212
+ history.scrollRestoration = 'manual'
213
+ }
214
+ historyEntries.adopt(entryOf())
215
+ const landingState = (history.state as AbideHistoryState | null) ?? {}
216
+ history.replaceState(
217
+ { ...landingState, abideEntry: historyEntries.current },
218
+ '',
219
+ location.href,
220
+ )
178
221
  window.addEventListener('popstate', onPopState)
222
+ window.addEventListener('pagehide', onPageHide)
179
223
  document.addEventListener('click', onClick as EventListener)
180
224
  }
181
225
 
@@ -200,6 +244,29 @@ export function router(
200
244
  /* The route matches on the pathname only; the query/hash ride along for
201
245
  the probe (so server gating sees them) and for clientPage.url. */
202
246
  const pathname = path.split(/[?#]/)[0] ?? path
247
+ /* A same-document navigation — only the `#hash` (and thus scroll) differs from
248
+ the mounted page — needs no teardown: the live page stays, page.url
249
+ republishes (so `page.url.hash` updates in place), and we restore the entry's
250
+ scroll bucket or scroll to the anchor. A differing pathname or query still
251
+ rebuilds (a query is page data). Skipped on first paint — nothing is mounted. */
252
+ const targetUrl = resolveUrl(path)
253
+ const mountedUrl = clientPage.value.url
254
+ if (
255
+ !first &&
256
+ mountedUrl.pathname === targetUrl.pathname &&
257
+ mountedUrl.search === targetUrl.search &&
258
+ mountedUrl.hash !== targetUrl.hash
259
+ ) {
260
+ /* Invalidate any in-flight full navigation so its late `.then` can't
261
+ rebuild over the page this hash hop keeps mounted (the token guard
262
+ only bails on a NEWER sequence). That bailed `.then` is also the only
263
+ writer of `navigating: false`, so clear the flag here — a hash hop is
264
+ synchronous and settles immediately. */
265
+ sequence += 1
266
+ clientPage.value = { ...clientPage.value, url: targetUrl, navigating: false }
267
+ historyEntries.restore(targetUrl.hash)
268
+ return
269
+ }
203
270
  const matched = matchRoute(patterns, pathname)
204
271
  const key = matched?.route ?? '*'
205
272
  const params = matched?.params ?? {}
@@ -275,19 +342,19 @@ export function router(
275
342
  while it's still mounted. Disposing first kills that scope; surviving
276
343
  prefix layouts then update in place on publish. */
277
344
  const base = disposeFrom(divergence)
278
- clientPage.value = {
279
- route: chainRoute,
280
- params,
281
- url:
282
- typeof location === 'undefined'
283
- ? new URL(`http://localhost${path}`)
284
- : new URL(location.href),
285
- navigating: false,
286
- }
345
+ const url = resolveUrl(path)
346
+ clientPage.value = { route: chainRoute, params, url, navigating: false }
287
347
  if (!hydrating) {
288
348
  base.textContent = ''
289
349
  }
290
350
  buildFrom(base, divergence, chainKeys, layoutViews, pageView, params, hydrating)
351
+ /* Reapply the destination entry's scroll once its DOM exists — a
352
+ back/forward restores its offset, a fresh nav scrolls to the `#hash`
353
+ anchor (now built) or the top. Runs on the initial paint too: with
354
+ `scrollRestoration='manual'` the browser does NOT restore a reload's
355
+ offset, so first paint recovers it from the persisted `history.state`
356
+ (a fresh load with no persisted offset falls through to hash/top). */
357
+ historyEntries.restore(url.hash)
291
358
  }
292
359
  /* Wrap the swap in a View Transition where the browser supports it, so
293
360
  the page change cross-fades (and shared `view-transition-name` elements
@@ -311,6 +378,7 @@ export function router(
311
378
  disposed = true
312
379
  if (typeof window !== 'undefined') {
313
380
  window.removeEventListener('popstate', onPopState)
381
+ window.removeEventListener('pagehide', onPageHide)
314
382
  document.removeEventListener('click', onClick as EventListener)
315
383
  }
316
384
  stop()
@@ -0,0 +1,113 @@
1
+ import type { AbideHistoryState } from './types/AbideHistoryState.ts'
2
+
3
+ /*
4
+ Per-history-entry scroll buckets for manual scroll restoration. The browser's own
5
+ restoration is disabled (`history.scrollRestoration = 'manual'` at router boot)
6
+ because the router tears the page down and rebuilds it AFTER the browser would have
7
+ restored scroll — so the offset is lost against a node that no longer exists. Instead
8
+ each history entry carries a monotonic id (stamped into `history.state.abideEntry` by
9
+ `navigate`), and this module buckets that entry's scroll offset: `save()` records the
10
+ outgoing offset before history moves, `restore()` reapplies the destination entry's
11
+ offset after its DOM is rebuilt (or, for an entry seen for the first time — a fresh
12
+ navigation — honours a `#hash` anchor when one resolves, else scrolls to the top). A
13
+ back/forward `adopt`s the entry id read from history.state; a `replace` `discard`s the
14
+ current bucket (its content is superseded). The in-memory `offsets` Map covers the
15
+ same-session back/forward path; `persist()` also mirrors the live scroll into
16
+ `history.state` (on pagehide, when state still reflects the active entry) so a RELOAD
17
+ — where the Map is gone and `scrollRestoration='manual'` keeps the browser from
18
+ restoring — can still recover it via `restore()`'s `persistedOffset` fallback.
19
+
20
+ Scroll APIs are reached off `globalThis` (guarded) so the module is a no-op on the
21
+ server and in headless tests that never install a window. Shared mutable singleton on
22
+ one object, reached without a barrel — mirrors `reactiveAbortState`/`clientPage`.
23
+ */
24
+
25
+ /* The scroll surface: the real `Window` shape, made `Partial` so every member is
26
+ optional — the module degrades to a no-op on the server / in headless tests that
27
+ never install a window. */
28
+ const view = globalThis as unknown as Partial<Window>
29
+
30
+ /* The active entry's offset persisted in `history.state` — survives a reload (the
31
+ in-memory `offsets` Map does not). Honoured only when the stored id matches `current`,
32
+ so a foreign history entry's state never restores the wrong scroll. */
33
+ function persistedOffset(): [number, number] | undefined {
34
+ const state = view.history?.state as AbideHistoryState | null
35
+ if (state?.abideEntry === current && Array.isArray(state.scroll)) {
36
+ return state.scroll
37
+ }
38
+ return undefined
39
+ }
40
+
41
+ /* The element a `#hash` addresses, if the document holds one. */
42
+ function anchorFor(hash: string | undefined): HTMLElement | undefined {
43
+ if (hash === undefined || hash.length <= 1 || view.document === undefined) {
44
+ return undefined
45
+ }
46
+ return view.document.getElementById(hash.slice(1)) ?? undefined
47
+ }
48
+
49
+ /* The active entry's scroll offset is buckets.get(current); `seq` mints fresh ids. */
50
+ const offsets = new Map<number, [number, number]>()
51
+ let current = 0
52
+ let seq = 0
53
+
54
+ export const historyEntries = {
55
+ /* The active history entry's id — stamped into history.state by `navigate`. */
56
+ get current(): number {
57
+ return current
58
+ },
59
+ /* Mint the next entry id for a pushed history entry, making it active. */
60
+ next(): number {
61
+ current = seq += 1
62
+ return current
63
+ },
64
+ /* A back/forward landed on an existing entry — make it active so the following
65
+ save/restore target its bucket. Keeps `seq` ahead of any adopted id. */
66
+ adopt(entry: number): void {
67
+ current = entry
68
+ if (entry > seq) {
69
+ seq = entry
70
+ }
71
+ },
72
+ /* Bucket the active entry's current scroll offset, before history moves away. */
73
+ save(): void {
74
+ if (typeof view.scrollTo === 'function') {
75
+ offsets.set(current, [view.scrollX ?? 0, view.scrollY ?? 0])
76
+ }
77
+ },
78
+ /* Drop the active entry's bucket — a replace lands fresh content, so the saved
79
+ scroll no longer applies. (The replace's own `replaceState` overwrites the
80
+ persisted copy in `history.state`, so the durable fallback clears too.) */
81
+ discard(): void {
82
+ offsets.delete(current)
83
+ },
84
+ /* Mirror the live scroll into the active entry's `history.state` so it survives a
85
+ reload (the in-memory Map does not). Called on pagehide — when `history.state`
86
+ still reflects the active entry — merging to keep its `abideEntry` stamp. */
87
+ persist(): void {
88
+ if (typeof view.scrollTo !== 'function' || view.history === undefined) {
89
+ return
90
+ }
91
+ const state = (view.history.state as AbideHistoryState | null) ?? {}
92
+ view.history.replaceState({ ...state, scroll: [view.scrollX ?? 0, view.scrollY ?? 0] }, '')
93
+ },
94
+ /* Reapply the active entry's scroll once its DOM exists. A back/forward to a saved
95
+ entry returns to its offset (in-memory, else the reload-durable persisted copy);
96
+ a fresh entry honours a `#hash` anchor when one resolves, else scrolls to the top. */
97
+ restore(hash?: string): void {
98
+ if (typeof view.scrollTo !== 'function') {
99
+ return
100
+ }
101
+ const offset = offsets.get(current) ?? persistedOffset()
102
+ if (offset !== undefined) {
103
+ view.scrollTo(offset[0], offset[1])
104
+ return
105
+ }
106
+ const anchor = anchorFor(hash)
107
+ if (anchor !== undefined) {
108
+ anchor.scrollIntoView()
109
+ return
110
+ }
111
+ view.scrollTo(0, 0)
112
+ },
113
+ }
@@ -0,0 +1,15 @@
1
+ import type { Scope } from '../types/Scope.ts'
2
+
3
+ /*
4
+ Dev-only registry of every live scope, for the inspector's Reactive tab. `scopes`
5
+ stays empty and untouched unless installInspectorBridge flips `enabled` (gated by
6
+ the server-injected `__abideInspect`), so production allocates and tracks nothing.
7
+ createScope adds on construction and removes on dispose; the bridge reconstructs
8
+ the scope forest from each entry's `id` + `parent.id` (the Scope surface exposes
9
+ no children accessor, so the flat set + parent links is the traversal path). One
10
+ mutable singleton object — mirrors `reactiveAbortState`, reached without a barrel.
11
+ */
12
+ export const liveScopes: { enabled: boolean; scopes: Set<Scope> } = {
13
+ enabled: false,
14
+ scopes: new Set(),
15
+ }
@@ -0,0 +1,9 @@
1
+ /* The shape abide stamps into `History.state`: a monotonic `abideEntry` id the router
2
+ buckets scroll by, and the last `scroll` offset persisted for that entry (so a reload
3
+ can recover it — the in-memory bucket does not survive). Both optional: a foreign or
4
+ bare entry (one another script pushed, or the first landing before a stamp) carries
5
+ neither, which the readers treat as "no abide data". */
6
+ export type AbideHistoryState = {
7
+ abideEntry?: number
8
+ scroll?: [number, number]
9
+ }
@@ -6,6 +6,7 @@ import { setPageResolver } from '../shared/setPageResolver.ts'
6
6
  import type { CacheSnapshotEntry } from '../shared/types/CacheSnapshotEntry.ts'
7
7
  import type { StreamedResolution } from '../shared/types/StreamedResolution.ts'
8
8
  import { installHotBridge } from './installHotBridge.ts'
9
+ import { installInspectorBridge } from './installInspectorBridge.ts'
9
10
  import { probeNavigation } from './probeNavigation.ts'
10
11
  import { router } from './router.ts'
11
12
  import { clientPage } from './runtime/clientPage.ts'
@@ -42,6 +43,11 @@ export function startClient(
42
43
  if ((globalThis as { __abideDev?: boolean }).__abideDev) {
43
44
  installHotBridge()
44
45
  }
46
+ /* Inspector only: the server injects `__abideInspect` when ABIDE_ENABLE_INSPECTOR
47
+ is on, so the scope/router bridge arms before the router builds any scope. */
48
+ if ((globalThis as { __abideInspect?: boolean }).__abideInspect) {
49
+ installInspectorBridge()
50
+ }
45
51
  const ssr = (globalThis as { __SSR__?: SsrPayload }).__SSR__ ?? {}
46
52
  setBaseResolver(() => ssr.base ?? '')
47
53
  /* The `page` proxy reads route/params/url off the router-updated snapshot. */
@@ -22,6 +22,9 @@ the ONLY public entry; everything else is a method reached through it (the
22
22
  */
23
23
  export type Scope = {
24
24
  readonly id: string
25
+ /* Dev-only display name (the host component/element it mounted into) for the
26
+ inspector's Reactive tab; undefined for SSR/detached/child scopes. */
27
+ readonly label?: string
25
28
  readonly parent: Scope | undefined
26
29
  /* data — mirrors Doc */
27
30
  read: <T>(path: string) => T
@@ -1,132 +0,0 @@
1
- /*
2
- Every standard HTML element name (lowercase), plus the two foreign-content roots
3
- `svg`/`math`. A component wrapper tag that collides with one of these is a real
4
- element with a content model and parser quirks — `<button>`/`<a>` reject interactive
5
- descendants, table/list/select families foster or auto-close non-conforming children,
6
- void elements self-close, and `<svg>`/`<math>` switch the parser into SVG/MathML
7
- namespace so the wrapper's HTML children break out (foster-parent) of it entirely —
8
- so the parser reparents the component's own markup out of the wrapper and hydration
9
- claims `null`. `componentWrapperTag` remaps any such name to a transparent custom
10
- element instead. Pure SVG/MathML descendant names (`circle`, `path`, `mrow`, …) are
11
- NOT here: in HTML context they are inert unknown elements that hold children fine, so
12
- a `<Circle>` component stays as-is like any non-element name. Superset of VOID_TAGS
13
- (which the parser/SSR still use for self-closing); this set is only the
14
- wrapper-safety check.
15
- */
16
- export const HTML_TAGS: ReadonlySet<string> = new Set([
17
- 'a',
18
- 'abbr',
19
- 'address',
20
- 'area',
21
- 'article',
22
- 'aside',
23
- 'audio',
24
- 'b',
25
- 'base',
26
- 'bdi',
27
- 'bdo',
28
- 'blockquote',
29
- 'body',
30
- 'br',
31
- 'button',
32
- 'canvas',
33
- 'caption',
34
- 'cite',
35
- 'code',
36
- 'col',
37
- 'colgroup',
38
- 'data',
39
- 'datalist',
40
- 'dd',
41
- 'del',
42
- 'details',
43
- 'dfn',
44
- 'dialog',
45
- 'div',
46
- 'dl',
47
- 'dt',
48
- 'em',
49
- 'embed',
50
- 'fieldset',
51
- 'figcaption',
52
- 'figure',
53
- 'footer',
54
- 'form',
55
- 'h1',
56
- 'h2',
57
- 'h3',
58
- 'h4',
59
- 'h5',
60
- 'h6',
61
- 'head',
62
- 'header',
63
- 'hgroup',
64
- 'hr',
65
- 'html',
66
- 'i',
67
- 'iframe',
68
- 'img',
69
- 'input',
70
- 'ins',
71
- 'kbd',
72
- 'label',
73
- 'legend',
74
- 'li',
75
- 'link',
76
- 'main',
77
- 'map',
78
- 'mark',
79
- 'math',
80
- 'menu',
81
- 'meta',
82
- 'meter',
83
- 'nav',
84
- 'noscript',
85
- 'object',
86
- 'ol',
87
- 'optgroup',
88
- 'option',
89
- 'output',
90
- 'p',
91
- 'param',
92
- 'picture',
93
- 'pre',
94
- 'progress',
95
- 'q',
96
- 'rp',
97
- 'rt',
98
- 'ruby',
99
- 's',
100
- 'samp',
101
- 'script',
102
- 'search',
103
- 'section',
104
- 'select',
105
- 'slot',
106
- 'small',
107
- 'source',
108
- 'span',
109
- 'strong',
110
- 'style',
111
- 'sub',
112
- 'summary',
113
- 'sup',
114
- 'svg',
115
- 'table',
116
- 'tbody',
117
- 'td',
118
- 'template',
119
- 'textarea',
120
- 'tfoot',
121
- 'th',
122
- 'thead',
123
- 'time',
124
- 'title',
125
- 'tr',
126
- 'track',
127
- 'u',
128
- 'ul',
129
- 'var',
130
- 'video',
131
- 'wbr',
132
- ])