@abide/abide 0.54.0 → 0.55.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 +330 -498
- package/CHANGELOG.md +49 -0
- package/README.md +166 -146
- package/package.json +2 -3
- package/src/lib/server/rpc/defineRpc.ts +2 -1
- package/src/lib/server/rpc/types/RpcHelper.ts +29 -22
- package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +3 -2
- package/src/lib/server/runtime/createUiPageRenderer.ts +34 -26
- package/src/lib/shared/SEEDS.ts +33 -0
- package/src/lib/shared/SOCKET_SEED.ts +4 -5
- package/src/lib/shared/cache.ts +169 -75
- package/src/lib/shared/createRemoteFunction.ts +4 -1
- package/src/lib/shared/encodeSeedValue.ts +24 -0
- package/src/lib/shared/jsonSchemaForType.ts +3 -16
- package/src/lib/shared/peek.ts +3 -6
- package/src/lib/ui/amendReaderHook.ts +1 -1
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -6
- package/src/lib/ui/compile/analyzeComponent.ts +0 -2
- package/src/lib/ui/compile/awaitSubjectExpr.ts +23 -0
- package/src/lib/ui/compile/cachedShadowProgram.ts +2 -1
- package/src/lib/ui/compile/compileComponent.ts +1 -10
- package/src/lib/ui/compile/componentOrdinals.ts +39 -0
- package/src/lib/ui/compile/desugarSignals.ts +8 -44
- package/src/lib/ui/compile/generateBuild.ts +25 -24
- package/src/lib/ui/compile/generateSSR.ts +37 -24
- package/src/lib/ui/compile/hoistableAwaits.ts +1 -11
- package/src/lib/ui/compile/isSpuriousAsyncReadDiagnostic.ts +46 -5
- package/src/lib/ui/compile/lowerContext.ts +26 -6
- package/src/lib/ui/compile/lowerScript.ts +1 -11
- package/src/lib/ui/compile/renameSignalRefs.ts +7 -21
- package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +1 -1
- package/src/lib/ui/compile/shadowInterpolationClassifier.ts +1 -1
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +3 -5
- package/src/lib/ui/compile/wrapReactiveSeeds.ts +44 -0
- package/src/lib/ui/compile/wrapSeed.ts +28 -0
- package/src/lib/ui/computed.ts +16 -0
- package/src/lib/ui/createScope.ts +4 -3
- package/src/lib/ui/dom/anchorCursor.ts +2 -1
- package/src/lib/ui/dom/anchoredBranch.ts +14 -3
- package/src/lib/ui/dom/appendStatic.ts +11 -20
- package/src/lib/ui/dom/appendText.ts +41 -43
- package/src/lib/ui/dom/appendTextAt.ts +10 -57
- package/src/lib/ui/dom/awaitBlock.ts +30 -44
- package/src/lib/ui/dom/awaitSubject.ts +118 -0
- package/src/lib/ui/dom/claimText.ts +38 -0
- package/src/lib/ui/dom/cloneStatic.ts +2 -8
- package/src/lib/ui/dom/discardAndRebuild.ts +23 -0
- package/src/lib/ui/dom/discardBoundary.ts +5 -4
- package/src/lib/ui/dom/each.ts +3 -5
- package/src/lib/ui/dom/fillBoundary.ts +2 -1
- package/src/lib/ui/dom/hydrate.ts +5 -12
- package/src/lib/ui/dom/mountChild.ts +73 -5
- package/src/lib/ui/dom/mountSwappableRange.ts +2 -1
- package/src/lib/ui/dom/openMarker.ts +2 -4
- package/src/lib/ui/dom/outlet.ts +4 -3
- package/src/lib/ui/dom/readCell.ts +26 -11
- package/src/lib/ui/dom/skeleton.ts +2 -7
- package/src/lib/ui/dom/switchBlock.ts +2 -1
- package/src/lib/ui/dom/tryBlock.ts +26 -38
- package/src/lib/ui/dom/when.ts +6 -2
- package/src/lib/ui/linked.ts +14 -0
- package/src/lib/ui/resumeSeedScript.ts +1 -1
- package/src/lib/ui/router.ts +12 -24
- package/src/lib/ui/runtime/AsyncCellError.ts +2 -1
- package/src/lib/ui/runtime/BRANCH_SEGMENT.ts +24 -0
- package/src/lib/ui/runtime/CELL_SEED.ts +4 -6
- package/src/lib/ui/runtime/DOC_SEED.ts +5 -6
- package/src/lib/ui/runtime/RENDER.ts +7 -5
- package/src/lib/ui/runtime/RESUME.ts +5 -7
- package/src/lib/ui/runtime/SEED_MARKS.ts +14 -0
- package/src/lib/ui/runtime/SuspenseSignal.ts +2 -2
- package/src/lib/ui/runtime/claimChild.ts +5 -7
- package/src/lib/ui/runtime/claimExpected.ts +2 -6
- package/src/lib/ui/runtime/claimMarker.ts +13 -0
- package/src/lib/ui/runtime/claimRun.ts +22 -0
- package/src/lib/ui/runtime/consumeSeed.ts +38 -0
- package/src/lib/ui/runtime/createAsyncCell.ts +56 -8
- package/src/lib/ui/runtime/createComputedNode.ts +1 -0
- package/src/lib/ui/runtime/createEffectNode.ts +1 -0
- package/src/lib/ui/runtime/createSignalNode.ts +1 -0
- package/src/lib/ui/runtime/parkCursor.ts +10 -0
- package/src/lib/ui/runtime/readNode.ts +21 -1
- package/src/lib/ui/runtime/renderPath.ts +2 -2
- package/src/lib/ui/runtime/routeBareSeed.ts +28 -0
- package/src/lib/ui/runtime/runHydrationPass.ts +69 -0
- package/src/lib/ui/runtime/runNode.ts +14 -0
- package/src/lib/ui/runtime/types/HydrationCursor.ts +8 -0
- package/src/lib/ui/runtime/types/ReactiveNode.ts +5 -0
- package/src/lib/ui/settleAsyncCells.ts +30 -3
- package/src/lib/ui/startClient.ts +0 -7
- package/src/lib/ui/tryEncodeResume.ts +6 -15
- package/src/lib/ui/watch.ts +3 -11
- package/src/lib/ui/dom/mountStreamedChild.ts +0 -84
- package/src/lib/ui/dom/readCellBlocking.ts +0 -29
- package/src/lib/ui/runtime/restoreWarmSeeds.ts +0 -20
- package/src/lib/ui/runtime/warmSeedBackup.ts +0 -10
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/*
|
|
2
2
|
The tiny inline script the abide-ui SSR stream ships in <head>. For each streamed
|
|
3
3
|
`<abide-resolve data-id>` frame it reads the leading `<script type=application/json>`,
|
|
4
|
-
stores its raw ref-json text into `window.
|
|
4
|
+
stores its raw ref-json text into `window.__abideSeeds.resume` (the resume partition of the
|
|
5
|
+
one seed manifest, ADR-0048 — the store hydration
|
|
5
6
|
reads, decoding each entry at read time) and swaps the resolved markup into the matching
|
|
6
7
|
`<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary — so the pending shell paints
|
|
7
8
|
instantly and each value lands as it arrives, before the client bundle even loads. It
|
|
@@ -12,7 +13,7 @@ inlines cheaply ahead of the document body.
|
|
|
12
13
|
export const SSR_SWAP_SCRIPT =
|
|
13
14
|
"function __abideSwap(){var f=document.querySelector('abide-resolve');while(f){" +
|
|
14
15
|
"var id=f.getAttribute('data-id'),p=f.firstChild,w=document.createTreeWalker(document.body,NodeFilter.SHOW_COMMENT),o=null,c;" +
|
|
15
|
-
"if(p&&p.nodeName==='SCRIPT'){
|
|
16
|
+
"if(p&&p.nodeName==='SCRIPT'){var s=window.__abideSeeds=window.__abideSeeds||{};(s.resume=s.resume||{})[id]=p.textContent||'';p.remove();}" +
|
|
16
17
|
"while((c=w.nextNode())){if(c.data==='abide:await:'+id){o=c;break;}}" +
|
|
17
18
|
"if(o){var n=o.nextSibling;while(n&&!(n.nodeType===8&&n.data==='/abide:await:'+id)){var x=n.nextSibling;n.remove();n=x;}" +
|
|
18
19
|
"while(f.firstChild){o.parentNode.insertBefore(f.firstChild,n);}}f.remove();f=document.querySelector('abide-resolve');}}"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { appNameSlot } from '../../shared/appNameSlot.ts'
|
|
2
2
|
import { SSR_CACHE_CONTROL } from '../../shared/CACHE_CONTROL_VALUES.ts'
|
|
3
3
|
import { docSnapshotsSlot } from '../../shared/docSnapshotsSlot.ts'
|
|
4
|
-
import {
|
|
4
|
+
import { encodeSeedValue } from '../../shared/encodeSeedValue.ts'
|
|
5
5
|
import { formatTraceparent } from '../../shared/formatTraceparent.ts'
|
|
6
6
|
import { hasSeedableRequest } from '../../shared/hasSeedableRequest.ts'
|
|
7
7
|
import { layoutChainForRoute } from '../../shared/layoutChainForRoute.ts'
|
|
@@ -116,12 +116,13 @@ export function createUiPageRenderer({
|
|
|
116
116
|
}
|
|
117
117
|
const cells: Record<string, string> = {}
|
|
118
118
|
for (const { key, value } of entries) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
119
|
+
const encoded = encodeSeedValue(
|
|
120
|
+
value,
|
|
121
|
+
`async cell "${key}" resolved value`,
|
|
122
|
+
'it will re-run on the client instead of hydrating warm',
|
|
123
|
+
)
|
|
124
|
+
if (encoded !== undefined) {
|
|
125
|
+
cells[key] = encoded
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
return Object.keys(cells).length > 0 ? cells : undefined
|
|
@@ -152,11 +153,13 @@ export function createUiPageRenderer({
|
|
|
152
153
|
) {
|
|
153
154
|
continue
|
|
154
155
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
156
|
+
const encoded = encodeSeedValue(
|
|
157
|
+
snapshot,
|
|
158
|
+
`scope "${id}" state snapshot`,
|
|
159
|
+
'the client re-inits its state cold',
|
|
160
|
+
)
|
|
161
|
+
if (encoded !== undefined) {
|
|
162
|
+
docs[id] = encoded
|
|
160
163
|
}
|
|
161
164
|
}
|
|
162
165
|
return Object.keys(docs).length > 0 ? docs : undefined
|
|
@@ -175,12 +178,13 @@ export function createUiPageRenderer({
|
|
|
175
178
|
}
|
|
176
179
|
const sockets: Record<string, string> = {}
|
|
177
180
|
for (const { name, value } of entries) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
181
|
+
const encoded = encodeSeedValue(
|
|
182
|
+
value,
|
|
183
|
+
`socket "${name}" retained frame`,
|
|
184
|
+
'its client peek() reads undefined at hydration instead of the server value',
|
|
185
|
+
)
|
|
186
|
+
if (encoded !== undefined) {
|
|
187
|
+
sockets[name] = encoded
|
|
184
188
|
}
|
|
185
189
|
}
|
|
186
190
|
return Object.keys(sockets).length > 0 ? sockets : undefined
|
|
@@ -418,10 +422,12 @@ export function createUiPageRenderer({
|
|
|
418
422
|
streamed and the client cold-runs it. An unserializable value emits nothing.
|
|
419
423
|
Read from `store` directly since the stream body runs outside the request ALS. */
|
|
420
424
|
for (const { key, value } of store.streamedCells.entries) {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
+
const encoded = encodeSeedValue(
|
|
426
|
+
value,
|
|
427
|
+
`streaming cell "${key}" settled value`,
|
|
428
|
+
'the client cold-runs its seed instead of adopting the streamed value',
|
|
429
|
+
)
|
|
430
|
+
if (encoded === undefined) {
|
|
425
431
|
continue
|
|
426
432
|
}
|
|
427
433
|
controller.enqueue(
|
|
@@ -436,10 +442,12 @@ export function createUiPageRenderer({
|
|
|
436
442
|
if (seededCellKeys.has(key)) {
|
|
437
443
|
continue
|
|
438
444
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
445
|
+
const encoded = encodeSeedValue(
|
|
446
|
+
value,
|
|
447
|
+
`streamed child's async cell "${key}" resolved value`,
|
|
448
|
+
'it will re-run on the client instead of hydrating warm',
|
|
449
|
+
)
|
|
450
|
+
if (encoded === undefined) {
|
|
443
451
|
continue
|
|
444
452
|
}
|
|
445
453
|
controller.enqueue(
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The ONE hydration seed manifest (ADR-0048): every passive server→client warm-seed
|
|
3
|
+
partition — await resumes, async-cell values, doc snapshots, socket frames — rides a single
|
|
4
|
+
`globalThis.__abideSeeds` global, kind-partitioned. Each partition is a Record of ref-json
|
|
5
|
+
STRINGS decoded lazily at its read site, so the inline pre-bundle scripts (the SSR swap
|
|
6
|
+
script, the resume seed script) can write entries without the codec. The per-kind module
|
|
7
|
+
names (`RESUME`, `CELL_SEED`, `DOC_SEED`, `SOCKET_SEED`) are views onto these partitions,
|
|
8
|
+
so consumers keep their domain name while the wire carries one global.
|
|
9
|
+
|
|
10
|
+
Each partition initializes with its own `??=` — NOT one whole-object create — because the
|
|
11
|
+
vanilla scripts and the bundle race in either order and a script that ran first may have
|
|
12
|
+
created the root with only its own partition; whoever arrives later must fill the gaps and
|
|
13
|
+
adopt the same references. The live streamed-cell machinery (`STREAMED_CELLS`) and the head
|
|
14
|
+
collector buffer (`__abideResumeCache`) deliberately stay separate: they are apply-timing
|
|
15
|
+
phases (ADR-0040), not passive manifests.
|
|
16
|
+
*/
|
|
17
|
+
type SeedPartitions = {
|
|
18
|
+
resume: Record<string, string>
|
|
19
|
+
cells: Record<string, string>
|
|
20
|
+
docs: Record<string, string>
|
|
21
|
+
sockets: Record<string, string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const globalScope = globalThis as { __abideSeeds?: Partial<SeedPartitions> }
|
|
25
|
+
globalScope.__abideSeeds ??= {}
|
|
26
|
+
const root = globalScope.__abideSeeds
|
|
27
|
+
root.resume ??= {}
|
|
28
|
+
root.cells ??= {}
|
|
29
|
+
root.docs ??= {}
|
|
30
|
+
root.sockets ??= {}
|
|
31
|
+
|
|
32
|
+
// @documentation plumbing
|
|
33
|
+
export const SEEDS: SeedPartitions = root as SeedPartitions
|
|
@@ -1,13 +1,12 @@
|
|
|
1
|
+
import { SEEDS } from './SEEDS.ts'
|
|
2
|
+
|
|
1
3
|
/* The socket warm-seed manifest: a socket's retained latest frame as read via `peek(socket)` during
|
|
2
4
|
SSR, keyed by the socket's name, as a ref-json-encoded STRING (decoded at the read site in
|
|
3
5
|
`socketProxy`). `startClient` drains `__SSR__.sockets` into here before mount; a hydrating
|
|
4
6
|
`socketProxy` reads its key to seed the socket's `lastFrame` WARM, so `peek(socket)` returns the
|
|
5
7
|
SAME retained value the server rendered instead of `undefined` on the not-yet-connected client —
|
|
6
8
|
killing the SSR/hydration divergence that would otherwise discard the server markup and cold-render.
|
|
7
|
-
Backed by `globalThis.__abideSockets` so an inline pre-bundle script and the framework share one
|
|
8
|
-
store — whoever runs first creates it, the other adopts the same reference (mirrors CELL_SEED). */
|
|
9
|
-
const globalScope = globalThis as { __abideSockets?: Record<string, string> }
|
|
10
|
-
globalScope.__abideSockets ??= {}
|
|
11
9
|
|
|
10
|
+
The `sockets` partition of the one `__abideSeeds` manifest (ADR-0048, see SEEDS). */
|
|
12
11
|
// @documentation plumbing
|
|
13
|
-
export const SOCKET_SEED: Record<string, string> =
|
|
12
|
+
export const SOCKET_SEED: Record<string, string> = SEEDS.sockets
|
package/src/lib/shared/cache.ts
CHANGED
|
@@ -176,10 +176,16 @@ its value is kept for display regardless of ttl, and ttl marks a staleness
|
|
|
176
176
|
deadline (the next read past it revalidates in the background) instead of driving
|
|
177
177
|
eviction. No call-site options (ADR-0020): all policy is read off the endpoint
|
|
178
178
|
(fn.cache). Always a decoded RemoteFunction read (the callable carries .raw), so
|
|
179
|
-
it returns Promise<Return>.
|
|
179
|
+
it returns Promise<Return>. `key` is the caller's already-derived cache key (the
|
|
180
|
+
callable keys its rpc-error registry with it) — threaded through so readThrough
|
|
181
|
+
reuses it instead of re-deriving the identical string; omitted, readThrough derives.
|
|
180
182
|
*/
|
|
181
|
-
function smartRead<Args, Return>(
|
|
182
|
-
|
|
183
|
+
function smartRead<Args, Return>(
|
|
184
|
+
fn: RemoteFunction<Args, Return>,
|
|
185
|
+
args?: Args,
|
|
186
|
+
key?: string,
|
|
187
|
+
): Promise<Return> {
|
|
188
|
+
return readThrough(fn, args, undefined, true, key) as Promise<Return>
|
|
183
189
|
}
|
|
184
190
|
cache.read = smartRead
|
|
185
191
|
|
|
@@ -196,6 +202,7 @@ function readThrough<Args, Return>(
|
|
|
196
202
|
args: Args | undefined,
|
|
197
203
|
options: CacheOptions | undefined,
|
|
198
204
|
smart: boolean,
|
|
205
|
+
precomputedKey?: string,
|
|
199
206
|
): Promise<Return | Response> {
|
|
200
207
|
/*
|
|
201
208
|
A remote function carries the REMOTE_FUNCTION brand (set by
|
|
@@ -239,13 +246,17 @@ function readThrough<Args, Return>(
|
|
|
239
246
|
- a read that resolved to the process store WITHOUT `shared` — a server read outside any
|
|
240
247
|
request scope, where activeCacheStore() falls back to the shared store. Nothing bounds
|
|
241
248
|
its lifetime there, so retaining it would leak forever.
|
|
242
|
-
Both apply on either side and however the call is made.
|
|
249
|
+
Both apply on either side and however the call is made. An explicit `shared` defeats both:
|
|
250
|
+
it is a deliberate "memoise this across requests" opt-in (a POST that is a pure function
|
|
251
|
+
of its args, using the body only to carry a large payload — highlightCode), so it retains
|
|
252
|
+
in the process store with the default Infinity ttl rather than dropping on settle. An
|
|
253
|
+
explicit `ttl` already defeats them via the `ttl === undefined` guard above.
|
|
243
254
|
*/
|
|
244
255
|
const coalesceOnly =
|
|
245
256
|
isRemote &&
|
|
246
257
|
policy?.ttl === undefined &&
|
|
247
|
-
|
|
248
|
-
|
|
258
|
+
!policy?.shared &&
|
|
259
|
+
(!replayable || (typeof window === 'undefined' && store === sharedCacheStore()))
|
|
249
260
|
const effectiveOptions = coalesceOnly ? { ...policy, ttl: 0 } : policy
|
|
250
261
|
if (!isRemote) {
|
|
251
262
|
warnAnonymousProducer(fn as Producer<Args, Return>)
|
|
@@ -254,7 +265,12 @@ function readThrough<Args, Return>(
|
|
|
254
265
|
return invokeProducer(store, fn as Producer<Args, Return>, args, effectiveOptions)
|
|
255
266
|
}
|
|
256
267
|
const remote = rawFn as RawRemoteFunction<Args>
|
|
257
|
-
|
|
268
|
+
/* Reuse the caller's derived key when it threaded one (the smart bare call already
|
|
269
|
+
computes it for its rpc-error registry, and remote is that callable's .raw — same
|
|
270
|
+
method/url, so the string is identical). Otherwise derive it — the public cache()
|
|
271
|
+
and the raw path pass none. Saves a query-encode / canonical-json stringify per read,
|
|
272
|
+
warm hits included. */
|
|
273
|
+
const key = precomputedKey ?? keyForRemoteCall(remote.method, remote.url, args)
|
|
258
274
|
if (
|
|
259
275
|
!effectiveOptions?.shared &&
|
|
260
276
|
effectiveOptions?.ttl !== undefined &&
|
|
@@ -756,22 +772,100 @@ rather than a first-ever load. An empty or unmatched selector is a no-op on the
|
|
|
756
772
|
cache; the lifecycle ping still fires but recomputes pending() to the same value.
|
|
757
773
|
*/
|
|
758
774
|
function invalidate<Args, Return>(arg?: CacheSelector<Args, Return>, args?: Args): void {
|
|
759
|
-
/* Resolve the fn-selector prefix once; the matcher
|
|
775
|
+
/* Resolve the fn-selector prefix once; the matcher, the label, and the exact-key
|
|
776
|
+
fast path all consume it. */
|
|
760
777
|
const prefix = selectorPrefix(arg, args)
|
|
761
|
-
invalidateMatching(
|
|
778
|
+
invalidateMatching(
|
|
779
|
+
selectorMatcher(arg, args, prefix),
|
|
780
|
+
selectorLabel(arg, args, prefix),
|
|
781
|
+
prefix,
|
|
782
|
+
exactSelectorKey(arg, args, prefix),
|
|
783
|
+
)
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/*
|
|
787
|
+
The single entry key a `fn + args` selector targets: args narrows the match to one
|
|
788
|
+
call, so the resolved prefix IS the exact key — the apply loops can `Map.get` it
|
|
789
|
+
directly instead of scanning every store. undefined for an all / bare-fn-prefix /
|
|
790
|
+
tag selector (which may match many entries) so those still scan, and for a producer
|
|
791
|
+
never cached (prefix undefined) whose scan matches nothing anyway.
|
|
792
|
+
*/
|
|
793
|
+
function exactSelectorKey<Args, Return>(
|
|
794
|
+
arg: CacheSelector<Args, Return> | undefined,
|
|
795
|
+
args: Args | undefined,
|
|
796
|
+
prefix: string | undefined,
|
|
797
|
+
): string | undefined {
|
|
798
|
+
return typeof arg === 'function' && args !== undefined ? prefix : undefined
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/*
|
|
802
|
+
The ONE per-store selection dispatch every selector-driven verb runs through: an exact
|
|
803
|
+
key (a fn + args selector, from exactSelectorKey) picks its single candidate via a
|
|
804
|
+
direct Map.get instead of scanning; any other selector scans the store against
|
|
805
|
+
`matches`. Shared so the fast path and the scan can't diverge across
|
|
806
|
+
invalidate / refresh / amend (ADR-0041).
|
|
807
|
+
*/
|
|
808
|
+
function applyToMatching(
|
|
809
|
+
store: CacheStore,
|
|
810
|
+
exactKey: string | undefined,
|
|
811
|
+
matches: (entry: CacheEntry) => boolean,
|
|
812
|
+
apply: (entry: CacheEntry) => void,
|
|
813
|
+
): void {
|
|
814
|
+
if (exactKey !== undefined) {
|
|
815
|
+
const entry = store.entries.get(exactKey)
|
|
816
|
+
if (entry !== undefined) {
|
|
817
|
+
apply(entry)
|
|
818
|
+
}
|
|
819
|
+
return
|
|
820
|
+
}
|
|
821
|
+
/* Deleting the current entry mid-iteration is spec-safe on a Map; no snapshot needed. */
|
|
822
|
+
for (const entry of store.entries.values()) {
|
|
823
|
+
if (matches(entry)) {
|
|
824
|
+
apply(entry)
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/*
|
|
830
|
+
The per-entry drop/refetch decision of invalidate() — the single seam so the full
|
|
831
|
+
scan and the exact-key fast path can't diverge (ADR-0041). A refetch-policy entry is
|
|
832
|
+
kept and its refetch coalesced; every other match is dropped so the next read reloads.
|
|
833
|
+
*/
|
|
834
|
+
function invalidateEntry(
|
|
835
|
+
store: CacheStore,
|
|
836
|
+
entry: CacheEntry,
|
|
837
|
+
matched: string[],
|
|
838
|
+
affected: string[],
|
|
839
|
+
): void {
|
|
840
|
+
matched.push(entry.key)
|
|
841
|
+
if (entry.invalidation) {
|
|
842
|
+
scheduleInvalidationRefetch(store, entry)
|
|
843
|
+
} else {
|
|
844
|
+
store.entries.delete(entry.key)
|
|
845
|
+
/* Flag the next read a reload (refreshing()) — but only if a reader is
|
|
846
|
+
holding the value now; with none on screen the next read is a first
|
|
847
|
+
load, and an ungated add would linger forever on the tab store. */
|
|
848
|
+
if (store.hasReader(entry.key)) {
|
|
849
|
+
store.pendingRefresh.add(entry.key)
|
|
850
|
+
}
|
|
851
|
+
affected.push(entry.key)
|
|
852
|
+
}
|
|
762
853
|
}
|
|
763
854
|
|
|
764
855
|
/*
|
|
765
856
|
The store-loop body of invalidate(), driven by a raw entry predicate rather than a
|
|
766
|
-
selector —
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
857
|
+
selector — a local invalidate() and a wire-driven one (a server broadcast decoded via
|
|
858
|
+
matcherFromEnvelope) share the per-entry `invalidateEntry` seam and can't diverge
|
|
859
|
+
(ADR-0041). `label` feeds the cycle tripwire; `prefix` (undefined for a tag selector)
|
|
860
|
+
resets recorded rpc errors for the selector. `exactKey` (a fn + args selector's one
|
|
861
|
+
key, from exactSelectorKey) picks that entry directly instead of scanning; the
|
|
862
|
+
wire-driven path passes none and scans as before.
|
|
770
863
|
*/
|
|
771
864
|
function invalidateMatching(
|
|
772
865
|
matches: (entry: CacheEntry) => boolean,
|
|
773
866
|
label: string,
|
|
774
867
|
prefix: string | undefined,
|
|
868
|
+
exactKey?: string,
|
|
775
869
|
): void {
|
|
776
870
|
invalidateTripwire(label)
|
|
777
871
|
/* Reset any recorded rpc errors for this selector too (independent of cache entries — a
|
|
@@ -782,25 +876,9 @@ function invalidateMatching(
|
|
|
782
876
|
for (const store of cacheStores()) {
|
|
783
877
|
const matched: string[] = []
|
|
784
878
|
const affected: string[] = []
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
continue
|
|
789
|
-
}
|
|
790
|
-
matched.push(entry.key)
|
|
791
|
-
if (entry.invalidation) {
|
|
792
|
-
scheduleInvalidationRefetch(store, entry)
|
|
793
|
-
} else {
|
|
794
|
-
store.entries.delete(entry.key)
|
|
795
|
-
/* Flag the next read a reload (refreshing()) — but only if a reader is
|
|
796
|
-
holding the value now; with none on screen the next read is a first
|
|
797
|
-
load, and an ungated add would linger forever on the tab store. */
|
|
798
|
-
if (store.hasReader(entry.key)) {
|
|
799
|
-
store.pendingRefresh.add(entry.key)
|
|
800
|
-
}
|
|
801
|
-
affected.push(entry.key)
|
|
802
|
-
}
|
|
803
|
-
}
|
|
879
|
+
applyToMatching(store, exactKey, matches, (entry) =>
|
|
880
|
+
invalidateEntry(store, entry, matched, affected),
|
|
881
|
+
)
|
|
804
882
|
/* Every match changed state (probes re-derive); only the dropped subset
|
|
805
883
|
changed its visible value (readers re-read) — swr matches stay put. */
|
|
806
884
|
notify(store, matched, affected)
|
|
@@ -851,18 +929,65 @@ way to re-run, so it drops (the next read reloads) — the invalidate fallback.
|
|
|
851
929
|
*/
|
|
852
930
|
function refresh<Args, Return>(arg?: CacheSelector<Args, Return>, args?: Args): void {
|
|
853
931
|
const prefix = selectorPrefix(arg, args)
|
|
854
|
-
refreshMatching(
|
|
932
|
+
refreshMatching(
|
|
933
|
+
selectorMatcher(arg, args, prefix),
|
|
934
|
+
selectorLabel(arg, args, prefix),
|
|
935
|
+
prefix,
|
|
936
|
+
exactSelectorKey(arg, args, prefix),
|
|
937
|
+
)
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/*
|
|
941
|
+
The per-entry refetch/drop decision of refresh() — the single seam shared by the full
|
|
942
|
+
scan and the exact-key fast path (ADR-0041). Only a match with a live reader refetches;
|
|
943
|
+
a reader-less match drops so the next read reloads.
|
|
944
|
+
*/
|
|
945
|
+
function refreshEntry(
|
|
946
|
+
store: CacheStore,
|
|
947
|
+
entry: CacheEntry,
|
|
948
|
+
matched: string[],
|
|
949
|
+
affected: string[],
|
|
950
|
+
): void {
|
|
951
|
+
matched.push(entry.key)
|
|
952
|
+
/* No live reader is holding this key — its reactive scope has torn down (or
|
|
953
|
+
it was never read reactively and already dropped). A refresh has no
|
|
954
|
+
on-screen value to keep-stale-and-swap, so firing the network now is
|
|
955
|
+
wasted work that may be stale again before anything shows it. Drop the
|
|
956
|
+
entry so the next read reloads fresh instead — the same lazy path a
|
|
957
|
+
reader-less invalidate() takes. */
|
|
958
|
+
if (!store.hasReader(entry.key)) {
|
|
959
|
+
store.entries.delete(entry.key)
|
|
960
|
+
return
|
|
961
|
+
}
|
|
962
|
+
/* Arm a refetch on the fly for a policy-less remote entry by replaying its
|
|
963
|
+
stored Request — so a refresh always refetches-and-swaps, never blanks. */
|
|
964
|
+
if (entry.invalidation === undefined && entry.request !== undefined) {
|
|
965
|
+
const request = entry.request
|
|
966
|
+
entry.invalidation = { refetch: () => fetch(request.clone()) }
|
|
967
|
+
}
|
|
968
|
+
if (entry.invalidation !== undefined) {
|
|
969
|
+
scheduleInvalidationRefetch(store, entry)
|
|
970
|
+
} else {
|
|
971
|
+
/* No policy, no request (a producer never cached with a refetch window): drop so the
|
|
972
|
+
next read reloads. A reader is holding it (checked above), so flag the
|
|
973
|
+
next read a reload (refreshing()). */
|
|
974
|
+
store.entries.delete(entry.key)
|
|
975
|
+
store.pendingRefresh.add(entry.key)
|
|
976
|
+
affected.push(entry.key)
|
|
977
|
+
}
|
|
855
978
|
}
|
|
856
979
|
|
|
857
980
|
/*
|
|
858
981
|
The store-loop body of refresh(), driven by a raw entry predicate — the refetch
|
|
859
|
-
analogue of invalidateMatching, so a local refresh() and a wire-driven one share
|
|
860
|
-
|
|
982
|
+
analogue of invalidateMatching, so a local refresh() and a wire-driven one share the
|
|
983
|
+
per-entry `refreshEntry` seam (ADR-0041). `exactKey` (a fn + args selector's one key)
|
|
984
|
+
picks that entry directly instead of scanning; the wire-driven path passes none.
|
|
861
985
|
*/
|
|
862
986
|
function refreshMatching(
|
|
863
987
|
matches: (entry: CacheEntry) => boolean,
|
|
864
988
|
label: string,
|
|
865
989
|
prefix: string | undefined,
|
|
990
|
+
exactKey?: string,
|
|
866
991
|
): void {
|
|
867
992
|
invalidateTripwire(label)
|
|
868
993
|
if (prefix !== undefined) {
|
|
@@ -871,38 +996,9 @@ function refreshMatching(
|
|
|
871
996
|
for (const store of cacheStores()) {
|
|
872
997
|
const matched: string[] = []
|
|
873
998
|
const affected: string[] = []
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
}
|
|
878
|
-
matched.push(entry.key)
|
|
879
|
-
/* No live reader is holding this key — its reactive scope has torn down (or
|
|
880
|
-
it was never read reactively and already dropped). A refresh has no
|
|
881
|
-
on-screen value to keep-stale-and-swap, so firing the network now is
|
|
882
|
-
wasted work that may be stale again before anything shows it. Drop the
|
|
883
|
-
entry so the next read reloads fresh instead — the same lazy path a
|
|
884
|
-
reader-less invalidate() takes. */
|
|
885
|
-
if (!store.hasReader(entry.key)) {
|
|
886
|
-
store.entries.delete(entry.key)
|
|
887
|
-
continue
|
|
888
|
-
}
|
|
889
|
-
/* Arm a refetch on the fly for a policy-less remote entry by replaying its
|
|
890
|
-
stored Request — so a refresh always refetches-and-swaps, never blanks. */
|
|
891
|
-
if (entry.invalidation === undefined && entry.request !== undefined) {
|
|
892
|
-
const request = entry.request
|
|
893
|
-
entry.invalidation = { refetch: () => fetch(request.clone()) }
|
|
894
|
-
}
|
|
895
|
-
if (entry.invalidation !== undefined) {
|
|
896
|
-
scheduleInvalidationRefetch(store, entry)
|
|
897
|
-
} else {
|
|
898
|
-
/* No policy, no request (a producer never cached with a refetch window): drop so the
|
|
899
|
-
next read reloads. A reader is holding it (checked above), so flag the
|
|
900
|
-
next read a reload (refreshing()). */
|
|
901
|
-
store.entries.delete(entry.key)
|
|
902
|
-
store.pendingRefresh.add(entry.key)
|
|
903
|
-
affected.push(entry.key)
|
|
904
|
-
}
|
|
905
|
-
}
|
|
999
|
+
applyToMatching(store, exactKey, matches, (entry) =>
|
|
1000
|
+
refreshEntry(store, entry, matched, affected),
|
|
1001
|
+
)
|
|
906
1002
|
/* Mark the whole match set (probes re-derive); emit only the dropped subset — the
|
|
907
1003
|
refetched entries emit when their fresh value lands (fireRefetch). */
|
|
908
1004
|
notify(store, matched, affected)
|
|
@@ -930,13 +1026,14 @@ function amend<Args, Return>(
|
|
|
930
1026
|
isValue: boolean,
|
|
931
1027
|
): void {
|
|
932
1028
|
const prefix = selectorPrefix(arg, args)
|
|
1029
|
+
const exactKey = exactSelectorKey(arg, args, prefix)
|
|
933
1030
|
const matches = selectorMatcher(arg, args, prefix)
|
|
1031
|
+
/* The exact-key arm is the optimistic-update / socket-frame path, potentially once
|
|
1032
|
+
per frame — a direct Map.get through the shared dispatch, no scan. */
|
|
934
1033
|
for (const store of cacheStores()) {
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
}
|
|
939
|
-
}
|
|
1034
|
+
applyToMatching(store, exactKey, matches, (entry) =>
|
|
1035
|
+
applyAmend(store, entry, updater as (current: unknown) => unknown, isValue),
|
|
1036
|
+
)
|
|
940
1037
|
}
|
|
941
1038
|
}
|
|
942
1039
|
|
|
@@ -1378,8 +1475,5 @@ function notify(store: CacheStore, marked: string[], emitted: string[]): void {
|
|
|
1378
1475
|
}
|
|
1379
1476
|
|
|
1380
1477
|
function emit(store: CacheStore, keys: string[]): void {
|
|
1381
|
-
if (keys.length === 0) {
|
|
1382
|
-
return
|
|
1383
|
-
}
|
|
1384
1478
|
store.events.dispatchEvent(invalidateEvent(keys))
|
|
1385
1479
|
}
|
|
@@ -142,7 +142,10 @@ export function createRemoteFunction<Args, Return>(opts: {
|
|
|
142
142
|
`raw`. There is no call-site options argument any more — all cache policy is read
|
|
143
143
|
from the endpoint (`callable.cache`); per-call transport options live on `.raw`. */
|
|
144
144
|
const key = keyForRemoteCall(method, url, args)
|
|
145
|
-
|
|
145
|
+
/* Thread the derived key into the smart read so readThrough reuses it rather than
|
|
146
|
+
re-deriving the identical string (callable.raw carries the same method/url); the
|
|
147
|
+
key already tags the rpc-error registry entries in the handlers below. */
|
|
148
|
+
return cache.read(callable as RemoteFunction<Args, Return>, args as Args, key).then(
|
|
146
149
|
/* Capture the rejection into the rpc error registry (design Part 4) keyed by call
|
|
147
150
|
identity, and clear it on success — the reactive `fn.error()` probe reads it. */
|
|
148
151
|
(value) => {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { encodeRefJson } from './encodeRefJson.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The ONE degradation site for every server→client hydration seed (ADR-0048): ref-json-encode
|
|
5
|
+
a value bound for a warm-seed channel (await resume, cell value, doc snapshot, socket frame,
|
|
6
|
+
streamed-cell chunk), or drop JUST that entry with a warn naming what was dropped and what
|
|
7
|
+
the client does instead. encodeRefJson is total for ordinary graphs (cycles become
|
|
8
|
+
back-references, functions fold to undefined), so a throw is pathological — but it must
|
|
9
|
+
never blank the surrounding payload/stream. Funnelling every channel through here keeps the
|
|
10
|
+
serialize-or-degrade policy, the warn shape, and the per-kind consequence in one place
|
|
11
|
+
instead of seven independently-worded (or silent) drop sites.
|
|
12
|
+
*/
|
|
13
|
+
export function encodeSeedValue(
|
|
14
|
+
value: unknown,
|
|
15
|
+
label: string,
|
|
16
|
+
consequence: string,
|
|
17
|
+
): string | undefined {
|
|
18
|
+
try {
|
|
19
|
+
return encodeRefJson(value)
|
|
20
|
+
} catch (cause) {
|
|
21
|
+
console.warn(`[abide] ${label} is not serializable — ${consequence}.`, cause)
|
|
22
|
+
return undefined
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -85,7 +85,7 @@ function project(
|
|
|
85
85
|
// A `Set`/`Map` rides the wire as a JSON array (ADR-0029 output encode), so the projected schema
|
|
86
86
|
// MUST agree with those bytes — a `Set<T>` is `T[]`, a `Map<K,V>` is `[K,V][]` entries.
|
|
87
87
|
if (symbolName === 'Set' || symbolName === 'ReadonlySet') {
|
|
88
|
-
return
|
|
88
|
+
return projectArray(checker, type, seen)
|
|
89
89
|
}
|
|
90
90
|
if (symbolName === 'Map' || symbolName === 'ReadonlyMap') {
|
|
91
91
|
return projectMap(checker, type, seen)
|
|
@@ -131,7 +131,8 @@ function projectUnion(
|
|
|
131
131
|
return { anyOf }
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
/* `T[]`/`readonly T[]` → `{ type: 'array', items: project(T) }`.
|
|
134
|
+
/* `T[]`/`readonly T[]` → `{ type: 'array', items: project(T) }`. Also serves `Set<T>`, which
|
|
135
|
+
rides the wire as `[...set]` (ADR-0029) — the same `T[]` shape. */
|
|
135
136
|
function projectArray(
|
|
136
137
|
checker: ts.TypeChecker,
|
|
137
138
|
type: ts.Type,
|
|
@@ -144,20 +145,6 @@ function projectArray(
|
|
|
144
145
|
return { type: 'array', items }
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
/* `Set<T>`/`readonly Set<T>` → `{ type: 'array', items: project(T) }` — congruent with the
|
|
148
|
-
`wireJsonReplacer` encode (`[...set]`). */
|
|
149
|
-
function projectSet(
|
|
150
|
-
checker: ts.TypeChecker,
|
|
151
|
-
type: ts.Type,
|
|
152
|
-
seen: Set<ts.Type>,
|
|
153
|
-
): Record<string, unknown> {
|
|
154
|
-
const element = checker.getTypeArguments(type as ts.TypeReference)[0]
|
|
155
|
-
seen.add(type)
|
|
156
|
-
const items = element === undefined ? {} : project(checker, element, seen)
|
|
157
|
-
seen.delete(type)
|
|
158
|
-
return { type: 'array', items }
|
|
159
|
-
}
|
|
160
|
-
|
|
161
148
|
/* `Map<K,V>`/`readonly Map<K,V>` → an array of `[K,V]` entry tuples — congruent with the
|
|
162
149
|
`wireJsonReplacer` encode (`[...map]`). Each entry is a fixed 2-tuple, so the item schema is
|
|
163
150
|
positional `prefixItems` with `items: false` to forbid extras (matching projectTuple). */
|
package/src/lib/shared/peek.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { cache } from './cache.ts'
|
|
2
2
|
import { isAsyncCell } from './isAsyncCell.ts'
|
|
3
|
+
import { isAsyncIterable } from './isAsyncIterable.ts'
|
|
3
4
|
import type { AsyncComputed } from './types/AsyncComputed.ts'
|
|
4
5
|
import type { RemoteFunction } from './types/RemoteFunction.ts'
|
|
5
6
|
import type { Socket } from './types/Socket.ts'
|
|
@@ -38,12 +39,8 @@ export function peek(source: unknown, args?: unknown): unknown {
|
|
|
38
39
|
/* A subscribable (socket/stream) carries its own latest-frame probe; an rpc does not
|
|
39
40
|
have Symbol.asyncIterator, so this cleanly splits the two even though both expose a
|
|
40
41
|
`.peek` method. */
|
|
41
|
-
if (
|
|
42
|
-
source
|
|
43
|
-
typeof source === 'object' &&
|
|
44
|
-
typeof (source as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function'
|
|
45
|
-
) {
|
|
46
|
-
return (source as { peek: () => unknown }).peek()
|
|
42
|
+
if (isAsyncIterable(source)) {
|
|
43
|
+
return (source as unknown as { peek: () => unknown }).peek()
|
|
47
44
|
}
|
|
48
45
|
return cache.peek(source as RemoteFunction<unknown, unknown>, args as never)
|
|
49
46
|
}
|
|
@@ -57,7 +57,7 @@ export function createAmendReaderHook(
|
|
|
57
57
|
/* Transport loss: reconcile the possibly-missed value against server truth,
|
|
58
58
|
then re-open a fresh LIVE subscription (no replay) and keep going. */
|
|
59
59
|
if (error instanceof SocketDisconnectedError) {
|
|
60
|
-
cache.refreshMatching((entry) => entry.key === key, key, undefined)
|
|
60
|
+
cache.refreshMatching((entry) => entry.key === key, key, undefined, key)
|
|
61
61
|
try {
|
|
62
62
|
iterator = socket[Symbol.asyncIterator]()
|
|
63
63
|
} catch (reopenError) {
|
|
@@ -38,16 +38,12 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string; alias?: stri
|
|
|
38
38
|
{ name: 'eachAsync', specifier: 'ui/dom/eachAsync', alias: '$$eachAsync' },
|
|
39
39
|
{ name: 'when', specifier: 'ui/dom/when', alias: '$$when' },
|
|
40
40
|
{ name: 'awaitBlock', specifier: 'ui/dom/awaitBlock', alias: '$$awaitBlock' },
|
|
41
|
+
{ name: 'awaitSubject', specifier: 'ui/dom/awaitSubject', alias: '$$awaitSubject' },
|
|
41
42
|
{ name: 'tryBlock', specifier: 'ui/dom/tryBlock', alias: '$$tryBlock' },
|
|
42
43
|
{ name: 'switchBlock', specifier: 'ui/dom/switchBlock', alias: '$$switchBlock' },
|
|
43
44
|
{ name: 'mountSlot', specifier: 'ui/dom/mountSlot', alias: '$$mountSlot' },
|
|
44
45
|
{ name: 'outlet', specifier: 'ui/dom/outlet', alias: '$$outlet' },
|
|
45
46
|
{ name: 'mountChild', specifier: 'ui/dom/mountChild', alias: '$$mountChild' },
|
|
46
|
-
{
|
|
47
|
-
name: 'mountStreamedChild',
|
|
48
|
-
specifier: 'ui/dom/mountStreamedChild',
|
|
49
|
-
alias: '$$mountStreamedChild',
|
|
50
|
-
},
|
|
51
47
|
{ name: 'mergeProps', specifier: 'ui/dom/mergeProps', alias: '$$mergeProps' },
|
|
52
48
|
{ name: 'spreadProps', specifier: 'ui/dom/spreadProps', alias: '$$spreadProps' },
|
|
53
49
|
{ name: 'restProps', specifier: 'ui/dom/restProps', alias: '$$restProps' },
|
|
@@ -56,7 +52,6 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string; alias?: stri
|
|
|
56
52
|
{ name: 'spreadAttrs', specifier: 'ui/dom/spreadAttrs', alias: '$$spreadAttrs' },
|
|
57
53
|
{ name: 'readCall', specifier: 'ui/dom/readCall', alias: '$$readCall' },
|
|
58
54
|
{ name: 'readCell', specifier: 'ui/dom/readCell', alias: '$$readCell' },
|
|
59
|
-
{ name: 'readCellBlocking', specifier: 'ui/dom/readCellBlocking', alias: '$$readCellBlocking' },
|
|
60
55
|
{ name: 'writeCell', specifier: 'ui/dom/writeCell', alias: '$$writeCell' },
|
|
61
56
|
{ name: 'cellPending', specifier: 'ui/dom/cellPending', alias: '$$cellPending' },
|
|
62
57
|
{ name: 'withPath', specifier: 'ui/runtime/withPath', alias: '$$withPath' },
|