@ultimat3/render 22.7.1 → 22.8.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/CLAUDE.md +6 -2
- package/package.json +10 -8
- package/src/errors.ts +18 -0
- package/src/hydrate.ts +5 -1
- package/src/index.ts +49 -1
- package/src/modes.ts +5 -5
- package/src/navigation-cache.ts +93 -0
- package/src/navigation-dom-fixture.ts +418 -0
- package/src/navigation-dom.ts +146 -0
- package/src/navigation-entry.ts +7 -0
- package/src/navigation-fetch.ts +77 -0
- package/src/navigation-rules.ts +306 -0
- package/src/navigation-swap.ts +297 -0
- package/src/navigation-tags.ts +38 -0
- package/src/navigation.ts +480 -0
- package/src/route.ts +21 -1
package/CLAUDE.md
CHANGED
|
@@ -25,7 +25,7 @@ axiom 6). Never `cli` (upward).
|
|
|
25
25
|
|---|---|
|
|
26
26
|
| `offline`, `meta` | required by `RouteDefinition`. Never make them optional. |
|
|
27
27
|
| `hydrate` | optional, **derived from `island()`** — `'interaction'` when the module declared one, `'never'` when not. Declaring it still wins and is the only way to reach `idle` / `visible`. Never give an island its own strategy: `RouteDescriptor.hydrate` is read by `sw.js`, the web manifest and `x routes`. |
|
|
28
|
-
| `defineRoute` shape | exactly the contract's
|
|
28
|
+
| `defineRoute` shape | exactly the contract's 12 keys (`cache`, `ssr` only, is the tenth; `post`, an action's export name bound to `POST` at the page URL, the eleventh — never on `static`, `X_ROUTE_POST_INVALID`; the CLI mounts it and refuses an unregistered name at boot; `navigation`, `'prefetch' \| 'document'`, the twelfth — read by the server's navigation gate, refused off a `navigation.client` surface at boot, `X_ROUTE_NAVIGATION_INVALID`). New route *metadata* goes inside `meta`. |
|
|
29
29
|
| `load` | optional, and the ONE server-side data seam. Resolved once per render by `routeDataFor()` and handed to **both** `meta` and the page component. Absent `load`, the context IS the data (`{ params, url }`). |
|
|
30
30
|
| `load` is required when the context cannot supply the data | `LoadRequirement<TData>` in `defineRoute`'s parameter. `RouteContext` is a type ALIAS on purpose — only an alias carries the implicit index signature that makes it a `RouteData`. |
|
|
31
31
|
| A loader's own error | rethrown only when `isUltimateError` says so (core's brand), never a `code` property and never `instanceof UltimateError` — a tier-0 error is branded, not a subclass. Everything else is `X_ROUTE_LOAD_FAILED`. |
|
|
@@ -54,7 +54,7 @@ axiom 6). Never `cli` (upward).
|
|
|
54
54
|
| The hydration runtime's CSP | `HYDRATE_RUNTIME_BODIES` — every body `hydrateRuntime` can emit (seven subsets). It is inline in every document with an island and `@ultimat3/http`'s `script-src` is `'self' 'wasm-unsafe-eval'`, so `@ultimat3/cli`'s `script-csp.ts` hashes this list at boot. `runtimeBody` is the one place the served and hashed text are one string. **Uncovered**: `render-stream.ts`'s per-hole `<script>$X("id")</script>` (unreachable today; the first real hole needs a nonce). |
|
|
55
55
|
| Island markup | the props `<script>` is emitted INSIDE the wrapper by `render-html.ts`; an assembler only adds `hydrateRuntime(directives)`. |
|
|
56
56
|
| Island boot | `el.__x` holds the boot PROMISE, never a boolean. |
|
|
57
|
-
| Island mount markers | `data-x-mounted=""` when `mount()` resolved, `data-x-failed="<message>"` when it rejected — set by the runtime, never by `emitIslandAttributes`. The rejection handler RETHROWS. Prelude sizes `As of 2026-09-
|
|
57
|
+
| Island mount markers | `data-x-mounted=""` when `mount()` resolved, `data-x-failed="<message>"` when it rejected — set by the runtime, never by `emitIslandAttributes`. The rejection handler RETHROWS. Prelude sizes `As of 2026-09-28`: `idle` 1,784, `visible` 886, `interaction` 1,669 B (what `DEFAULT_ISLAND_JS_BYTES` derives from). |
|
|
58
58
|
| A runtime that calls `boot` | TERMINATES the chain: `idle`/`visible` end in `.catch(hush)`, `interaction` passes `off` as the rejection arm. `hydrate-runtime.test.ts` reds on an unhandled rejection. |
|
|
59
59
|
| `idle` replays too | `idle` and `interaction` share ONE `catchUp(el)` in `hydrate.ts` (capture listeners for `data-x-events`, default `click`; a queue; `aim`; one flush). A caught event wakes an `idle` island early; each event replays once. `hydrate-replay.test.ts`. |
|
|
60
60
|
| Where `interaction` replays | `aim(el, ev)`, never `ev.target` — `mount` usually clears the root, detaching the pressed node. Kept → original target; replaced → `path(el, target)`'s structural walk (same tag, same place), then `document.elementFromPoint(ev.clientX, ev.clientY)` (unless `ev.detail === 0`), then the island root. A hit outside the island falls back to the root. `hydrate-replay.test.ts`. |
|
|
@@ -100,6 +100,10 @@ axiom 6). Never `cli` (upward).
|
|
|
100
100
|
| Colours | tokens and `data-theme` only. No hex in `head.ts` or any emitted script. |
|
|
101
101
|
| An island's `mount` | may return `() => void`, its disposer; the runtime never calls it, `@ultimat3/testing`'s `mountIsland` does. |
|
|
102
102
|
| `<head>` binding | `head.ts` stays injection-only; `head-seo.ts` is the ONE binding of `HeadRenderers` to `@ultimat3/seo`. |
|
|
103
|
+
| Client navigation | `navigation*.ts` — soft navigation over server-rendered documents, NOT a mode: the router renders nothing, it swaps the document the route already rendered. Rules are pure (`navigation-rules.ts`: `linkVerdict`, `formVerdict`, `responseVerdict`, `mayPrefetch`) and unit-tested; the DOM half (`navigation-swap.ts`, `navigation.ts`) is proven in Chrome by `packages/cli/e2e/client-navigation-*.e2e.test.ts`. `./navigation` (`navigation-entry.ts`) is the script the CLI builds; importing it starts it, and a document without `ultimate-navigation` is never intercepted. |
|
|
104
|
+
| Router ↔ islands | ONE bootstrap: the swap re-runs the inline hydration runtime, whose `each` visits a root once per tab (`el.__v`). Never add a second mount path for the router. Dispose = call what `el.__x` resolved to. |
|
|
105
|
+
| Router head diff | only elements a SERVER document put in `<head>` (`documentHead`'s set) may be removed; a script-added one is the tab's. Scripts and stylesheets are never diffed. |
|
|
106
|
+
| Router: nothing is sent twice | the router follows no redirect (`redirect: 'manual'`; the server hands it over as `204` + `x-ultimate-location`), never re-submits a POST (a failure is `ultimate:navigation-error`), and hands a non-page answer to the browser from the bytes it already has. Its cache never answers with a non-2xx, a hand-over, or a `no-store` page older than 5 s, and is emptied by any POST, `onClientWrite`, `onRescope` and a `BroadcastChannel` message from another tab. |
|
|
103
107
|
|
|
104
108
|
Cross-package: `@ultimat3/pwa` consumes route descriptors as **data**, never by import.
|
|
105
109
|
Keep `RouteDescriptor` additive — removing a field breaks `sw.js` generation.
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.8.0",
|
|
4
4
|
"description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": [
|
|
8
8
|
"./src/errors.ts",
|
|
9
|
-
"./src/server.ts"
|
|
9
|
+
"./src/server.ts",
|
|
10
|
+
"./src/navigation-entry.ts"
|
|
10
11
|
],
|
|
11
12
|
"repository": {
|
|
12
13
|
"type": "git",
|
|
@@ -19,7 +20,8 @@
|
|
|
19
20
|
},
|
|
20
21
|
"exports": {
|
|
21
22
|
".": "./src/index.ts",
|
|
22
|
-
"./server": "./src/server.ts"
|
|
23
|
+
"./server": "./src/server.ts",
|
|
24
|
+
"./navigation": "./src/navigation-entry.ts"
|
|
23
25
|
},
|
|
24
26
|
"files": [
|
|
25
27
|
"src",
|
|
@@ -36,11 +38,11 @@
|
|
|
36
38
|
"test": "bun test"
|
|
37
39
|
},
|
|
38
40
|
"dependencies": {
|
|
39
|
-
"@ultimat3/cache": "22.
|
|
40
|
-
"@ultimat3/core": "22.
|
|
41
|
-
"@ultimat3/http": "22.
|
|
42
|
-
"@ultimat3/i18n": "22.
|
|
43
|
-
"@ultimat3/seo": "22.
|
|
41
|
+
"@ultimat3/cache": "22.8.0",
|
|
42
|
+
"@ultimat3/core": "22.8.0",
|
|
43
|
+
"@ultimat3/http": "22.8.0",
|
|
44
|
+
"@ultimat3/i18n": "22.8.0",
|
|
45
|
+
"@ultimat3/seo": "22.8.0",
|
|
44
46
|
"sass": "1.104.0"
|
|
45
47
|
}
|
|
46
48
|
}
|
package/src/errors.ts
CHANGED
|
@@ -27,6 +27,7 @@ export const RENDER_ERROR_CODES = [
|
|
|
27
27
|
'X_STYLES_GLOBAL_MISSING',
|
|
28
28
|
'X_ASSET_MISSING',
|
|
29
29
|
'X_ROUTE_POST_INVALID',
|
|
30
|
+
'X_ROUTE_NAVIGATION_INVALID',
|
|
30
31
|
] as const;
|
|
31
32
|
|
|
32
33
|
export type RenderErrorCode = (typeof RENDER_ERROR_CODES)[number];
|
|
@@ -51,6 +52,8 @@ export const RENDER_ERROR_TITLES: Readonly<Record<RenderErrorCode, string>> = {
|
|
|
51
52
|
'a surface renders documents whose CSS defines no :root custom properties',
|
|
52
53
|
X_ASSET_MISSING: 'a page names a site asset the app does not have',
|
|
53
54
|
X_ROUTE_POST_INVALID: "a page route's post binding cannot be mounted",
|
|
55
|
+
X_ROUTE_NAVIGATION_INVALID:
|
|
56
|
+
"a route's navigation is not 'prefetch' or 'document', or its surface has no client navigation",
|
|
54
57
|
};
|
|
55
58
|
|
|
56
59
|
// Titles must be registered for `format()` to render the contract's first line. Every code above is
|
|
@@ -200,6 +203,21 @@ export class RoutePostInvalidError extends UltimateError {
|
|
|
200
203
|
}
|
|
201
204
|
}
|
|
202
205
|
|
|
206
|
+
/**
|
|
207
|
+
* `defineRoute({ navigation })` that says nothing a process can honour: a value other than
|
|
208
|
+
* `'prefetch'` or `'document'`, or — at boot — a page whose surface has no client navigation.
|
|
209
|
+
*/
|
|
210
|
+
export class RouteNavigationInvalidError extends UltimateError {
|
|
211
|
+
static readonly code = 'X_ROUTE_NAVIGATION_INVALID' as const;
|
|
212
|
+
constructor(cause: string) {
|
|
213
|
+
super({
|
|
214
|
+
code: RouteNavigationInvalidError.code,
|
|
215
|
+
cause,
|
|
216
|
+
fix: "defineRoute({ ..., navigation: 'prefetch' | 'document' }) on a page whose surface is listed in app.config.ts navigation.client, or remove the key",
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
203
221
|
export class RouteLoadInvalidError extends UltimateError {
|
|
204
222
|
static readonly code = 'X_ROUTE_LOAD_INVALID' as const;
|
|
205
223
|
constructor(cause: string, fix: string) {
|
package/src/hydrate.ts
CHANGED
|
@@ -129,9 +129,13 @@ var props=p?JSON.parse(p.textContent||'{}'):{};
|
|
|
129
129
|
return el.__x=import(e).then(function(m){return m.mount(el,props)}).then(
|
|
130
130
|
function(r){el.setAttribute('${ISLAND_MOUNTED_ATTRIBUTE}','');return r},
|
|
131
131
|
function(x){el.setAttribute('${ISLAND_FAILED_ATTRIBUTE}',x&&x.message||'1');throw x})}
|
|
132
|
-
function each(s,f){Array.prototype.forEach.call(document.querySelectorAll(s),f)}
|
|
132
|
+
function each(s,f){Array.prototype.forEach.call(document.querySelectorAll(s),function(el){if(!el.__v){el.__v=1;f(el)}})}
|
|
133
133
|
function hush(){}
|
|
134
134
|
`.trim();
|
|
135
|
+
// `each` visits an island root ONCE per tab (`el.__v`): the client router (`navigation-swap.ts`)
|
|
136
|
+
// re-runs this same runtime over every body it swaps in, and an island it carried across
|
|
137
|
+
// (`data-x-persist`) must not get a second `catchUp` — its listeners would replay every later click
|
|
138
|
+
// twice. One bootstrap path, safe to run again, instead of a second mount API for the router.
|
|
135
139
|
// `hush` above: `boot` rethrows, so every runtime below has to terminate the chain it starts or
|
|
136
140
|
// the page reports an unhandled rejection for a failure it already recorded on the element.
|
|
137
141
|
|
package/src/index.ts
CHANGED
|
@@ -53,6 +53,7 @@ export {
|
|
|
53
53
|
RouteLoadInvalidError,
|
|
54
54
|
RouteMetaMissingError,
|
|
55
55
|
RouteModeInvalidError,
|
|
56
|
+
RouteNavigationInvalidError,
|
|
56
57
|
RouteOfflineMissingError,
|
|
57
58
|
RoutePostInvalidError,
|
|
58
59
|
RouteStatusInvalidError,
|
|
@@ -111,6 +112,46 @@ export {
|
|
|
111
112
|
defaultIslandBudget,
|
|
112
113
|
MODE_SPECS,
|
|
113
114
|
} from './modes';
|
|
115
|
+
/** Client-side navigation over server-rendered documents — `navigation: { client }` surfaces. */
|
|
116
|
+
export type { CachedDocument, NavigationCache, NavigationCacheOptions } from './navigation-cache';
|
|
117
|
+
export { cacheKey, NAVIGATION_CACHE_MAX_ENTRIES, navigationCache } from './navigation-cache';
|
|
118
|
+
export type {
|
|
119
|
+
CachedFacts,
|
|
120
|
+
FormFacts,
|
|
121
|
+
FormVerdict,
|
|
122
|
+
LinkFacts,
|
|
123
|
+
LinkVerdict,
|
|
124
|
+
NativeReason,
|
|
125
|
+
PrefetchFacts,
|
|
126
|
+
ResponseFacts,
|
|
127
|
+
ResponseVerdict,
|
|
128
|
+
} from './navigation-rules';
|
|
129
|
+
export {
|
|
130
|
+
formVerdict,
|
|
131
|
+
linkVerdict,
|
|
132
|
+
mayPrefetch,
|
|
133
|
+
NAVIGATE_EVENT,
|
|
134
|
+
NAVIGATED_EVENT,
|
|
135
|
+
NAVIGATING_ATTRIBUTE,
|
|
136
|
+
NAVIGATION_CACHE_TTL_MS,
|
|
137
|
+
NAVIGATION_ERROR_EVENT,
|
|
138
|
+
NAVIGATION_HEADER,
|
|
139
|
+
NAVIGATION_LOCATION_HEADER,
|
|
140
|
+
NAVIGATION_MAX_HOPS,
|
|
141
|
+
NAVIGATION_META,
|
|
142
|
+
NAVIGATION_NO_PREFETCH_ATTRIBUTE,
|
|
143
|
+
NAVIGATION_NO_STORE_REUSE_MS,
|
|
144
|
+
NAVIGATION_PERSIST_ATTRIBUTE,
|
|
145
|
+
NAVIGATION_PREFETCH_DELAY_MS,
|
|
146
|
+
NAVIGATION_PROGRESS_DELAY_MS,
|
|
147
|
+
NAVIGATION_RELOAD_ATTRIBUTE,
|
|
148
|
+
NAVIGATION_SCOPE_HEADER,
|
|
149
|
+
NAVIGATION_SURFACE_HEADER,
|
|
150
|
+
responseVerdict,
|
|
151
|
+
reusable,
|
|
152
|
+
} from './navigation-rules';
|
|
153
|
+
export type { ClientNavigationHead } from './navigation-tags';
|
|
154
|
+
export { clientNavigationTags } from './navigation-tags';
|
|
114
155
|
export type {
|
|
115
156
|
CompiledPattern,
|
|
116
157
|
RegisterRouteInput,
|
|
@@ -145,9 +186,16 @@ export type {
|
|
|
145
186
|
RouteMetaAsyncFn,
|
|
146
187
|
RouteMetaContext,
|
|
147
188
|
RouteMetaFn,
|
|
189
|
+
RouteNavigationMode,
|
|
148
190
|
RouteParams,
|
|
149
191
|
} from './route';
|
|
150
|
-
export {
|
|
192
|
+
export {
|
|
193
|
+
DEFAULT_ISLAND_HYDRATE,
|
|
194
|
+
defineRoute,
|
|
195
|
+
isRouteConfig,
|
|
196
|
+
ROUTE_NAVIGATION_MODES,
|
|
197
|
+
tagKeys,
|
|
198
|
+
} from './route';
|
|
151
199
|
export type { RouteComponent } from './route-component';
|
|
152
200
|
export { pageComponentOf } from './route-component';
|
|
153
201
|
export { metaContextFor, routeDataFor } from './route-data';
|
package/src/modes.ts
CHANGED
|
@@ -246,12 +246,12 @@ export function defaultHydrate(surface: Surface): HydrateStrategy {
|
|
|
246
246
|
* (`settings.island.tsx`) is 17,797 B. No `budget.js` under 4096 was reachable by any of them, on
|
|
247
247
|
* any surface, because the allowance is measured above the baseline and not against it.
|
|
248
248
|
*
|
|
249
|
-
* The number: 17,797 (the heaviest island this repo actually ships) + 1,
|
|
249
|
+
* The number: 17,797 (the heaviest island this repo actually ships) + 1,784 (`hydrateRuntimeBytes`
|
|
250
250
|
* for one `idle` directive — `defaultHydrate('app')`, and since 2026-09-22 the costlier of the two
|
|
251
|
-
* runtimes an island gets unasked; `DEFAULT_ISLAND_HYDRATE`'s `'interaction'` is 1,
|
|
252
|
-
* **19,
|
|
253
|
-
* next whole kilobyte above it, leaving
|
|
254
|
-
* bundling the same island twice is refused. `island-budget.test.ts` asserts all three. `visible` costs
|
|
251
|
+
* runtimes an island gets unasked; `DEFAULT_ISLAND_HYDRATE`'s `'interaction'` is 1,669) =
|
|
252
|
+
* **19,581**. That is the worst case an app reaches without writing a number down. 20,480 is the
|
|
253
|
+
* next whole kilobyte above it, leaving 899 B of headroom and still under 2x 19,581, so a route
|
|
254
|
+
* bundling the same island twice is refused. `island-budget.test.ts` asserts all three. `visible` costs 886, so an island route
|
|
255
255
|
* that declares it pays less; the default is what the budget has to clear.
|
|
256
256
|
*
|
|
257
257
|
* All three grew by 129 B on 2026-08-21 (from 881 / 615 / 687), when the prelude learned to mark a
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client router's per-tab memory of documents it fetched: a prefetch on intent, answered by
|
|
3
|
+
* the click that follows. Memory only, never storage — a gated page rendered for this principal
|
|
4
|
+
* must not outlive the tab — for `NAVIGATION_CACHE_TTL_MS`, and emptied by any POST, because a
|
|
5
|
+
* write is the one thing this tab knows makes every page it holds suspect.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { NAVIGATION_CACHE_TTL_MS } from './navigation-rules';
|
|
9
|
+
|
|
10
|
+
/** One fetched document: the promise, so a click during the prefetch waits for the same fetch. */
|
|
11
|
+
export interface CachedDocument<T> {
|
|
12
|
+
readonly at: number;
|
|
13
|
+
readonly value: Promise<T>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface NavigationCache<T> {
|
|
17
|
+
/** A fresh entry for `url`, or `undefined` — an expired one is dropped on the way. */
|
|
18
|
+
get(url: string): Promise<T> | undefined;
|
|
19
|
+
/** A fresh entry's answer and how old it is, or `undefined`. */
|
|
20
|
+
peek(url: string): { readonly value: Promise<T>; readonly ageMs: number } | undefined;
|
|
21
|
+
set(url: string, value: Promise<T>): void;
|
|
22
|
+
delete(url: string): void;
|
|
23
|
+
clear(): void;
|
|
24
|
+
readonly size: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface NavigationCacheOptions {
|
|
28
|
+
/** The clock, for a test. The TTL and the cap are constants: nothing has a reason to vary them. */
|
|
29
|
+
readonly now?: () => number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Bounded: a visitor hovering a 200-row table must not hold 200 documents. */
|
|
33
|
+
export const NAVIGATION_CACHE_MAX_ENTRIES = 20;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The fragment never reaches the server, so `/a#x` and `/a#y` are one document; and `?b=2&a=1` is
|
|
37
|
+
* `?a=1&b=2` — one query, however a link happened to spell it.
|
|
38
|
+
*/
|
|
39
|
+
export const cacheKey = (url: string): string => {
|
|
40
|
+
const parsed = new URL(url, 'http://x.invalid');
|
|
41
|
+
parsed.hash = '';
|
|
42
|
+
parsed.searchParams.sort();
|
|
43
|
+
return url.startsWith('/') ? `${parsed.pathname}${parsed.search}` : parsed.href;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function navigationCache<T>(options: NavigationCacheOptions = {}): NavigationCache<T> {
|
|
47
|
+
const ttl = NAVIGATION_CACHE_TTL_MS;
|
|
48
|
+
const now = options.now ?? Date.now;
|
|
49
|
+
const max = NAVIGATION_CACHE_MAX_ENTRIES;
|
|
50
|
+
const entries = new Map<string, CachedDocument<T>>();
|
|
51
|
+
return {
|
|
52
|
+
get(url) {
|
|
53
|
+
const key = cacheKey(url);
|
|
54
|
+
const entry = entries.get(key);
|
|
55
|
+
if (entry === undefined) return undefined;
|
|
56
|
+
if (now() - entry.at >= ttl) {
|
|
57
|
+
entries.delete(key);
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
return entry.value;
|
|
61
|
+
},
|
|
62
|
+
peek(url) {
|
|
63
|
+
const value = this.get(url);
|
|
64
|
+
const entry = entries.get(cacheKey(url));
|
|
65
|
+
return value === undefined || entry === undefined
|
|
66
|
+
? undefined
|
|
67
|
+
: { value, ageMs: now() - entry.at };
|
|
68
|
+
},
|
|
69
|
+
set(url, value) {
|
|
70
|
+
const key = cacheKey(url);
|
|
71
|
+
entries.delete(key);
|
|
72
|
+
entries.set(key, { at: now(), value });
|
|
73
|
+
// Oldest first: a Map iterates in insertion order and `set` re-inserts.
|
|
74
|
+
for (const oldest of entries.keys()) {
|
|
75
|
+
if (entries.size <= max) break;
|
|
76
|
+
entries.delete(oldest);
|
|
77
|
+
}
|
|
78
|
+
// A failed fetch is not a document: the next click must ask again, not replay the failure.
|
|
79
|
+
value.catch(() => {
|
|
80
|
+
if (entries.get(key)?.value === value) entries.delete(key);
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
delete(url) {
|
|
84
|
+
entries.delete(cacheKey(url));
|
|
85
|
+
},
|
|
86
|
+
clear() {
|
|
87
|
+
entries.clear();
|
|
88
|
+
},
|
|
89
|
+
get size() {
|
|
90
|
+
return entries.size;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|