@ultimat3/render 22.15.0 → 24.0.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/src/jsx.ts CHANGED
@@ -28,16 +28,37 @@ export function isJsxNode(value: unknown): value is JsxNode {
28
28
  * component reading `props.children` behaves the same whether its children came from the classic
29
29
  * factory's rest arguments or from an explicit `children` prop.
30
30
  */
31
+ export function h(type: string, props: JsxProps | null, ...children: readonly unknown[]): JsxNode;
32
+ /**
33
+ * A component, with the props IT declares. `JsxComponent` takes any record, so a component typed
34
+ * for its own props — an `island()`, a page's `Shell` — is not one under `strictFunctionTypes`,
35
+ * and a hand-written `h(Toggle, { label })` was refused where the `.tsx` loader's own call is not.
36
+ * The props are checked against the component here; `children` rides as the rest arguments.
37
+ */
38
+ export function h<Props extends object>(
39
+ type: (props: Props) => unknown,
40
+ props: Omit<Props, 'children'> | null,
41
+ ...children: readonly unknown[]
42
+ ): JsxNode;
31
43
  export function h(
32
44
  type: string | JsxComponent,
33
45
  props: JsxProps | null,
34
46
  ...children: readonly unknown[]
47
+ ): JsxNode;
48
+ export function h(
49
+ // `never` is the one parameter type every component's props satisfy.
50
+ type: string | ((props: never) => unknown),
51
+ props: object | null,
52
+ ...children: readonly unknown[]
35
53
  ): JsxNode {
36
- const base = props ?? {};
37
- if (children.length === 0) return { [JSX_NODE]: true, type, props: base };
54
+ // The overloads above are where a call is checked; a node carries the pair erased, which is all
55
+ // `render-html.ts` ever reads — it hands `props` back to the `type` it arrived with.
56
+ const erased = type as string | JsxComponent;
57
+ const base = (props ?? {}) as JsxProps;
58
+ if (children.length === 0) return { [JSX_NODE]: true, type: erased, props: base };
38
59
  return {
39
60
  [JSX_NODE]: true,
40
- type,
61
+ type: erased,
41
62
  props: { ...base, children: children.length === 1 ? children[0] : children },
42
63
  };
43
64
  }
package/src/modes.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { HydrateStrategy, RenderMode } from '@ultimat3/core';
9
- import { HYDRATE_STRATEGIES, RENDER_MODES } from '@ultimat3/core';
9
+ import { HYDRATE_STRATEGIES, RENDER_MODES, renderCauseValue } from '@ultimat3/core';
10
10
  import { parseTtlMs } from './duration';
11
11
  import { RouteModeInvalidError } from './errors';
12
12
  import type { RouteConfig } from './route';
@@ -84,14 +84,14 @@ export function assertModeShape(config: RouteShape): void {
84
84
  : undefined;
85
85
  if (spec === undefined) {
86
86
  throw new RouteModeInvalidError(
87
- `render: ${JSON.stringify(config.render)} is not a render mode`,
87
+ `render: ${renderCauseValue(config.render)} is not a render mode`,
88
88
  `use one of ${RENDER_MODES.join(' | ')}`,
89
89
  );
90
90
  }
91
91
 
92
92
  if (!HYDRATE_STRATEGIES.includes(config.hydrate)) {
93
93
  throw new RouteModeInvalidError(
94
- `hydrate: ${JSON.stringify(config.hydrate)} is not a hydration strategy`,
94
+ `hydrate: ${renderCauseValue(config.hydrate)} is not a hydration strategy`,
95
95
  `use one of ${HYDRATE_STRATEGIES.join(' | ')}`,
96
96
  );
97
97
  }
@@ -200,6 +200,17 @@ export function assertModeInvariants(config: RouteShape, ctx: ModeCheckContext):
200
200
  );
201
201
  }
202
202
 
203
+ // `js` is the one budget anything measures. `lcp`, `css`, `cls` and `tbt` were each accepted,
204
+ // stored and compared by nothing that had a measurement — a false green — so a key the type no
205
+ // longer declares, arriving by a cast or from plain JS, is refused rather than carried.
206
+ for (const key of Object.keys(config.budget)) {
207
+ if (key === 'js') continue;
208
+ throw new RouteModeInvalidError(
209
+ `${ctx.file} declares budget.${key}, which nothing in the framework measures — only budget.js is weighed (x verify's budgets step). In ${ctx.file}, delete ${key}: the whole budget is the js key`,
210
+ "budget: { js: '10kb' }",
211
+ );
212
+ }
213
+
203
214
  // `budget` is always on the descriptor; `budget.js` is the field that stays optional,
