@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/CLAUDE.md +15 -6
- package/README.md +23 -5
- package/package.json +7 -6
- package/src/asset.ts +5 -2
- package/src/client-path-style-tag.ts +25 -0
- package/src/css-modules.ts +58 -11
- package/src/duration.ts +10 -2
- package/src/errors.ts +2 -2
- package/src/head.ts +9 -10
- package/src/html.ts +13 -19
- package/src/hydrate.ts +3 -3
- package/src/index.ts +8 -2
- package/src/island-collector.ts +2 -1
- package/src/island.ts +1 -1
- package/src/jsx.ts +24 -3
- package/src/modes.ts +14 -3
- package/src/module-loader.ts +48 -8
- package/src/mounted-routes.ts +74 -0
- package/src/navigation-dom.ts +28 -6
- package/src/navigation-rules.ts +22 -6
- package/src/navigation-scroll.ts +51 -0
- package/src/navigation-swap.ts +10 -2
- package/src/navigation-tabs.ts +58 -0
- package/src/navigation.ts +25 -53
- package/src/registry.ts +107 -55
- package/src/render-html.ts +4 -4
- package/src/render-isr.ts +127 -65
- package/src/render-static.ts +20 -6
- package/src/render-stream.ts +6 -5
- package/src/route-pattern.ts +67 -0
- package/src/route.ts +10 -18
- package/src/sass-cache.ts +22 -3
- package/src/server.ts +4 -0
- package/src/static-path.ts +15 -2
- package/src/type-pins.tsx +4 -3
- package/src/navigation-controller-fixture.ts +0 -91
- package/src/navigation-dom-fixture.ts +0 -424
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
|
-
|
|
37
|
-
|
|
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: ${
|
|
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: ${
|
|
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) {
|
package/src/module-loader.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
);
|
package/src/navigation-dom.ts
CHANGED
|
@@ -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
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
package/src/navigation-rules.ts
CHANGED
|
@@ -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
|
-
/**
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
}
|
package/src/navigation-swap.ts
CHANGED
|
@@ -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
|
-
/**
|
|
60
|
-
|
|
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
|
+
}
|