@abide/abide 0.46.0 → 0.48.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.
- package/AGENTS.md +370 -320
- package/CHANGELOG.md +94 -0
- package/README.md +203 -168
- package/package.json +12 -8
- package/src/abideLsp.ts +11 -12
- package/src/abideResolverPlugin.ts +9 -4
- package/src/lib/bundle/disconnected.abide +3 -0
- package/src/lib/cli/printCommandHelp.ts +43 -0
- package/src/lib/cli/printSessionHelp.ts +2 -1
- package/src/lib/cli/{printHelp.ts → printTopLevelHelp.ts} +3 -40
- package/src/lib/cli/runCli.ts +2 -1
- package/src/lib/mcp/buildPrompts.ts +25 -0
- package/src/lib/mcp/dispatchMcpRequest.ts +8 -6
- package/src/lib/mcp/mcpResourceServerSlot.ts +5 -10
- package/src/lib/mcp/mcpSurface.ts +13 -252
- package/src/lib/mcp/mcpTools.ts +187 -0
- package/src/lib/mcp/renderPrompt.ts +25 -0
- package/src/lib/mcp/types/McpSurface.ts +15 -0
- package/src/lib/mcp/types/PromptDescriptor.ts +5 -0
- package/src/lib/mcp/types/PromptMessage.ts +2 -0
- package/src/lib/mcp/types/ToolDescriptor.ts +7 -0
- package/src/lib/mcp/types/ToolResult.ts +2 -0
- package/src/lib/server/agent.ts +1 -1
- package/src/lib/server/jsonl.ts +2 -2
- package/src/lib/server/rpc/defineRpc.ts +5 -0
- package/src/lib/server/runtime/buildCacheSnapshot.ts +6 -6
- package/src/lib/server/runtime/containsTraversal.ts +21 -17
- package/src/lib/server/runtime/createAppAssetServer.ts +11 -16
- package/src/lib/server/runtime/createServer.ts +101 -71
- package/src/lib/server/runtime/createUiPageRenderer.ts +23 -4
- package/src/lib/server/runtime/devClientFingerprint.ts +3 -3
- package/src/lib/server/runtime/devHotModuleResponse.ts +2 -1
- package/src/lib/server/runtime/finalizeResponse.ts +32 -0
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +8 -17
- package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
- package/src/lib/server/runtime/types/InspectorCacheSnapshot.ts +4 -4
- package/src/lib/server/runtime/types/InspectorContext.ts +1 -1
- package/src/lib/server/socket.ts +1 -1
- package/src/lib/server/sockets/createSocketDispatcher.ts +28 -5
- package/src/lib/server/sockets/defineSocket.ts +26 -7
- package/src/lib/server/sockets/types/SocketRegistryEntry.ts +1 -1
- package/src/lib/server/sockets/types/SocketRoutes.ts +1 -1
- package/src/lib/server/sse.ts +2 -2
- package/src/lib/shared/DEFER.ts +8 -0
- package/src/lib/shared/activeCacheStore.ts +2 -2
- package/src/lib/shared/activePage.ts +2 -2
- package/src/lib/shared/attachRpcSelectorMethods.ts +42 -0
- package/src/lib/shared/attachSocketSelectorMethods.ts +34 -0
- package/src/lib/shared/basePath.ts +2 -2
- package/src/lib/shared/baseSlot.ts +6 -5
- package/src/lib/shared/bodyValueForKind.ts +1 -2
- package/src/lib/shared/buildSocketOverChannel.ts +40 -4
- package/src/lib/shared/cache.ts +522 -117
- package/src/lib/shared/cacheEntryFromSnapshot.ts +3 -22
- package/src/lib/shared/cacheStoreSlot.ts +9 -5
- package/src/lib/shared/cacheStores.ts +3 -3
- package/src/lib/{server/runtime → shared}/createReachable.ts +2 -2
- package/src/lib/shared/createRemoteFunction.ts +58 -9
- package/src/lib/shared/createResolverSlot.ts +24 -31
- package/src/lib/shared/createSocketSubRegistry.ts +2 -2
- package/src/lib/shared/decodeRefJson.ts +4 -2
- package/src/lib/shared/decodeResponse.ts +8 -6
- package/src/lib/shared/done.ts +14 -0
- package/src/lib/shared/encodeRefJson.ts +4 -2
- package/src/lib/shared/hydratingSlot.ts +12 -0
- package/src/lib/shared/isLayoutFile.ts +12 -0
- package/src/lib/shared/keyForRemoteCall.ts +2 -1
- package/src/lib/shared/keyPrefixForRemote.ts +12 -0
- package/src/lib/shared/matchRoute.ts +175 -0
- package/src/lib/shared/normalizePathname.ts +11 -0
- package/src/lib/shared/pageSlot.ts +14 -5
- package/src/lib/shared/pageUrlForFile.ts +3 -3
- package/src/lib/shared/parseRouteSegments.ts +16 -7
- package/src/lib/shared/patch.ts +41 -0
- package/src/lib/shared/peek.ts +35 -0
- package/src/lib/shared/prepareRemoteExport.ts +40 -0
- package/src/lib/shared/prepareRpcModule.ts +98 -16
- package/src/lib/shared/prepareSocketModule.ts +11 -15
- package/src/lib/shared/queryStringFromArgs.ts +11 -0
- package/src/lib/shared/reachable.ts +102 -0
- package/src/lib/shared/refresh.ts +22 -0
- package/src/lib/shared/requestScopeSlot.ts +10 -7
- package/src/lib/shared/routeParamsShape.ts +9 -9
- package/src/lib/shared/rpcErrorRegistry.ts +69 -0
- package/src/lib/shared/selectorPrefix.ts +3 -10
- package/src/lib/shared/setOwnProperty.ts +19 -0
- package/src/lib/shared/sharedCacheStore.ts +14 -0
- package/src/lib/shared/sharedCacheStoreSlot.ts +10 -0
- package/src/lib/shared/snippet.ts +1 -1
- package/src/lib/shared/subscribableProbes.ts +111 -0
- package/src/lib/shared/tailProbeSlot.ts +8 -1
- package/src/lib/shared/types/CacheEntry.ts +9 -15
- package/src/lib/shared/types/CacheOptions.ts +23 -11
- package/src/lib/shared/types/CacheSnapshotEntry.ts +0 -5
- package/src/lib/shared/types/CacheStats.ts +1 -1
- package/src/lib/shared/types/RemoteCallable.ts +25 -7
- package/src/lib/shared/types/RemoteFunction.ts +48 -13
- package/src/lib/shared/types/ResolverSlot.ts +7 -4
- package/src/lib/shared/types/RpcError.ts +26 -0
- package/src/lib/shared/types/SmartReadOptions.ts +36 -0
- package/src/lib/shared/types/Socket.ts +54 -0
- package/src/lib/shared/types/SsrBootState.ts +27 -0
- package/src/lib/shared/types/SsrPayload.ts +21 -0
- package/src/lib/shared/types/Subscribable.ts +13 -13
- package/src/lib/shared/types/TailHooks.ts +2 -1
- package/src/lib/shared/url.ts +29 -14
- package/src/lib/shared/wakeHydrationPeeks.ts +20 -0
- package/src/lib/shared/writeTestSocketsDts.ts +1 -1
- package/src/lib/test/createScriptedSurface.ts +1 -1
- package/src/lib/test/createTestApp.ts +8 -8
- package/src/lib/test/createTestSocketChannel.ts +3 -3
- package/src/lib/ui/compile/REACTIVE_CALLEES.ts +5 -6
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +10 -7
- package/src/lib/ui/compile/abideUiPlugin.ts +2 -2
- package/src/lib/ui/compile/analyzeComponent.ts +28 -6
- package/src/lib/ui/compile/compileModule.ts +137 -11
- package/src/lib/ui/compile/compileShadow.ts +101 -84
- package/src/lib/ui/compile/desugarSignals.ts +120 -107
- package/src/lib/ui/compile/generateBuild.ts +48 -16
- package/src/lib/ui/compile/generateSSR.ts +117 -14
- package/src/lib/ui/compile/identifierReferencePattern.ts +13 -0
- package/src/lib/ui/compile/lowerContext.ts +10 -4
- package/src/lib/ui/compile/lowerScript.ts +72 -5
- package/src/lib/ui/compile/parseTemplate.ts +1 -14
- package/src/lib/ui/compile/prepareNestedScript.ts +31 -17
- package/src/lib/ui/compile/resolveReactiveExport.ts +95 -0
- package/src/lib/ui/compile/signalCallee.ts +24 -0
- package/src/lib/ui/compile/stripEffects.ts +14 -11
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
- package/src/lib/ui/compile/types/TemplateNode.ts +0 -5
- package/src/lib/ui/dom/appendStatic.ts +9 -1
- package/src/lib/ui/dom/appendText.ts +15 -3
- package/src/lib/ui/dom/assertClaimedText.ts +20 -0
- package/src/lib/ui/dom/awaitBlock.ts +19 -83
- package/src/lib/ui/dom/bindSelectValue.ts +48 -0
- package/src/lib/ui/dom/each.ts +12 -1
- package/src/lib/ui/dom/hydrate.ts +10 -0
- package/src/lib/ui/dom/mountChild.ts +2 -5
- package/src/lib/ui/dom/mountRange.ts +0 -75
- package/src/lib/ui/effect.ts +4 -3
- package/src/lib/ui/installHotBridge.ts +4 -2
- package/src/lib/ui/remoteProxy.ts +18 -2
- package/src/lib/ui/renderToStream.ts +9 -13
- package/src/lib/ui/resumeSeedScript.ts +2 -2
- package/src/lib/ui/router.ts +21 -2
- package/src/lib/ui/runtime/RESUME.ts +0 -6
- package/src/lib/ui/runtime/ambientScopeBacking.ts +1 -1
- package/src/lib/ui/runtime/types/SsrRender.ts +3 -4
- package/src/lib/ui/scope.ts +6 -3
- package/src/lib/ui/seedBootState.ts +53 -0
- package/src/lib/ui/socketChannel.ts +2 -2
- package/src/lib/ui/socketProxy.ts +9 -3
- package/src/lib/ui/startClient.ts +17 -31
- package/src/lib/ui/state.ts +44 -13
- package/src/lib/ui/sync.ts +11 -5
- package/src/lib/ui/tryEncodeResume.ts +2 -5
- package/src/lib/ui/types/Scope.ts +14 -10
- package/src/lib/ui/watch.ts +140 -0
- package/src/serverEntry.ts +13 -11
- package/template/CLAUDE.md +1 -1
- package/template/package.json +1 -1
- package/template/src/server/rpc/getHello.ts +3 -3
- package/template/src/ui/pages/page.abide +2 -2
- package/template/test/app.test.ts +1 -1
- package/src/lib/server/reachable.ts +0 -45
- package/src/lib/server/sockets/types/Socket.ts +0 -23
- package/src/lib/shared/CACHE_WRAPPED.ts +0 -9
- package/src/lib/shared/DEFER_MIN_ARRAY_LENGTH.ts +0 -14
- package/src/lib/shared/baseResolver.ts +0 -10
- package/src/lib/shared/cacheKeyOf.ts +0 -7
- package/src/lib/shared/cacheKeyStore.ts +0 -8
- package/src/lib/shared/cacheStoreResolver.ts +0 -12
- package/src/lib/shared/globalCacheStore.ts +0 -15
- package/src/lib/shared/globalCacheStoreResolver.ts +0 -12
- package/src/lib/shared/globalCacheStoreSlot.ts +0 -9
- package/src/lib/shared/pageResolver.ts +0 -16
- package/src/lib/shared/recordCacheKey.ts +0 -6
- package/src/lib/shared/requestScopeResolver.ts +0 -12
- package/src/lib/shared/setBaseResolver.ts +0 -4
- package/src/lib/shared/setCacheStoreResolver.ts +0 -4
- package/src/lib/shared/setGlobalCacheStoreResolver.ts +0 -4
- package/src/lib/shared/setPageResolver.ts +0 -4
- package/src/lib/shared/setRequestScopeResolver.ts +0 -4
- package/src/lib/shared/toBunRoutePattern.ts +0 -28
- package/src/lib/shared/types/TailOptions.ts +0 -10
- package/src/lib/ui/deferResume.ts +0 -36
- package/src/lib/ui/dom/firstElementBetween.ts +0 -14
- package/src/lib/ui/matchRoute.ts +0 -106
- package/src/lib/ui/runtime/scheduleWake.ts +0 -28
- package/src/lib/ui/runtime/whenIdle.ts +0 -21
- package/src/lib/ui/runtime/whenVisible.ts +0 -105
- package/src/lib/ui/tail.ts +0 -324
- /package/src/lib/{server/sockets → shared}/types/SocketClientFrame.ts +0 -0
- /package/src/lib/{server/sockets → shared}/types/SocketServerFrame.ts +0 -0
package/src/lib/ui/matchRoute.ts
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import type { RouteSegment } from '../shared/parseRouteSegments.ts'
|
|
2
|
-
import { parseRouteSegments } from '../shared/parseRouteSegments.ts'
|
|
3
|
-
|
|
4
|
-
/*
|
|
5
|
-
Per-route parse cache. The route set is stable across a session, so parsing a
|
|
6
|
-
pattern into segments (and counting its literals, the specificity tie-breaker) is
|
|
7
|
-
done once per route rather than twice per route on every navigation. Keyed by the
|
|
8
|
-
pattern string; entries never need eviction since routes don't churn.
|
|
9
|
-
*/
|
|
10
|
-
const PARSED_ROUTES = new Map<string, { segments: RouteSegment[]; literals: number }>()
|
|
11
|
-
|
|
12
|
-
function parsedRoute(route: string): { segments: RouteSegment[]; literals: number } {
|
|
13
|
-
let parsed = PARSED_ROUTES.get(route)
|
|
14
|
-
if (parsed === undefined) {
|
|
15
|
-
const segments = parseRouteSegments(route)
|
|
16
|
-
const literals = segments.filter((segment) => segment.kind === 'literal').length
|
|
17
|
-
parsed = { segments, literals }
|
|
18
|
-
PARSED_ROUTES.set(route, parsed)
|
|
19
|
-
}
|
|
20
|
-
return parsed
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/*
|
|
24
|
-
Client route matcher: given the registered route patterns and the current
|
|
25
|
-
pathname, returns the matching route + decoded params, or undefined. Mirrors the
|
|
26
|
-
server's segment grammar via the shared `parseRouteSegments` (literal / `[name]` /
|
|
27
|
-
`[...rest]`), so client navigation decodes params the same way SSR does. The most
|
|
28
|
-
specific match wins — the pattern with the most literal segments — so a static
|
|
29
|
-
route beats a param route at the same depth.
|
|
30
|
-
*/
|
|
31
|
-
export function matchRoute(
|
|
32
|
-
routes: string[],
|
|
33
|
-
pathname: string,
|
|
34
|
-
): { route: string; params: Record<string, string> } | undefined {
|
|
35
|
-
/* Normalize a trailing slash (except root) so `/users/` matches `/users` —
|
|
36
|
-
otherwise the extra empty segment makes the length guard reject it (a 404 the
|
|
37
|
-
server's own routing wouldn't give) or lets `/users/[id]` capture an empty id. */
|
|
38
|
-
const normalized =
|
|
39
|
-
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname
|
|
40
|
-
const pathSegments = normalized.split('/')
|
|
41
|
-
let best: { route: string; params: Record<string, string>; literals: number } | undefined
|
|
42
|
-
for (const route of routes) {
|
|
43
|
-
const parsed = parsedRoute(route)
|
|
44
|
-
const params = matchSegments(parsed.segments, pathSegments)
|
|
45
|
-
if (params === undefined) {
|
|
46
|
-
continue
|
|
47
|
-
}
|
|
48
|
-
if (best === undefined || parsed.literals > best.literals) {
|
|
49
|
-
best = { route, params, literals: parsed.literals }
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
return best === undefined ? undefined : { route: best.route, params: best.params }
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/* Percent-decodes a captured `[name]` value. Bun's `req.params` decoding is
|
|
56
|
-
lenient (malformed sequences pass through), so mirror that by falling back to
|
|
57
|
-
the raw value rather than throwing on a malformed `%` a page navigation would
|
|
58
|
-
otherwise crash on. */
|
|
59
|
-
function decodeParam(value: string): string {
|
|
60
|
-
try {
|
|
61
|
-
return decodeURIComponent(value)
|
|
62
|
-
} catch {
|
|
63
|
-
return value
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/* Matches one parsed pattern against the path's segments, capturing params;
|
|
68
|
-
undefined on mismatch. A catch-all consumes every remaining segment. */
|
|
69
|
-
function matchSegments(
|
|
70
|
-
segments: RouteSegment[],
|
|
71
|
-
pathSegments: string[],
|
|
72
|
-
): Record<string, string> | undefined {
|
|
73
|
-
const params: Record<string, string> = {}
|
|
74
|
-
for (let index = 0; index < segments.length; index += 1) {
|
|
75
|
-
const segment = segments[index]
|
|
76
|
-
if (segment === undefined) {
|
|
77
|
-
return undefined
|
|
78
|
-
}
|
|
79
|
-
if (segment.kind === 'param' && segment.catchAll) {
|
|
80
|
-
params[segment.name] = pathSegments.slice(index).join('/')
|
|
81
|
-
return params
|
|
82
|
-
}
|
|
83
|
-
const value = pathSegments[index]
|
|
84
|
-
if (value === undefined) {
|
|
85
|
-
return undefined
|
|
86
|
-
}
|
|
87
|
-
if (segment.kind === 'literal') {
|
|
88
|
-
if (segment.value !== value) {
|
|
89
|
-
return undefined
|
|
90
|
-
}
|
|
91
|
-
} else {
|
|
92
|
-
/* A `[name]` param never captures an empty segment (e.g. `/users//5`). */
|
|
93
|
-
if (value === '') {
|
|
94
|
-
return undefined
|
|
95
|
-
}
|
|
96
|
-
/* `url()` encodes a `[name]` value whole, and Bun decodes `req.params`
|
|
97
|
-
server-side, so decode here to hand the page the same value SSR does
|
|
98
|
-
(e.g. `The%20Daily%20Show` → `The Daily Show`). The catch-all above
|
|
99
|
-
stays raw to match the server, which reconstructs it from the raw
|
|
100
|
-
pathname. */
|
|
101
|
-
params[segment.name] = decodeParam(value)
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
/* No catch-all consumed the tail, so the path must have no extra segments. */
|
|
105
|
-
return pathSegments.length === segments.length ? params : undefined
|
|
106
|
-
}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { whenIdle } from './whenIdle.ts'
|
|
2
|
-
import { whenVisible } from './whenVisible.ts'
|
|
3
|
-
|
|
4
|
-
/*
|
|
5
|
-
Schedule a one-shot wake for a deferred (inert-hydrated) region by trigger — the single
|
|
6
|
-
trigger-selection both the await block and component islands share. `'visible'` (and `'auto'`)
|
|
7
|
-
wake on scroll-in when the DOM can be measured (a real IntersectionObserver) and the region has
|
|
8
|
-
an element to observe: a below-the-fold region decodes only when reached, one never scrolled to
|
|
9
|
-
costs nothing. Otherwise — `'idle'`, no observer, or an empty branch — wake on idle: off the
|
|
10
|
-
critical boot path but soon, so the region never lingers inert. Returns a cancel to drop a
|
|
11
|
-
pending wake on teardown.
|
|
12
|
-
|
|
13
|
-
`'auto'` is the await block's policy (defer decides, position decides trigger); `'idle'` /
|
|
14
|
-
`'visible'` are an island's explicit `client:` choice.
|
|
15
|
-
*/
|
|
16
|
-
export function scheduleWake(
|
|
17
|
-
trigger: 'idle' | 'visible' | 'auto',
|
|
18
|
-
element: Element | undefined,
|
|
19
|
-
wake: () => void,
|
|
20
|
-
): () => void {
|
|
21
|
-
const hasObserver =
|
|
22
|
-
typeof (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver ===
|
|
23
|
-
'function'
|
|
24
|
-
if (trigger !== 'idle' && hasObserver && element !== undefined) {
|
|
25
|
-
return whenVisible(element, wake)
|
|
26
|
-
}
|
|
27
|
-
return whenIdle(wake)
|
|
28
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
Run `callback` once, in the first idle gap after the current frame — the client-side
|
|
3
|
-
trigger a deferred (inert-hydrated) region uses to wake itself: boot stays cheap (no
|
|
4
|
-
decode, no effects), then the region belatedly comes alive off the critical path,
|
|
5
|
-
before a human can act on it. `requestIdleCallback` when the runtime has it (a `timeout`
|
|
6
|
-
guarantees it fires even under sustained load); a `setTimeout(0)` macrotask otherwise
|
|
7
|
-
(Safari < 16.4, test DOMs), which still clears the boot frame. Returns a cancel to drop
|
|
8
|
-
the pending wake on teardown, so a region torn down before idle does no late work.
|
|
9
|
-
*/
|
|
10
|
-
export function whenIdle(callback: () => void, timeoutMs = 200): () => void {
|
|
11
|
-
const globalWithIdle = globalThis as {
|
|
12
|
-
requestIdleCallback?: (cb: () => void, options?: { timeout: number }) => number
|
|
13
|
-
cancelIdleCallback?: (handle: number) => void
|
|
14
|
-
}
|
|
15
|
-
if (typeof globalWithIdle.requestIdleCallback === 'function') {
|
|
16
|
-
const handle = globalWithIdle.requestIdleCallback(callback, { timeout: timeoutMs })
|
|
17
|
-
return () => globalWithIdle.cancelIdleCallback?.(handle)
|
|
18
|
-
}
|
|
19
|
-
const handle = setTimeout(callback, 0)
|
|
20
|
-
return () => clearTimeout(handle)
|
|
21
|
-
}
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
Run `callback` once, the first time `element` enters (or is already within) the viewport —
|
|
3
|
-
the trigger a below-the-fold deferred region uses to wake only when scrolled into view, so a
|
|
4
|
-
grid the user never reaches costs nothing. A synchronous fire where the observer is absent
|
|
5
|
-
(SSR/test DOMs), which keeps the region from staying inert where visibility can't be measured.
|
|
6
|
-
Returns a cancel to drop a pending watch on teardown. Fires at most once.
|
|
7
|
-
|
|
8
|
-
ONE shared IntersectionObserver backs every watcher (a grid of N islands registers N elements
|
|
9
|
-
on one observer, not N observers), and wakes are BATCHED off the observer callback into a
|
|
10
|
-
frame-flush capped at `WAKE_BUDGET_PER_FRAME`: a wake rebuilds DOM, so running it inside the
|
|
11
|
-
observer callback would force a synchronous layout that shifts other elements across the
|
|
12
|
-
margin and re-fires the observer — a cascade that locks the main thread at grid scale. Queuing
|
|
13
|
-
to a rAF and spending a bounded number per frame breaks that loop and keeps each frame cheap;
|
|
14
|
-
overflow spills to the next frame, so even a whole grid scrolled into view at once stays
|
|
15
|
-
responsive.
|
|
16
|
-
*/
|
|
17
|
-
// A screenful of margin so a region is live by the time it's actually read.
|
|
18
|
-
const ROOT_MARGIN = '256px'
|
|
19
|
-
// Max wakes (DOM rebuilds) per frame — the ceiling that keeps a flush off the critical path.
|
|
20
|
-
const WAKE_BUDGET_PER_FRAME = 40
|
|
21
|
-
|
|
22
|
-
let sharedObserver: IntersectionObserver | undefined
|
|
23
|
-
/* The IntersectionObserver constructor `sharedObserver` was built from. Identity only changes
|
|
24
|
-
when a test swaps `globalThis.IntersectionObserver`; on a change the observer + its pending
|
|
25
|
-
registrations are rebuilt, so module state doesn't leak across tests. */
|
|
26
|
-
let sharedObserverCtor: unknown
|
|
27
|
-
const wakeByElement = new Map<Element, () => void>()
|
|
28
|
-
const readyWakes = new Set<() => void>()
|
|
29
|
-
let flushScheduled = false
|
|
30
|
-
|
|
31
|
-
/* Next animation frame (browser) or macrotask (test/SSR) — the flush cadence. */
|
|
32
|
-
function scheduleFrame(run: () => void): void {
|
|
33
|
-
const globalWithRaf = globalThis as { requestAnimationFrame?: (cb: () => void) => number }
|
|
34
|
-
if (typeof globalWithRaf.requestAnimationFrame === 'function') {
|
|
35
|
-
globalWithRaf.requestAnimationFrame(run)
|
|
36
|
-
} else {
|
|
37
|
-
setTimeout(run, 0)
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/* Spend up to the per-frame budget of queued wakes; reschedule if any remain. */
|
|
42
|
-
function flushWakes(): void {
|
|
43
|
-
flushScheduled = false
|
|
44
|
-
let processed = 0
|
|
45
|
-
for (const wake of readyWakes) {
|
|
46
|
-
readyWakes.delete(wake)
|
|
47
|
-
wake()
|
|
48
|
-
processed += 1
|
|
49
|
-
if (processed >= WAKE_BUDGET_PER_FRAME) {
|
|
50
|
-
break
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
scheduleFlush()
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function scheduleFlush(): void {
|
|
57
|
-
if (flushScheduled || readyWakes.size === 0) {
|
|
58
|
-
return
|
|
59
|
-
}
|
|
60
|
-
flushScheduled = true
|
|
61
|
-
scheduleFrame(flushWakes)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/* Queue every newly-intersecting element's wake — never run it here (see the cascade note). */
|
|
65
|
-
function onIntersections(entries: IntersectionObserverEntry[]): void {
|
|
66
|
-
for (const entry of entries) {
|
|
67
|
-
if (entry.isIntersecting) {
|
|
68
|
-
const wake = wakeByElement.get(entry.target)
|
|
69
|
-
if (wake !== undefined) {
|
|
70
|
-
wakeByElement.delete(entry.target)
|
|
71
|
-
sharedObserver?.unobserve(entry.target)
|
|
72
|
-
readyWakes.add(wake)
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
scheduleFlush()
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function observerFor(observerConstructor: typeof IntersectionObserver): IntersectionObserver {
|
|
80
|
-
if (sharedObserver === undefined || sharedObserverCtor !== observerConstructor) {
|
|
81
|
-
wakeByElement.clear()
|
|
82
|
-
readyWakes.clear()
|
|
83
|
-
sharedObserver = new observerConstructor(onIntersections, { rootMargin: ROOT_MARGIN })
|
|
84
|
-
sharedObserverCtor = observerConstructor
|
|
85
|
-
}
|
|
86
|
-
return sharedObserver
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function whenVisible(element: Element, callback: () => void): () => void {
|
|
90
|
-
const observerConstructor = (
|
|
91
|
-
globalThis as { IntersectionObserver?: typeof IntersectionObserver }
|
|
92
|
-
).IntersectionObserver
|
|
93
|
-
if (typeof observerConstructor !== 'function') {
|
|
94
|
-
callback()
|
|
95
|
-
return () => undefined
|
|
96
|
-
}
|
|
97
|
-
const observer = observerFor(observerConstructor)
|
|
98
|
-
wakeByElement.set(element, callback)
|
|
99
|
-
observer.observe(element)
|
|
100
|
-
return () => {
|
|
101
|
-
wakeByElement.delete(element)
|
|
102
|
-
readyWakes.delete(callback)
|
|
103
|
-
observer.unobserve(element)
|
|
104
|
-
}
|
|
105
|
-
}
|
package/src/lib/ui/tail.ts
DELETED
|
@@ -1,324 +0,0 @@
|
|
|
1
|
-
import { createLifecycleChannel } from '../shared/createLifecycleChannel.ts'
|
|
2
|
-
import { createSubscriber } from '../shared/createSubscriber.ts'
|
|
3
|
-
import { SocketDisconnectedError } from '../shared/SocketDisconnectedError.ts'
|
|
4
|
-
import { tailProbeSlot } from '../shared/tailProbeSlot.ts'
|
|
5
|
-
import type { Subscribable } from '../shared/types/Subscribable.ts'
|
|
6
|
-
import type { TailOptions } from '../shared/types/TailOptions.ts'
|
|
7
|
-
|
|
8
|
-
type TailStatus = 'pending' | 'open' | 'done' | 'error'
|
|
9
|
-
|
|
10
|
-
/*
|
|
11
|
-
One entry shape for both read forms: the bare latest-wins read is a window of
|
|
12
|
-
1 projected to its single frame, so the frame loop, reconnect handling, and
|
|
13
|
-
probes have exactly one path. The bare form keys by name alone (so `tail(x)`
|
|
14
|
-
and `tail(x, { last: 1 })` stay independent subscriptions); probes match on
|
|
15
|
-
`source`.
|
|
16
|
-
*/
|
|
17
|
-
type Entry<T> = {
|
|
18
|
-
source: string
|
|
19
|
-
key: string
|
|
20
|
-
/* window cap; 1 for the bare latest-wins form */
|
|
21
|
-
last: number
|
|
22
|
-
window: T[]
|
|
23
|
-
error: Error | undefined
|
|
24
|
-
status: TailStatus
|
|
25
|
-
refreshing: boolean
|
|
26
|
-
/* true while the createSubscriber connect callback holds the iterator open */
|
|
27
|
-
live: boolean
|
|
28
|
-
tap: () => void
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const registry = new Map<string, Entry<unknown>>()
|
|
32
|
-
|
|
33
|
-
/*
|
|
34
|
-
Registry-wide lifecycle channel for the pending()/refreshing() probes — the
|
|
35
|
-
tail-side counterpart of the cache store's. Probes match entries by source
|
|
36
|
-
name (or all) without creating them, so they tap one "membership or state
|
|
37
|
-
changed" signal instead of opening a stream the way a tail() read would.
|
|
38
|
-
*/
|
|
39
|
-
const lifecycle = createLifecycleChannel()
|
|
40
|
-
|
|
41
|
-
/*
|
|
42
|
-
Prober for the shared pending()/refreshing() probes. Probes report, never
|
|
43
|
-
act: an absent entry is read as "no value yet" (pending) without opening a
|
|
44
|
-
stream. A name spans every entry on that source — latest-wins and window
|
|
45
|
-
forms alike; the bare form spans every registered stream.
|
|
46
|
-
*/
|
|
47
|
-
tailProbeSlot.probe = (name) => {
|
|
48
|
-
lifecycle.track()
|
|
49
|
-
const entries = [...registry.values()].filter(
|
|
50
|
-
(entry) => name === undefined || entry.source === name,
|
|
51
|
-
)
|
|
52
|
-
return {
|
|
53
|
-
pending:
|
|
54
|
-
(name !== undefined && entries.length === 0) ||
|
|
55
|
-
entries.some((entry) => entry.status === 'pending'),
|
|
56
|
-
refreshing: entries.some((entry) => entry.refreshing),
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/*
|
|
61
|
-
Reactive consumer for streaming sources. Takes a Subscribable<T> — the shape
|
|
62
|
-
both `Socket<T>` (declared under src/server/sockets/) and the result of
|
|
63
|
-
`fn.stream(args)` satisfy:
|
|
64
|
-
|
|
65
|
-
const latest = $derived(tail(chat)) // socket, latest frame
|
|
66
|
-
const recent = $derived(tail(chat, { last: 20 })) // live window of the last ≤20
|
|
67
|
-
const tick = $derived(tail(tickFeed.stream())) // rpc stream
|
|
68
|
-
|
|
69
|
-
The bare form is latest-wins: T | undefined, pending until the first frame.
|
|
70
|
-
The window form returns T[] — `[]` while pending, never undefined — holding
|
|
71
|
-
the last ≤`last` frames however they arrived. Seeding rides the source's
|
|
72
|
-
optional retention capability (`Subscribable.tail(count)`): a socket declared
|
|
73
|
-
`{ tail: n }` replays up to `last` retained frames (1 for the bare form — a
|
|
74
|
-
latest-wins reader only needs the newest); a source without the capability
|
|
75
|
-
(rpc stream, undeclared socket) starts live-only. tail() never requires
|
|
76
|
-
retention — the window semantics are identical either way, only how much
|
|
77
|
-
past it can show differs.
|
|
78
|
-
|
|
79
|
-
Lifecycle mirrors cache(): the entry's tracker is a createSubscriber,
|
|
80
|
-
so the first derived read in a tracking scope opens the underlying iterator
|
|
81
|
-
and the last derived to stop reading closes it. Many deriveds reading the
|
|
82
|
-
same source share one underlying subscription — the registry dedupes by
|
|
83
|
-
`subscribable.name`, with `last` folded into the key, so the bare form and
|
|
84
|
-
each window size are independent subscriptions. Passing fresh `fn.stream(args)`
|
|
85
|
-
Subscribables across re-renders is safe: same args → same key → shared
|
|
86
|
-
subscription. tail.error / tail.status take the same options to address the
|
|
87
|
-
same entry.
|
|
88
|
-
|
|
89
|
-
Reconnect-with-retained-value: a transport loss (the typed
|
|
90
|
-
SocketDisconnectedError, raised only by the ws channel — an rpc stream is a
|
|
91
|
-
one-shot Response and never disconnects) does not surface as an error. The
|
|
92
|
-
entry keeps its window, flags `refreshing` (the probe contract: value held,
|
|
93
|
-
fresher source in flight — never merely `open`), and re-invokes the source;
|
|
94
|
-
the channel's backoff owns the retry. The reopened source's replay commits
|
|
95
|
-
over the window atomically at the `replayed` boundary (TailHooks) — appended
|
|
96
|
-
it would duplicate, rebuilt frame-by-frame it would flash — and an empty
|
|
97
|
-
replay keeps the held window, with live frames appending after. For the
|
|
98
|
-
bare form the commit is exactly latest-wins convergence. Initial opens seed
|
|
99
|
-
the same way, so a window's first paint is the full seed, not a staircase.
|
|
100
|
-
|
|
101
|
-
tail is a no-op on the server (returns undefined / []) — SSR can't keep a
|
|
102
|
-
stream open across the request boundary. Pages that want a seeded value in
|
|
103
|
-
the initial HTML should fetch via cache() against an HTTP rpc handler and
|
|
104
|
-
layer tail() on top for live updates after hydration.
|
|
105
|
-
|
|
106
|
-
Errors are surfaced through tail.error(x) rather than thrown, so reading
|
|
107
|
-
from a $derived can't crash the component. Status distinguishes "haven't
|
|
108
|
-
received the first frame" (pending) from "stream ended cleanly" (done) and
|
|
109
|
-
"wire layer surfaced an error" (error).
|
|
110
|
-
*/
|
|
111
|
-
// @documentation tail
|
|
112
|
-
export function tail<T>(subscribable: Subscribable<T>): T | undefined
|
|
113
|
-
export function tail<T>(subscribable: Subscribable<T>, options: TailOptions): T[]
|
|
114
|
-
export function tail<T>(subscribable: Subscribable<T>, options?: TailOptions): T | T[] | undefined {
|
|
115
|
-
if (options) {
|
|
116
|
-
return readField(subscribable, options, 'window') ?? []
|
|
117
|
-
}
|
|
118
|
-
return readField(subscribable, undefined, 'window')?.[0]
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
tail.error = function tailError<T>(
|
|
122
|
-
subscribable: Subscribable<T>,
|
|
123
|
-
options?: TailOptions,
|
|
124
|
-
): Error | undefined {
|
|
125
|
-
return readField(subscribable, options, 'error')
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
tail.status = function tailStatus<T>(
|
|
129
|
-
subscribable: Subscribable<T>,
|
|
130
|
-
options?: TailOptions,
|
|
131
|
-
): TailStatus {
|
|
132
|
-
return readField(subscribable, options, 'status') ?? 'pending'
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function readField<T, K extends keyof Entry<T>>(
|
|
136
|
-
subscribable: Subscribable<T>,
|
|
137
|
-
options: TailOptions | undefined,
|
|
138
|
-
field: K,
|
|
139
|
-
): Entry<T>[K] | undefined {
|
|
140
|
-
if (options && (!Number.isInteger(options.last) || options.last < 1)) {
|
|
141
|
-
throw new RangeError(`[abide] tail() \`last\` must be an integer ≥ 1, got ${options.last}`)
|
|
142
|
-
}
|
|
143
|
-
if (typeof window === 'undefined') {
|
|
144
|
-
return undefined
|
|
145
|
-
}
|
|
146
|
-
const entry = getOrCreateEntry(subscribable, options)
|
|
147
|
-
entry.tap()
|
|
148
|
-
const value = entry[field]
|
|
149
|
-
/*
|
|
150
|
-
Untracked read (outside $derived/$effect): tap() never connects, so no
|
|
151
|
-
teardown will ever evict the entry — drop it now or it sits in the
|
|
152
|
-
registry as a permanently-pending zombie the bare probes keep seeing.
|
|
153
|
-
*/
|
|
154
|
-
if (!entry.live) {
|
|
155
|
-
evictIfCurrent(entry as Entry<unknown>)
|
|
156
|
-
}
|
|
157
|
-
return value
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/* Delete only if this entry still owns its key — a fresh Subscribable with the
|
|
161
|
-
same name may have replaced it, and a stale cleanup must not nuke the new entry. */
|
|
162
|
-
function evictIfCurrent(entry: Entry<unknown>): boolean {
|
|
163
|
-
if (registry.get(entry.key) === entry) {
|
|
164
|
-
registry.delete(entry.key)
|
|
165
|
-
return true
|
|
166
|
-
}
|
|
167
|
-
return false
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function getOrCreateEntry<T>(
|
|
171
|
-
subscribable: Subscribable<T>,
|
|
172
|
-
options: TailOptions | undefined,
|
|
173
|
-
): Entry<T> {
|
|
174
|
-
const key = options ? `${subscribable.name}#${options.last}` : subscribable.name
|
|
175
|
-
const cached = registry.get(key)
|
|
176
|
-
if (cached) {
|
|
177
|
-
return cached as Entry<T>
|
|
178
|
-
}
|
|
179
|
-
const last = options?.last ?? 1
|
|
180
|
-
const entry: Entry<T> = {
|
|
181
|
-
source: subscribable.name,
|
|
182
|
-
key,
|
|
183
|
-
last,
|
|
184
|
-
window: [],
|
|
185
|
-
error: undefined,
|
|
186
|
-
status: 'pending',
|
|
187
|
-
refreshing: false,
|
|
188
|
-
live: false,
|
|
189
|
-
tap: () => undefined,
|
|
190
|
-
}
|
|
191
|
-
entry.tap = createSubscriber((update) => {
|
|
192
|
-
entry.window = []
|
|
193
|
-
entry.error = undefined
|
|
194
|
-
entry.status = 'pending'
|
|
195
|
-
entry.refreshing = false
|
|
196
|
-
entry.live = true
|
|
197
|
-
let cancelled = false
|
|
198
|
-
const notify = () => {
|
|
199
|
-
update()
|
|
200
|
-
lifecycle.mark()
|
|
201
|
-
}
|
|
202
|
-
/*
|
|
203
|
-
Seed-and-commit: frames from a retaining source accumulate silently
|
|
204
|
-
until its in-band `replayed` boundary, then commit to the window in
|
|
205
|
-
one update — readers never see the window rebuild frame-by-frame.
|
|
206
|
-
An empty seed keeps the held window across a gap: nothing was
|
|
207
|
-
replayed, so nothing can duplicate, and the live frames that follow
|
|
208
|
-
append. Sources without the capability have no replay; their frames
|
|
209
|
-
append directly.
|
|
210
|
-
*/
|
|
211
|
-
let seeding = false
|
|
212
|
-
let seed: T[] = []
|
|
213
|
-
const commit = () => {
|
|
214
|
-
if (cancelled || !seeding) {
|
|
215
|
-
return
|
|
216
|
-
}
|
|
217
|
-
seeding = false
|
|
218
|
-
if (seed.length > 0) {
|
|
219
|
-
entry.window = seed
|
|
220
|
-
seed = []
|
|
221
|
-
}
|
|
222
|
-
if (entry.window.length > 0) {
|
|
223
|
-
entry.status = 'open'
|
|
224
|
-
}
|
|
225
|
-
entry.refreshing = false
|
|
226
|
-
notify()
|
|
227
|
-
}
|
|
228
|
-
/*
|
|
229
|
-
A retaining source bounds replay to what the reader keeps: 1 frame
|
|
230
|
-
seeds the bare form, `last` seeds a window.
|
|
231
|
-
*/
|
|
232
|
-
const open = () => {
|
|
233
|
-
if (!subscribable.tail) {
|
|
234
|
-
return subscribable[Symbol.asyncIterator]()
|
|
235
|
-
}
|
|
236
|
-
seeding = true
|
|
237
|
-
seed = []
|
|
238
|
-
return subscribable.tail(last, { replayed: commit })[Symbol.asyncIterator]()
|
|
239
|
-
}
|
|
240
|
-
/* `let`: the reconnect path swaps in a fresh iterator; teardown closes the current one. */
|
|
241
|
-
let iterator = open()
|
|
242
|
-
;(async () => {
|
|
243
|
-
while (!cancelled) {
|
|
244
|
-
try {
|
|
245
|
-
const next = await iterator.next()
|
|
246
|
-
if (cancelled) {
|
|
247
|
-
return
|
|
248
|
-
}
|
|
249
|
-
if (next.done) {
|
|
250
|
-
/* a retaining source that ends without signalling still commits its seed */
|
|
251
|
-
commit()
|
|
252
|
-
if (entry.status !== 'error') {
|
|
253
|
-
entry.status = 'done'
|
|
254
|
-
}
|
|
255
|
-
entry.refreshing = false
|
|
256
|
-
notify()
|
|
257
|
-
return
|
|
258
|
-
}
|
|
259
|
-
if (seeding) {
|
|
260
|
-
seed.push(next.value)
|
|
261
|
-
if (seed.length > last) {
|
|
262
|
-
seed.shift()
|
|
263
|
-
}
|
|
264
|
-
continue
|
|
265
|
-
}
|
|
266
|
-
/*
|
|
267
|
-
Probes read only status/refreshing, so the registry
|
|
268
|
-
channel pings on transitions, not per frame — a chatty
|
|
269
|
-
stream must not re-derive every bare pending()/refreshing()
|
|
270
|
-
reader on each value. (A frame after a reconnect is the
|
|
271
|
-
seed landing — current again, hence a transition.)
|
|
272
|
-
*/
|
|
273
|
-
const transitioned = entry.status !== 'open' || entry.refreshing
|
|
274
|
-
/* One copy per frame: take the surviving suffix, then append. */
|
|
275
|
-
const base = entry.window
|
|
276
|
-
const frames =
|
|
277
|
-
base.length < last ? base.slice() : base.slice(base.length - last + 1)
|
|
278
|
-
frames.push(next.value)
|
|
279
|
-
entry.window = frames
|
|
280
|
-
entry.status = 'open'
|
|
281
|
-
entry.refreshing = false
|
|
282
|
-
update()
|
|
283
|
-
if (transitioned) {
|
|
284
|
-
lifecycle.mark()
|
|
285
|
-
}
|
|
286
|
-
} catch (error) {
|
|
287
|
-
if (cancelled) {
|
|
288
|
-
return
|
|
289
|
-
}
|
|
290
|
-
if (error instanceof SocketDisconnectedError) {
|
|
291
|
-
/*
|
|
292
|
-
Recoverable transport loss: retain the window, flag the
|
|
293
|
-
gap, reopen — open() re-arms seeding so the reopened
|
|
294
|
-
source's replay commits atomically over the held
|
|
295
|
-
window. The fresh sub frame queues on the channel and
|
|
296
|
-
flushes when its backoff attempt reconnects, so this
|
|
297
|
-
loop just awaits the next push — no spin.
|
|
298
|
-
*/
|
|
299
|
-
entry.refreshing = entry.window.length > 0
|
|
300
|
-
notify()
|
|
301
|
-
iterator = open()
|
|
302
|
-
continue
|
|
303
|
-
}
|
|
304
|
-
entry.error = error instanceof Error ? error : new Error(String(error))
|
|
305
|
-
entry.status = 'error'
|
|
306
|
-
entry.refreshing = false
|
|
307
|
-
notify()
|
|
308
|
-
return
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
})()
|
|
312
|
-
lifecycle.mark()
|
|
313
|
-
return () => {
|
|
314
|
-
cancelled = true
|
|
315
|
-
entry.live = false
|
|
316
|
-
iterator.return?.(undefined)?.catch(() => undefined)
|
|
317
|
-
if (evictIfCurrent(entry as Entry<unknown>)) {
|
|
318
|
-
lifecycle.mark()
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
})
|
|
322
|
-
registry.set(key, entry as Entry<unknown>)
|
|
323
|
-
return entry
|
|
324
|
-
}
|
|
File without changes
|
|
File without changes
|