204
215
  // and its absence is the failure — site/ is 0kb until a route says otherwise, in bytes.
205
216
  if (ctx.surface === 'site' && config.hydrate !== 'never' && config.budget.js === undefined) {
@@ -76,6 +76,11 @@ export interface Stylesheet {
76
76
  readonly file: string;
77
77
  /** The surface that owns it, or `null` for a package stylesheet shared by both graphs. */
78
78
  readonly surface: Surface | null;
79
+ /**
80
+ * A PACKAGE's sheet whose surface came from `claimStylesheets`, not from where the file sits.
81
+ * It is carried by that one surface and still ordered with the library — see `stylesheetOrder`.
82
+ */
83
+ readonly claimed: boolean;
79
84
  /** A plain (non-module) stylesheet: the tokens and the reset, which the cascade needs first. */
80
85
  readonly global: boolean;
81
86
  /**
@@ -118,8 +123,46 @@ export function registeredStylesheets(): readonly Stylesheet[] {
118
123
  */
119
124
  let stylesheetRoot: string | undefined;
120
125
 
126
+ /**
127
+ * Directories a package claimed for ONE surface, longest first. A package's sheet has no surface
128
+ * of its own and rides both graphs — right for a design system, and the reason an app's static
129
+ * `site/` documents carried the admin's stylesheet for screens only `app/` serves (axiom 6).
130
+ */
131
+ const claims: { readonly dir: string; readonly surface: Surface }[] = [];
132
+
133
+ const claimedSurface = (path: string): Surface | undefined =>
134
+ claims.find((claim) => path.startsWith(`${claim.dir}/`))?.surface;
135
+
121
136
  const surfaceOfSheet = (path: string): Surface | null =>
122
- surfaceUnder(stylesheetRoot ?? process.cwd(), path);
137
+ claimedSurface(path) ?? surfaceUnder(stylesheetRoot ?? process.cwd(), path);
138
+
139
+ /** Every registered sheet, classified again; the revision moves only when an answer does. */
140
+ const reclassify = (): void => {
141
+ for (const [path, sheet] of stylesheets) {
142
+ const surface = surfaceOfSheet(path);
143
+ const claimed = claimedSurface(path) !== undefined;
144
+ if (surface === sheet.surface && claimed === sheet.claimed) continue;
145
+ stylesheets.set(path, { ...sheet, surface, claimed });
146
+ revision += 1;
147
+ }
148
+ };
149
+
150
+ /**
151
+ * A package says its own stylesheets belong to one surface: every sheet below `dir` is carried by
152
+ * documents of `surface` and by no other. Called by the package that OWNS the directory, with its
153
+ * own `import.meta.dir` — a sheet loads when its module is linked, which is before the claiming
154
+ * module's body runs, so a claim re-classifies what is already registered.
155
+ *
156
+ * A claim is a fact about a package's source, made once per process: `clearStylesheets()` leaves
157
+ * it standing, because the module that made it is cached and will not make it again.
158
+ */
159
+ export function claimStylesheets(dir: string, surface: Surface): void {
160
+ const claimed = resolve(dir);
161
+ if (claims.some((claim) => claim.dir === claimed && claim.surface === surface)) return;
162
+ claims.push({ dir: claimed, surface });
163
+ claims.sort((a, b) => b.dir.length - a.dir.length);
164
+ reclassify();
165
+ }
123
166
 
124
167
  /**
125
168
  * Names the app root the registry classifies against; `loadApp` calls it before importing a module.
@@ -132,12 +175,7 @@ const surfaceOfSheet = (path: string): Surface | null =>
132
175
  export function setStylesheetRoot(root: string | undefined): void {
133
176
  // Resolved: `loadApp('.')` is a legal call, and `.` is a prefix of no absolute path.
134
177
  stylesheetRoot = root === undefined ? undefined : resolve(root);
135
- for (const [path, sheet] of stylesheets) {
136
- const surface = surfaceOfSheet(path);
137
- if (surface === sheet.surface) continue;
138
- stylesheets.set(path, { ...sheet, surface });
139
- revision += 1;
140
- }
178
+ reclassify();
141
179
  }
142
180
 
143
181
  /** Test seam: the registry is process-global because the module cache it mirrors is too. */
@@ -180,7 +218,8 @@ export function stylesFor(surface: Surface | null): string {
180
218
  }
181
219
 
182
220
  const OWNER_RANK = (sheet: Stylesheet): number =>
183
- sheet.surface === null ? 0 : sheet.surface === 'shared' ? 1 : 2;
221
+ // A claimed sheet is a package's whichever surface carries it: library first.
222
+ sheet.surface === null || sheet.claimed ? 0 : sheet.surface === 'shared' ? 1 : 2;
184
223
 
185
224
  /** Relative to the app root, so a container at `/app` and a laptop order one app alike. */
186
225
  const orderPath = (sheet: Stylesheet): string => {
@@ -233,6 +272,7 @@ export function loadStylesheet(
233
272
  stylesheets.set(path, {
234
273
  file: path,
235
274
  surface: surfaceOfSheet(path),
275
+ claimed: claimedSurface(path) !== undefined,
236
276
  global: isGlobalStylesheet(path),
237
277
  island,
238
278
  css: compiled.css,
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The routes a package MOUNTS — screens no surface file declares, like the admin's — held beside
3
+ * the file routes so `registry.ts` can describe both in the one route list. Storage and the
4
+ * collision rule only: nothing here renders, matches or serves.
5
+ */
6
+
7
+ import { RouteDuplicateError } from './errors';
8
+ import type { RouteConfig } from './route';
9
+ import type { Surface } from './surfaces';
10
+
11
+ /** Who put a mounted route in the table, and what it requires. */
12
+ export interface RouteMount {
13
+ /** The call that declared it: `defineAdmin`. */
14
+ readonly by: string;
15
+ /** Every permission the route's own screen decides on, coarse gate first. */
16
+ readonly permissions: readonly string[];
17
+ }
18
+
19
+ /** The mount a set of routes belongs to. `key` is what a re-declaration replaces. */
20
+ export interface RouteMountInput {
21
+ readonly key: string;
22
+ readonly by: string;
23
+ /** What `RouteDescriptor.file` reads for these routes: the package, since no app file is. */
24
+ readonly file: string;
25
+ readonly surface: Exclude<Surface, 'shared'>;
26
+ }
27
+
28
+ export interface MountedRouteInput {
29
+ readonly path: string;
30
+ readonly config: RouteConfig;
31
+ readonly permissions: readonly string[];
32
+ }
33
+
34
+ export interface MountedRoute extends MountedRouteInput {
35
+ readonly mount: RouteMountInput;
36
+ }
37
+
38
+ const mounted = new Map<string, readonly MountedRoute[]>();
39
+
40
+ /** Every mounted route, in mount-declaration order. */
41
+ export function mountedRoutes(): readonly MountedRoute[] {
42
+ return [...mounted.values()].flat();
43
+ }
44
+
45
+ /** The mounted route claiming `path`, if one does. */
46
+ export function mountedAt(path: string): MountedRoute | undefined {
47
+ return mountedRoutes().find((route) => route.path === path);
48
+ }
49
+
50
+ /** Replaces the set under `mount.key`: a re-declaration is the same mount, saved again. */
51
+ export function setMountedRoutes(
52
+ mount: RouteMountInput,
53
+ declared: readonly MountedRouteInput[],
54
+ ): void {
55
+ mounted.set(
56
+ mount.key,
57
+ declared.map((route) => ({ ...route, mount })),
58
+ );
59
+ }
60
+
61
+ export function clearMountedRoutes(): void {
62
+ mounted.clear();
63
+ }
64
+
65
+ /** One URL, one claimant — a file and a mount on the same path is two servers for it. */
66
+ export const mountCollision = (
67
+ path: string,
68
+ file: string,
69
+ mount: RouteMountInput,
70
+ ): RouteDuplicateError =>
71
+ new RouteDuplicateError(
72
+ `${path} is claimed by both ${file} and ${mount.by}() — a route ${mount.file} mounts`,
73
+ `delete ${file}: ${mount.by}() already serves ${path}, and the route table is keyed by URL`,
74
+ );
@@ -39,6 +39,28 @@ export function anchorOf(target: EventTarget | null): HTMLAnchorElement | HTMLAr
39
39
  export const fieldText = (value: unknown): string =>
40
40
  value instanceof File ? value.name : String(value);
41
41
 
42
+ /** Every lone CR, lone LF and CRLF in `text`, as CRLF. */
43
+ const crlf = (text: string): string => text.replace(/\r\n|\r|\n/g, '\r\n');
44
+
45
+ /**
46
+ * The fields as a GET query or a urlencoded body carries them — names and values with their line
47
+ * breaks normalised to CRLF, as the browser's own submit does (HTML's "convert to a list of
48
+ * name-value pairs"). A `<textarea>`'s value holds bare LFs; sent as they were, the router's
49
+ * submit and a native one delivered two different bodies for one form. A multipart body needs
50
+ * none of this: `fetch` encodes a `FormData` with the same normalisation.
51
+ */
52
+ export const formPairs = (fields: FormData): readonly (readonly [string, string])[] =>
53
+ [...fields].map(([name, value]: [string, unknown]) => [crlf(name), crlf(fieldText(value))]);
54
+
55
+ /**
56
+ * A form's action, resolved against the document — or the raw text when it does not resolve, which
57
+ * `formVerdict` leaves to the browser (`'unparsable'`) rather than throwing from a submit handler.
58
+ */
59
+ export function formAction(raw: string, fallback: string, base: string): string {
60
+ const action = raw || fallback;
61
+ return URL.canParse(action, base) ? new URL(action, base).href : action;
62
+ }
63
+
42
64
  /** The submitted fields, the submitter's own name/value included, as the browser would send. */
43
65
  export function formFields(form: HTMLFormElement, submitter: HTMLElement | null): FormData {
44
66
  try {
@@ -59,7 +81,7 @@ const hush = (): void => undefined;
59
81
  export async function transition(
60
82
  win: Window,
61
83
  apply: () => void,
62
- track?: (running: RunningTransition | undefined) => void,
84
+ track?: (running: RunningTransition, finished: boolean) => void,
63
85
  ): Promise<void> {
64
86
  const doc = win.document as Document & {
65
87
  startViewTransition?: (update: () => void) => RunningTransition;
@@ -70,12 +92,12 @@ export async function transition(
70
92
  return;
71
93
  }
72
94
  const running = doc.startViewTransition(apply);
73
- track?.(running);
95
+ track?.(running, false);
74
96
  running.ready.catch(hush);
75
- running.finished.then(
76
- () => track?.(undefined),
77
- () => track?.(undefined),
78
- );
97
+ // The transition itself, never "whichever is running": an older one finishing after a newer one
98
+ // started must not clear the newer one (the caller compares by identity).
99
+ const done = (): void => track?.(running, true);
100
+ running.finished.then(done, done);
79
101
  await running.updateCallbackDone;
80
102
  }
81
103
 
@@ -70,7 +70,8 @@ export type NativeReason =
70
70
  | 'hash'
71
71
  | 'method'
72
72
  | 'encoding'
73
- | 'file';
73
+ | 'file'
74
+ | 'unparsable';
74
75
 
75
76
  export type LinkVerdict =
76
77
  | { readonly kind: 'soft'; readonly url: string }
@@ -97,9 +98,12 @@ const native = (reason: NativeReason): { readonly kind: 'native'; reason: Native
97
98
  reason,
98
99
  });
99
100
 
100
- /** Same document, fragment only: the browser scrolls, no fetch. */
101
- const hashOnly = (to: URL, from: URL): boolean =>
102
- to.hash !== '' && to.pathname === from.pathname && to.search === from.search;
101
+ /**
102
+ * Same document, fragment only: the browser scrolls, no fetch. Asked of the HREF, not `to.hash`:
103
+ * an empty fragment (`<a href="#">`) parses to `hash === ''`, and was soft-navigated and re-fetched.
104
+ */
105
+ const hashOnly = (href: string, to: URL, from: URL): boolean =>
106
+ href.includes('#') && to.pathname === from.pathname && to.search === from.search;
103
107
 
104
108
  /**
105
109
  * The link rules, in the order a reader checks them. The SURFACE is not decided here: the URL
@@ -113,12 +117,15 @@ export function linkVerdict(facts: LinkFacts): LinkVerdict {
113
117
  if (facts.download) return native('download');
114
118
  if (facts.rel.split(/\s+/).includes('external')) return native('external');
115
119
  if (facts.reload) return native('opt-out');
120
+ // `a.href` is the raw attribute when it does not parse (`href="http://"`): the browser decides
121
+ // what such a link does, where `new URL` threw a bare `TypeError` out of the click handler.
122
+ if (!URL.canParse(facts.href)) return native('unparsable');
116
123
  const to = new URL(facts.href);
117
124
  const from = new URL(facts.current);
118
125
  if (to.origin !== from.origin || (to.protocol !== 'http:' && to.protocol !== 'https:')) {
119
126
  return native('cross-origin');
120
127
  }
121
- if (hashOnly(to, from)) return native('hash');
128
+ if (hashOnly(facts.href, to, from)) return native('hash');
122
129
  return { kind: 'soft', url: to.href };
123
130
  }
124
131
 
@@ -153,7 +160,7 @@ export type FormVerdict =
153
160
 
154
161
  /** What a `submit` tells the router — the submitter's `formmethod`/`formaction` already applied. */
155
162
  export interface FormFacts {
156
- /** Resolved action URL. */
163
+ /** Resolved action URL — or the raw attribute when it does not resolve (`'unparsable'`). */
157
164
  readonly action: string;
158
165
  readonly current: string;
159
166
  /** Lower case: `get`, `post` or `dialog`. */
@@ -175,6 +182,7 @@ export function formVerdict(facts: FormFacts): FormVerdict {
175
182
  if (facts.method !== 'get' && facts.method !== 'post') return native('method');
176
183
  // `text/plain` is the browser's own encoding, with no `fetch` body that reproduces it byte for byte.
177
184
  if (facts.method === 'post' && facts.enctype === 'text/plain') return native('encoding');
185
+ if (!URL.canParse(facts.action)) return native('unparsable');
178
186
  const to = new URL(facts.action);
179
187
  if (to.origin !== new URL(facts.current).origin) return native('cross-origin');
180
188
  if (facts.method === 'get') {
@@ -246,6 +254,14 @@ export function responseVerdict(facts: ResponseFacts): ResponseVerdict {
246
254
  }
247
255
  if (facts.status === 204) {
248
256
  if (facts.location === null) return { kind: 'stay' };
257
+ // The caller ASSIGNS this to `window.location`, so only http(s) is a place to go: a scheme
258
+ // that runs in the page would be script in the app's origin. `@ultimat3/http`'s `locationFor`
259
+ // refuses it at the source; this is the same rule for an answer something else wrote. The
260
+ // requested url is loaded as a document instead — a POST is never re-sent, so it fails.
261
+ if (!/^https?:\/\/./i.test(facts.location) || !URL.canParse(facts.location)) {
262
+ const reason = 'a location that is not http(s)';
263
+ return get ? load(facts.requested, reason) : { kind: 'failed', reason };
264
+ }
249
265
  if (origin(facts.location) !== origin(facts.requested))
250
266
  return load(facts.location, 'another origin');
251
267
  // The server asked for THIS url as a document: the route did not run, the browser runs it.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The client router's scroll keeping: the browser's own restoration is off, so the router restores
3
+ * a saved offset when a document LOADS onto one of its entries (reload, or Back after a full load),
4
+ * then saves the offset into the entry on screen as the visitor scrolls.
5
+ */
6
+
7
+ import { entryOf, saveEntryScroll } from './navigation-history';
8
+
9
+ export interface ScrollKeeper {
10
+ /** The offset, into the entry of the document on screen now. */
11
+ save(): void;
12
+ /** Drops a save still pending — it belongs to the entry just left. */
13
+ cancel(): void;
14
+ stop(): void;
15
+ }
16
+
17
+ /** How long a scroll must rest before it is saved: a browser caps `replaceState` calls. */
18
+ const SCROLL_SETTLE_MS = 150;
19
+
20
+ export function scrollKeeper(win: Window, rendered: () => string): ScrollKeeper {
21
+ win.history.scrollRestoration = 'manual';
22
+ // BEFORE the first save. With restoration manual, a reload or a Back that is a full load lands
23
+ // at the top, and the save below then overwrote the entry's offset with that 0 — the place the
24
+ // visitor left was lost twice. Only this document's own entry: one an app pushed has no offset.
25
+ const held = entryOf(win.history.state);
26
+ if (held !== undefined && held.doc === rendered()) {
27
+ win.scrollTo({
28
+ left: held.scroll[0],
29
+ top: held.scroll[1],
30
+ behavior: 'instant' as ScrollBehavior,
31
+ });
32
+ }
33
+ const save = (): void => saveEntryScroll(win, rendered());
34
+ save();
35
+ // Saved as the visitor scrolls, once the scroll settles — so FORWARD restores too.
36
+ let settle: number | undefined;
37
+ const cancel = (): void => win.clearTimeout(settle);
38
+ const onScroll = (): void => {
39
+ cancel();
40
+ settle = win.setTimeout(save, SCROLL_SETTLE_MS);
41
+ };
42
+ win.addEventListener('scroll', onScroll, { passive: true });
43
+ return {
44
+ save,
45
+ cancel,
46
+ stop() {
47
+ cancel();
48
+ win.removeEventListener('scroll', onScroll);
49
+ },
50
+ };
51
+ }
@@ -56,8 +56,15 @@ export function missingStylesheets(doc: Document, next: Document): readonly stri
56
56
  .filter((href) => !loaded.has(href));
57
57
  }
58
58
 
59
- /** Resolves once every sheet loaded OR failed — a missing sheet must not hang the navigation. */
60
- export function loadStylesheets(doc: Document, hrefs: readonly string[]): Promise<void> {
59
+ /**
60
+ * Resolves once every sheet loaded OR failed — a missing sheet must not hang the navigation. Each
61
+ * sheet joins `owned` as it is appended, so a swap that never happens still leaves it retirable.
62
+ */
63
+ export function loadStylesheets(
64
+ doc: Document,
65
+ hrefs: readonly string[],
66
+ owned: WeakSet<Element>,
67
+ ): Promise<void> {
61
68
  return Promise.all(
62
69
  hrefs.map(
63
70
  (href) =>
@@ -68,6 +75,7 @@ export function loadStylesheets(doc: Document, hrefs: readonly string[]): Promis
68
75
  link.onload = () => done();
69
76
  link.onerror = () => done();
70
77
  doc.head.append(link);
78
+ owned.add(link);
71
79
  }),
72
80
  ),
73
81
  ).then(() => undefined);
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The client router's tie to the origin's other tabs: one `BroadcastChannel`, so a write or a new
3
+ * principal in one tab empties every tab's prefetch cache. Closed while the page sits in the
4
+ * back/forward cache (an open channel keeps a page out of it); reopened, cache emptied, on return.
5
+ */
6
+
7
+ import { onClientWrite, onRescope } from '@ultimat3/core/page';
8
+
9
+ /** One channel per origin: a principal change or a write in one tab empties every tab's cache. */
10
+ export const NAVIGATION_CHANNEL = 'ultimate:navigation';
11
+
12
+ export interface TabSync {
13
+ /** This tab and every other: a write or a new principal makes every held page suspect. */
14
+ forget(): void;
15
+ stop(): void;
16
+ }
17
+
18
+ /** `clear` empties this tab's cache; called for this tab's own events and every other tab's. */
19
+ export function tabSync(win: Window, clear: () => void): TabSync {
20
+ const open = (): BroadcastChannel | undefined => {
21
+ if (!('BroadcastChannel' in win)) return undefined;
22
+ const opened = new BroadcastChannel(NAVIGATION_CHANNEL);
23
+ opened.onmessage = clear;
24
+ return opened;
25
+ };
26
+ let channel = open();
27
+ const forget = (): void => {
28
+ clear();
29
+ channel?.postMessage('clear');
30
+ };
31
+ // `pagehide`, not `unload`: it fires for a page entering the back/forward cache too, and a page
32
+ // holding an open channel is refused by it — Back then reloads the page from the network.
33
+ const onHide = (): void => {
34
+ channel?.close();
35
+ channel = undefined;
36
+ };
37
+ // Restored from that cache: every message sent while it was frozen was missed, so nothing held
38
+ // can be trusted. A `pageshow` of a fresh load finds the channel open and leaves it.
39
+ const onShow = (event: PageTransitionEvent): void => {
40
+ if (!event.persisted || channel !== undefined) return;
41
+ channel = open();
42
+ clear();
43
+ };
44
+ win.addEventListener('pagehide', onHide);
45
+ win.addEventListener('pageshow', onShow);
46
+ const offWrite = onClientWrite(forget);
47
+ const offRescope = onRescope(forget);
48
+ return {
49
+ forget,
50
+ stop() {
51
+ win.removeEventListener('pagehide', onHide);
52
+ win.removeEventListener('pageshow', onShow);
53
+ offWrite();
54
+ offRescope();
55
+ onHide();
56
+ },
57
+ };
58
+ }