@ultimat3/render 26.0.0 → 26.1.1
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 +7 -2
- package/README.md +39 -1
- package/package.json +7 -6
- package/src/client-sync-tags.ts +1 -1
- package/src/client.ts +14 -0
- package/src/errors.ts +12 -3
- package/src/index.ts +19 -0
- package/src/navigation-api.ts +84 -0
- package/src/navigation-dom.ts +54 -2
- package/src/navigation-errors.ts +21 -0
- package/src/navigation-fetch.ts +52 -1
- package/src/navigation-history.ts +8 -1
- package/src/navigation-modal-rules.ts +110 -0
- package/src/navigation-modal.ts +233 -0
- package/src/navigation-press.ts +59 -0
- package/src/navigation-swap.ts +9 -2
- package/src/navigation-tags.ts +16 -3
- package/src/navigation.ts +137 -143
- package/src/route.ts +11 -3
package/CLAUDE.md
CHANGED
|
@@ -10,7 +10,12 @@ loaders that make an app's `.tsx` and `.scss` runnable**.
|
|
|
10
10
|
`node:url`, which a browser build cannot link; no `sideEffects` value repairs that). Never re-export
|
|
11
11
|
a name from both barrels: `index.test.ts` asserts the empty name intersection and that `index.ts`'s
|
|
12
12
|
runtime import graph reaches none of those seven modules; `scripts/browser-barrel.test.ts` holds
|
|
13
|
-
the end property.
|
|
13
|
+
the end property. **`"./client"` (`client.ts`) is THE browser entry** for what an island calls
|
|
14
|
+
(`navigate`, `refresh`, `openModal`, `closeModal`, the modal refusal, the three event names): its
|
|
15
|
+
graph never reaches `errors.ts` (the barrel's `sideEffects` keeps that table in every chunk), held
|
|
16
|
+
by `client-bundle.test.ts`. A name there stays on `.` too (additive); a refusal it can construct
|
|
17
|
+
lives in its own module over `@ultimat3/core/page`'s `UltimateError` (`navigation-errors.ts`) and
|
|
18
|
+
`errors.ts` re-exports it — one class, never two.
|
|
14
19
|
|
|
15
20
|
`island()` is a **factory over the route's own `hydrate`**, not a ninth primitive and not a second
|
|
16
21
|
render mode. It adds no key to `defineRoute`.
|
|
@@ -109,7 +114,7 @@ axiom 6). Never `cli` (upward).
|
|
|
109
114
|
| Colours | tokens and `data-theme` only. No hex in `head.ts` or any emitted script. |
|
|
110
115
|
| An island's `mount` | may return `() => void`, its disposer; the runtime never calls it, `@ultimat3/testing`'s `mountIsland` does. |
|
|
111
116
|
| `<head>` binding | `head.ts` stays injection-only; `head-seo.ts` is the ONE binding of `HeadRenderers` to `@ultimat3/seo`. |
|
|
112
|
-
| 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. |
|
|
117
|
+
| 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. Route-presented modals (`navigation: 'modal'`): the hash grammar and verdicts are pure (`navigation-modal-rules.ts`), the `<dialog>` is `navigation-modal.ts`, styled by `@ultimat3/ui`'s `global.scss` under `NAVIGATION_MODAL_ATTRIBUTE` (pinned by `navigation-modal-style.test.ts` — render never imports ui), proven by `client-navigation-modal.e2e.test.ts`. |
|
|
113
118
|
| Router ↔ the page's lifecycle | `navigation-scroll.ts` owns `scrollRestoration = 'manual'` and RESTORES the entry's saved offset at start, before its first save (a reload, or Back after a full load); `navigation-tabs.ts` owns the `BroadcastChannel`, closed on `pagehide` (it blocks the back/forward cache), reopened and the cache emptied on a persisted `pageshow`. A sheet `loadStylesheets` appends is `owned` at once, so an aborted navigation's sheets are retired by the next swap. A view transition is tracked by identity: an older one finishing never clears the running one. |
|
|
114
119
|
| 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. |
|
|
115
120
|
| 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. |
|
package/README.md
CHANGED
|
@@ -126,6 +126,27 @@ posted fields (query wins). An RFC 8058 one-click `POST /correos/baja?t=…` wit
|
|
|
126
126
|
`GET` stays the confirm page. `@ultimat3/cli` mounts it in both boots; a name no action is
|
|
127
127
|
registered under is `X_ROUTE_POST_INVALID` at boot, as is `post` on a `static` page.
|
|
128
128
|
|
|
129
|
+
## `navigation` — how the client router treats a page
|
|
130
|
+
|
|
131
|
+
On a surface listed in `app.config.ts`'s `navigation.client` only (elsewhere it refuses the boot,
|
|
132
|
+
`X_ROUTE_NAVIGATION_INVALID`). Absent: swapped in on a click, never prefetched. Full rules:
|
|
133
|
+
[Client Navigation](../../wiki/Client-Navigation.md).
|
|
134
|
+
|
|
135
|
+
| `navigation` | The page |
|
|
136
|
+
|---|---|
|
|
137
|
+
| `'prefetch'` | may also be fetched on hover/focus — a GET that does nothing but render |
|
|
138
|
+
| `'document'` | always a real document load; carries no router |
|
|
139
|
+
| `'modal'` | `As of 26.1.0`. Shown OVER the page a visitor is on, in a native `<dialog>`, addressed by the hash (`/runs#/runs/new`); reload, Back/Forward and a pasted URL reopen it, a stale hash is just the page. A full load of its own URL is still the whole page — the server renders one document for both and names it a modal (`NAVIGATION_PRESENTATION_META`) |
|
|
140
|
+
|
|
141
|
+
Declared on the route, never on a link: the route is the one place that covers every way of
|
|
142
|
+
reaching a page — a link, a redirect after a POST, a pasted hash.
|
|
143
|
+
|
|
144
|
+
From code — an island, an app script — `navigate(url, { replace? })`, `refresh()`,
|
|
145
|
+
`openModal(path)` and `closeModal()` go through the router when the page has one and fall back to
|
|
146
|
+
`location.assign`/`replace`/`reload` when it does not; they import the router's type only. An
|
|
147
|
+
island imports them from **`@ultimat3/render/client`**, never the barrel — see
|
|
148
|
+
[Two entry points](#two-entry-points). [Navigating from code](../../wiki/Client-Navigation.md#navigating-from-code).
|
|
149
|
+
|
|
129
150
|
## Mode invariants, checked at registration
|
|
130
151
|
|
|
131
152
|
| Mode | Invariant | Error if violated |
|
|
@@ -463,6 +484,19 @@ does.
|
|
|
463
484
|
side effect. Anything that loads an app's source — `x dev`, `x build`, `server.ts`, a test that
|
|
464
485
|
`await import()`s a `page.tsx` — reaches it before the module it loads.
|
|
465
486
|
|
|
487
|
+
`@ultimat3/render/client` is the **browser** entry — what an island or a page script calls:
|
|
488
|
+
`navigate`, `refresh`, `openModal`, `closeModal`, `NavigateToOptions`,
|
|
489
|
+
`NavigationModalPathInvalidError`, `NAVIGATE_EVENT`, `NAVIGATED_EVENT`, `NAVIGATION_ERROR_EVENT`.
|
|
490
|
+
The barrel bundles for the browser but its `sideEffects` keeps `errors.ts` — render's code table,
|
|
491
|
+
and core's and schema's titles tables behind it — in every chunk that reaches it; this entry's
|
|
492
|
+
graph is the navigation helpers plus `@ultimat3/core/page` (`client-bundle.test.ts`). `As of
|
|
493
|
+
2026-10-08`, an island whose body is `refresh()`: 9,323 B minified from the barrel, 216 B from
|
|
494
|
+
`/client`. The barrel keeps the same names, for compatibility.
|
|
495
|
+
|
|
496
|
+
```ts
|
|
497
|
+
import { navigate, refresh } from '@ultimat3/render/client';
|
|
498
|
+
```
|
|
499
|
+
|
|
466
500
|
## Error classes
|
|
467
501
|
|
|
468
502
|
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
@@ -475,6 +509,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
475
509
|
| `IslandInvalidError` | `X_ISLAND_INVALID` | `src/errors.ts` |
|
|
476
510
|
| `IslandNotHydratedError` | `X_ISLAND_NOT_HYDRATED` | `src/errors.ts` |
|
|
477
511
|
| `IslandPropsInvalidError` | `X_ISLAND_PROPS_INVALID` | `src/errors.ts` |
|
|
512
|
+
| `NavigationModalPathInvalidError` | `X_NAVIGATION_MODAL_PATH_INVALID` | `src/navigation-errors.ts` (re-exported by `src/errors.ts`; also on `/client`) |
|
|
478
513
|
| `PrerenderFailedError` | `X_PRERENDER_FAILED` | `src/errors.ts` |
|
|
479
514
|
| `RouteDuplicateError` | `X_ROUTE_DUPLICATE` | `src/errors.ts` |
|
|
480
515
|
| `RouteFileInvalidError` | `X_ROUTE_FILE_INVALID` | `src/errors.ts` |
|
|
@@ -488,7 +523,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
488
523
|
|
|
489
524
|
## Public API
|
|
490
525
|
|
|
491
|
-
`†` marks a name on `@ultimat3/render/server`.
|
|
526
|
+
`†` marks a name on `@ultimat3/render/server`; `‡` a name also on `@ultimat3/render/client`.
|
|
492
527
|
|
|
493
528
|
| Export | Owns |
|
|
494
529
|
|---|---|
|
|
@@ -512,6 +547,9 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
512
547
|
| `ISLAND_HOLD_ATTRIBUTE`, `ISLAND_HOLD_MS`, `ISLAND_HOLD_REVEAL` | a held island's marker, and the cap after which it shows its server markup regardless |
|
|
513
548
|
| `parseByteBudget`, `defaultIslandBudget` | the `'40kb'` budget grammar, and the ceiling a declared island earns |
|
|
514
549
|
| `mergeHead`, `renderHead`, `themeScript` | `<head>` merge + the one inlined script; it stamps `data-theme` and, beside it, the fallback itself as `data-theme-default` for `@ultimat3/ui`'s `clearTheme`/`watchOsTheme` |
|
|
550
|
+
| `clientNavigationTags`, `linkVerdict`, `formVerdict`, `responseVerdict`, `reusable`, `mayPrefetch`, `NAVIGATION_*` (`NAVIGATION_ERROR_EVENT`‡), `NAVIGATE_EVENT`‡, `NAVIGATED_EVENT`‡ | the client router's document tags and its pure rules; the router itself is `@ultimat3/render/navigation`, built and served by the CLI |
|
|
551
|
+
| `navigate`‡, `refresh`‡, `openModal`‡, `closeModal`‡, `NavigateToOptions`‡, `NavigationModalPathInvalidError`‡ | navigating from code: the router when the page has one, the browser's own load when not. Import from `/client` in an island |
|
|
552
|
+
| `modalAddress`, `modalLocation`, `addressOf`, `presentation`, `modalHistory`, `leaveModal`, `NAVIGATION_PRESENTATION_META`, `NAVIGATION_MODAL_ATTRIBUTE` | route-presented modals (`navigation: 'modal'`): the hash grammar, when an answer is a modal, and what leaving one does to history |
|
|
515
553
|
| `clientPathStyleTags`, `CLIENT_PATH_STYLE_META` | `<meta name="ultimate-path-style">` — the action path style the server serves, for the browser's typed client. No tag for `'resource'`, the default |
|
|
516
554
|
|
|
517
555
|
## Notes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "26.
|
|
3
|
+
"version": "26.1.1",
|
|
4
4
|
"description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
},
|
|
21
21
|
"exports": {
|
|
22
22
|
".": "./src/index.ts",
|
|
23
|
+
"./client": "./src/client.ts",
|
|
23
24
|
"./server": "./src/server.ts",
|
|
24
25
|
"./navigation": "./src/navigation-entry.ts"
|
|
25
26
|
},
|
|
@@ -39,11 +40,11 @@
|
|
|
39
40
|
"test": "bun test"
|
|
40
41
|
},
|
|
41
42
|
"dependencies": {
|
|
42
|
-
"@ultimat3/cache": "26.
|
|
43
|
-
"@ultimat3/core": "26.
|
|
44
|
-
"@ultimat3/http": "26.
|
|
45
|
-
"@ultimat3/i18n": "26.
|
|
46
|
-
"@ultimat3/seo": "26.
|
|
43
|
+
"@ultimat3/cache": "26.1.1",
|
|
44
|
+
"@ultimat3/core": "26.1.1",
|
|
45
|
+
"@ultimat3/http": "26.1.1",
|
|
46
|
+
"@ultimat3/i18n": "26.1.1",
|
|
47
|
+
"@ultimat3/seo": "26.1.1",
|
|
47
48
|
"sass": "1.105.1"
|
|
48
49
|
}
|
|
49
50
|
}
|
package/src/client-sync-tags.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface ClientSyncHead {
|
|
|
11
11
|
readonly syncUrl: string;
|
|
12
12
|
readonly buildId: string;
|
|
13
13
|
readonly workerUrl?: string | undefined;
|
|
14
|
-
/** `/_x/page-boot/<hash>.js` — realtime's page boot, when the app has realtime. */
|
|
14
|
+
/** `/_x/assets/page-boot/<hash>.js` — realtime's page boot, when the app has realtime. */
|
|
15
15
|
readonly bootUrl?: string | undefined;
|
|
16
16
|
}
|
|
17
17
|
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// `@ultimat3/render/client` — THE browser entry of this package: what an island or a page script
|
|
2
|
+
// may call, and nothing that drags the rest along. The `.` barrel bare-retains `errors.ts` (render's
|
|
3
|
+
// whole code table, registered at import, plus core's titles tables); this entry's graph is
|
|
4
|
+
// `navigation-api.ts`, `navigation-errors.ts`, the pure rules and `@ultimat3/core/page`, held there
|
|
5
|
+
// by `client-bundle.test.ts`.
|
|
6
|
+
//
|
|
7
|
+
// import { navigate, openModal, refresh } from '@ultimat3/render/client';
|
|
8
|
+
//
|
|
9
|
+
// The same names stay exported from `.` for compatibility; an island imports them from here.
|
|
10
|
+
|
|
11
|
+
export type { NavigateToOptions } from './navigation-api';
|
|
12
|
+
export { closeModal, navigate, openModal, refresh } from './navigation-api';
|
|
13
|
+
export { NavigationModalPathInvalidError } from './navigation-errors';
|
|
14
|
+
export { NAVIGATE_EVENT, NAVIGATED_EVENT, NAVIGATION_ERROR_EVENT } from './navigation-rules';
|
package/src/errors.ts
CHANGED
|
@@ -28,6 +28,7 @@ export const RENDER_ERROR_CODES = [
|
|
|
28
28
|
'X_ASSET_MISSING',
|
|
29
29
|
'X_ROUTE_POST_INVALID',
|
|
30
30
|
'X_ROUTE_NAVIGATION_INVALID',
|
|
31
|
+
'X_NAVIGATION_MODAL_PATH_INVALID',
|
|
31
32
|
] as const;
|
|
32
33
|
|
|
33
34
|
export type RenderErrorCode = (typeof RENDER_ERROR_CODES)[number];
|
|
@@ -53,7 +54,9 @@ export const RENDER_ERROR_TITLES: Readonly<Record<RenderErrorCode, string>> = {
|
|
|
53
54
|
X_ASSET_MISSING: 'a page names a site asset the app does not have',
|
|
54
55
|
X_ROUTE_POST_INVALID: "a page route's post binding cannot be mounted",
|
|
55
56
|
X_ROUTE_NAVIGATION_INVALID:
|
|
56
|
-
"a route's navigation is not 'prefetch' or '
|
|
57
|
+
"a route's navigation is not 'prefetch', 'document' or 'modal', or its surface has no client navigation",
|
|
58
|
+
X_NAVIGATION_MODAL_PATH_INVALID:
|
|
59
|
+
'openModal was given something that is not a path of this origin',
|
|
57
60
|
};
|
|
58
61
|
|
|
59
62
|
// Titles must be registered for `format()` to render the contract's first line. Every code above is
|
|
@@ -205,7 +208,8 @@ export class RoutePostInvalidError extends UltimateError {
|
|
|
205
208
|
|
|
206
209
|
/**
|
|
207
210
|
* `defineRoute({ navigation })` that says nothing a process can honour: a value other than
|
|
208
|
-
* `'prefetch'` or `'
|
|
211
|
+
* `'prefetch'`, `'document'` or `'modal'`, or — at boot — a page whose surface has no client
|
|
212
|
+
* navigation.
|
|
209
213
|
*/
|
|
210
214
|
export class RouteNavigationInvalidError extends UltimateError {
|
|
211
215
|
static readonly code = 'X_ROUTE_NAVIGATION_INVALID' as const;
|
|
@@ -213,11 +217,16 @@ export class RouteNavigationInvalidError extends UltimateError {
|
|
|
213
217
|
super({
|
|
214
218
|
code: RouteNavigationInvalidError.code,
|
|
215
219
|
cause,
|
|
216
|
-
fix: "defineRoute({ ..., navigation: 'prefetch' | 'document' }) on a page whose surface is listed in app.config.ts navigation.client, or remove the key",
|
|
220
|
+
fix: "defineRoute({ ..., navigation: 'prefetch' | 'document' | 'modal' }) on a page whose surface is listed in app.config.ts navigation.client, or remove the key",
|
|
217
221
|
});
|
|
218
222
|
}
|
|
219
223
|
}
|
|
220
224
|
|
|
225
|
+
// `openModal(path)` with a path no hash can address on this origin. Re-exported, never re-declared:
|
|
226
|
+
// the class lives in `navigation-errors.ts` so `@ultimat3/render/client` constructs it without this
|
|
227
|
+
// module's table. One class, so `instanceof` holds from either entry.
|
|
228
|
+
export { NavigationModalPathInvalidError } from './navigation-errors';
|
|
229
|
+
|
|
221
230
|
export class RouteLoadInvalidError extends UltimateError {
|
|
222
231
|
static readonly code = 'X_ROUTE_LOAD_INVALID' as const;
|
|
223
232
|
constructor(cause: string, fix: string) {
|
package/src/index.ts
CHANGED
|
@@ -38,6 +38,7 @@ export {
|
|
|
38
38
|
IslandInvalidError,
|
|
39
39
|
IslandNotHydratedError,
|
|
40
40
|
IslandPropsInvalidError,
|
|
41
|
+
NavigationModalPathInvalidError,
|
|
41
42
|
PrerenderFailedError,
|
|
42
43
|
RENDER_ERROR_CODES,
|
|
43
44
|
RENDER_ERROR_TITLES,
|
|
@@ -106,9 +107,27 @@ export {
|
|
|
106
107
|
defaultIslandBudget,
|
|
107
108
|
MODE_SPECS,
|
|
108
109
|
} from './modes';
|
|
110
|
+
/**
|
|
111
|
+
* Navigating from code — through the router when the page has one, the browser's load when not.
|
|
112
|
+
* Kept here for compatibility; an island imports them from `@ultimat3/render/client` (`client.ts`),
|
|
113
|
+
* whose graph does not retain this barrel's `errors.ts` and the code tables behind it.
|
|
114
|
+
*/
|
|
115
|
+
export type { NavigateToOptions } from './navigation-api';
|
|
116
|
+
export { closeModal, navigate, openModal, refresh } from './navigation-api';
|
|
109
117
|
/** Client-side navigation over server-rendered documents — `navigation: { client }` surfaces. */
|
|
110
118
|
export type { CachedDocument, NavigationCache, NavigationCacheOptions } from './navigation-cache';
|
|
111
119
|
export { cacheKey, NAVIGATION_CACHE_MAX_ENTRIES, navigationCache } from './navigation-cache';
|
|
120
|
+
export type { Presentation, PresentationFacts } from './navigation-modal-rules';
|
|
121
|
+
export {
|
|
122
|
+
addressOf,
|
|
123
|
+
leaveModal,
|
|
124
|
+
modalAddress,
|
|
125
|
+
modalHistory,
|
|
126
|
+
modalLocation,
|
|
127
|
+
NAVIGATION_MODAL_ATTRIBUTE,
|
|
128
|
+
NAVIGATION_PRESENTATION_META,
|
|
129
|
+
presentation,
|
|
130
|
+
} from './navigation-modal-rules';
|
|
112
131
|
export type {
|
|
113
132
|
CachedFacts,
|
|
114
133
|
FormFacts,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigating from code: the client router's public face for an island or an app script —
|
|
3
|
+
* `navigate`, `refresh`, `openModal`, `closeModal`. Each goes through the router when the page
|
|
4
|
+
* carries one (`window.__xNavigation`, started by `@ultimat3/render/navigation`), and falls back to
|
|
5
|
+
* what the browser does without it — so the same call is right on a `site/` page, a
|
|
6
|
+
* `navigation: 'document'` page, or with the router's script blocked. Imports the router's TYPE
|
|
7
|
+
* only: an island that navigates never ships the router.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { NavigationRouter } from './navigation';
|
|
11
|
+
import { NavigationModalPathInvalidError } from './navigation-errors';
|
|
12
|
+
import { modalAddress } from './navigation-modal-rules';
|
|
13
|
+
|
|
14
|
+
export interface NavigateToOptions {
|
|
15
|
+
/** Replace the current history entry instead of pushing one (`location.replace` without the router). */
|
|
16
|
+
readonly replace?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface NavigationWindow {
|
|
20
|
+
readonly location: Location;
|
|
21
|
+
readonly __xNavigation?: NavigationRouter;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The page's window, or `undefined` on the server — where there is nothing to navigate. */
|
|
25
|
+
const page = (): NavigationWindow | undefined =>
|
|
26
|
+
(globalThis as { window?: NavigationWindow }).window;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Go to `url` (resolved against the page) as a click on a link to it would: through the router on
|
|
30
|
+
* this origin — its own rules and the server's gate decide swap, modal or full load — and as a
|
|
31
|
+
* document load anywhere else, or when the page has no router.
|
|
32
|
+
*/
|
|
33
|
+
export function navigate(url: string, options: NavigateToOptions = {}): Promise<void> {
|
|
34
|
+
const win = page();
|
|
35
|
+
if (win === undefined) return Promise.resolve();
|
|
36
|
+
const to = new URL(url, win.location.href);
|
|
37
|
+
const router = win.__xNavigation;
|
|
38
|
+
if (router === undefined || to.origin !== new URL(win.location.href).origin) {
|
|
39
|
+
if (options.replace === true) win.location.replace(to.href);
|
|
40
|
+
else win.location.assign(to.href);
|
|
41
|
+
return Promise.resolve();
|
|
42
|
+
}
|
|
43
|
+
return router.navigate(to.href, options.replace === true ? { history: 'replace' } : {});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The page on screen, rendered again by the server and swapped in where the visitor is scrolled —
|
|
48
|
+
* after a write the page's own islands do not reflect. Without the router: a reload.
|
|
49
|
+
*/
|
|
50
|
+
export function refresh(): Promise<void> {
|
|
51
|
+
const win = page();
|
|
52
|
+
if (win === undefined) return Promise.resolve();
|
|
53
|
+
if (win.__xNavigation === undefined) {
|
|
54
|
+
win.location.reload();
|
|
55
|
+
return Promise.resolve();
|
|
56
|
+
}
|
|
57
|
+
return win.__xNavigation.refresh();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Open the `navigation: 'modal'` route at `path` over the page on screen (`#<path>`), pushed so
|
|
62
|
+
* Back closes it. A path that is not a modal of this tab opens nothing, as a stale hash does.
|
|
63
|
+
* Without the router: the modal route's own whole page. A path no hash can address (another
|
|
64
|
+
* origin, `//host`, a relative one) is refused, `X_NAVIGATION_MODAL_PATH_INVALID`.
|
|
65
|
+
*/
|
|
66
|
+
export function openModal(path: string): Promise<void> {
|
|
67
|
+
const win = page();
|
|
68
|
+
const base = win?.location.href ?? 'http://localhost/';
|
|
69
|
+
if (!path.startsWith('/') || modalAddress(`#${path}`, base) === null) {
|
|
70
|
+
// Rejected, never thrown: one failure channel for an async call — `.catch` sees it.
|
|
71
|
+
return Promise.reject(new NavigationModalPathInvalidError(path));
|
|
72
|
+
}
|
|
73
|
+
if (win === undefined) return Promise.resolve();
|
|
74
|
+
if (win.__xNavigation === undefined) {
|
|
75
|
+
win.location.assign(new URL(path, base).href);
|
|
76
|
+
return Promise.resolve();
|
|
77
|
+
}
|
|
78
|
+
return win.__xNavigation.openModal(path);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Close the open modal as Escape does. Nothing is open without the router: nothing to do. */
|
|
82
|
+
export function closeModal(): void {
|
|
83
|
+
page()?.__xNavigation?.closeModal();
|
|
84
|
+
}
|
package/src/navigation-dom.ts
CHANGED
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
* browser. Split from `navigation.ts` so that file is the controller and nothing else.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { LinkFacts } from './navigation-rules';
|
|
8
|
-
import {
|
|
7
|
+
import type { FormFacts, LinkFacts } from './navigation-rules';
|
|
8
|
+
import {
|
|
9
|
+
linkVerdict,
|
|
10
|
+
mayPrefetch,
|
|
11
|
+
NAVIGATION_NO_PREFETCH_ATTRIBUTE,
|
|
12
|
+
NAVIGATION_RELOAD_ATTRIBUTE,
|
|
13
|
+
} from './navigation-rules';
|
|
9
14
|
|
|
10
15
|
/** A link as the rules read it; `event` absent for a hover, which is no click at all. */
|
|
11
16
|
export function linkFacts(
|
|
@@ -35,6 +40,24 @@ export function anchorOf(target: EventTarget | null): HTMLAnchorElement | HTMLAr
|
|
|
35
40
|
: null;
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
/** The URL a hover or focus on `target` may prefetch, or `undefined` (`linkVerdict`, `mayPrefetch`). */
|
|
44
|
+
export function intentUrl(win: Window, target: EventTarget | null): string | undefined {
|
|
45
|
+
const anchor = anchorOf(target);
|
|
46
|
+
if (anchor === null) return undefined;
|
|
47
|
+
const verdict = linkVerdict(linkFacts(win, anchor));
|
|
48
|
+
if (verdict.kind !== 'soft') return undefined;
|
|
49
|
+
const connection = (
|
|
50
|
+
win.navigator as { connection?: { saveData?: boolean; effectiveType?: string } }
|
|
51
|
+
).connection;
|
|
52
|
+
const may = mayPrefetch({
|
|
53
|
+
url: verdict.url,
|
|
54
|
+
noPrefetch: anchor.hasAttribute(NAVIGATION_NO_PREFETCH_ATTRIBUTE),
|
|
55
|
+
saveData: connection?.saveData,
|
|
56
|
+
effectiveType: connection?.effectiveType,
|
|
57
|
+
});
|
|
58
|
+
return may ? verdict.url : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
38
61
|
/** A field as a GET query carries it: a file by its name, as the browser does. */
|
|
39
62
|
export const fieldText = (value: unknown): string =>
|
|
40
63
|
value instanceof File ? value.name : String(value);
|
|
@@ -70,6 +93,35 @@ export function formFields(form: HTMLFormElement, submitter: HTMLElement | null)
|
|
|
70
93
|
}
|
|
71
94
|
}
|
|
72
95
|
|
|
96
|
+
/** A submit as the rules read it — the submitter's `form*` attributes over the form's — and its fields. */
|
|
97
|
+
export function submitFacts(
|
|
98
|
+
win: Window,
|
|
99
|
+
form: HTMLFormElement,
|
|
100
|
+
submitter: HTMLElement | null,
|
|
101
|
+
defaultPrevented: boolean,
|
|
102
|
+
): { readonly facts: FormFacts; readonly fields: FormData } {
|
|
103
|
+
const fields = formFields(form, submitter);
|
|
104
|
+
const say = (attr: string, fallback: string): string =>
|
|
105
|
+
submitter?.getAttribute(`form${attr}`) ?? form.getAttribute(attr) ?? fallback;
|
|
106
|
+
const facts: FormFacts = {
|
|
107
|
+
action: formAction(say('action', ''), win.location.href, win.document.baseURI),
|
|
108
|
+
current: win.location.href,
|
|
109
|
+
method: say('method', 'get').toLowerCase(),
|
|
110
|
+
enctype: say('enctype', 'application/x-www-form-urlencoded').toLowerCase(),
|
|
111
|
+
target: say('target', ''),
|
|
112
|
+
defaultPrevented,
|
|
113
|
+
reload:
|
|
114
|
+
form.hasAttribute(NAVIGATION_RELOAD_ATTRIBUTE) ||
|
|
115
|
+
submitter?.hasAttribute(NAVIGATION_RELOAD_ATTRIBUTE) === true,
|
|
116
|
+
// `unknown`: a browser yields a `File` for a file input, whatever a server-side type says.
|
|
117
|
+
hasFile: [...fields.values()].some(
|
|
118
|
+
(value: unknown) => value instanceof File && value.name !== '',
|
|
119
|
+
),
|
|
120
|
+
fields: formPairs(fields),
|
|
121
|
+
};
|
|
122
|
+
return { facts, fields };
|
|
123
|
+
}
|
|
124
|
+
|
|
73
125
|
const hush = (): void => undefined;
|
|
74
126
|
|
|
75
127
|
/**
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one refusal a BROWSER can reach in this package — `openModal`'s — apart from `errors.ts` for
|
|
3
|
+
* bytes: that module registers render's whole code table at import, and an island that calls
|
|
4
|
+
* `openModal` must not carry it. `errors.ts` re-exports this class, so it is ONE class wherever it is
|
|
5
|
+
* imported from and `instanceof` agrees across both entries. Without the table loaded, core titles
|
|
6
|
+
* the code from its name; the code, cause and fix are the same either way. Runs nothing at import.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { UltimateError } from '@ultimat3/core/page';
|
|
10
|
+
|
|
11
|
+
/** `openModal` was handed something no `#<path>` hash can address. Rejected, never thrown. */
|
|
12
|
+
export class NavigationModalPathInvalidError extends UltimateError {
|
|
13
|
+
static readonly code = 'X_NAVIGATION_MODAL_PATH_INVALID' as const;
|
|
14
|
+
constructor(path: string) {
|
|
15
|
+
super({
|
|
16
|
+
code: NavigationModalPathInvalidError.code,
|
|
17
|
+
cause: `openModal(${JSON.stringify(path)}): a modal is addressed by '#<path>', and this is not an absolute path on this origin`,
|
|
18
|
+
fix: "openModal('/runs/new') — the modal route's own path, as its link carries it; never a full URL or a relative one",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/navigation-fetch.ts
CHANGED
|
@@ -14,7 +14,8 @@ import {
|
|
|
14
14
|
CLIENT_NAVIGATION_SURFACE_HEADER,
|
|
15
15
|
CLIENT_SCOPE_META,
|
|
16
16
|
} from '@ultimat3/core/page';
|
|
17
|
-
import {
|
|
17
|
+
import type { NavigationCache } from './navigation-cache';
|
|
18
|
+
import { NAVIGATION_META, type ResponseFacts, reusable } from './navigation-rules';
|
|
18
19
|
|
|
19
20
|
/** One fetched answer, read once — a prefetch and the click after it share it. */
|
|
20
21
|
export interface Answer {
|
|
@@ -85,3 +86,53 @@ export function fetchDocument(
|
|
|
85
86
|
};
|
|
86
87
|
});
|
|
87
88
|
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A GET's answer: the prefetch held for `url` when it may stand in for the click (`reusable`), else
|
|
92
|
+
* `fetchAgain()`. Taken out of the cache either way — a held answer answers one click.
|
|
93
|
+
*/
|
|
94
|
+
export function heldOrFetched(
|
|
95
|
+
cache: NavigationCache<Answer>,
|
|
96
|
+
url: string,
|
|
97
|
+
fetchAgain: () => Promise<Answer>,
|
|
98
|
+
): Promise<Answer> {
|
|
99
|
+
const held = cache.peek(url);
|
|
100
|
+
cache.delete(url);
|
|
101
|
+
if (held === undefined) return fetchAgain();
|
|
102
|
+
return held.value.then(
|
|
103
|
+
(answer) =>
|
|
104
|
+
reusable({
|
|
105
|
+
status: answer.status,
|
|
106
|
+
html: answer.html !== null,
|
|
107
|
+
location: answer.location,
|
|
108
|
+
noStore: answer.noStore,
|
|
109
|
+
ageMs: held.ageMs,
|
|
110
|
+
})
|
|
111
|
+
? answer
|
|
112
|
+
: fetchAgain(),
|
|
113
|
+
fetchAgain,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What `responseVerdict` reads, off the answer, this document and the next one. */
|
|
118
|
+
export function responseFactsOf(
|
|
119
|
+
doc: Document,
|
|
120
|
+
next: Document | null,
|
|
121
|
+
answer: Answer,
|
|
122
|
+
asked: Pick<ResponseFacts, 'method' | 'requested' | 'hops'>,
|
|
123
|
+
): ResponseFacts {
|
|
124
|
+
const nextMeta = (name: string): string | null => (next === null ? null : metaOf(next, name));
|
|
125
|
+
return {
|
|
126
|
+
...asked,
|
|
127
|
+
status: answer.status,
|
|
128
|
+
opaqueRedirect: answer.opaqueRedirect,
|
|
129
|
+
location: answer.location,
|
|
130
|
+
contentType: answer.contentType,
|
|
131
|
+
surface: metaOf(doc, NAVIGATION_META),
|
|
132
|
+
nextSurface: nextMeta(NAVIGATION_META),
|
|
133
|
+
build: metaOf(doc, CLIENT_BUILD_META),
|
|
134
|
+
nextBuild: answer.build ?? nextMeta(CLIENT_BUILD_META),
|
|
135
|
+
scope: metaOf(doc, CLIENT_SCOPE_META),
|
|
136
|
+
nextScope: nextMeta(CLIENT_SCOPE_META),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -12,6 +12,8 @@ export interface EntryState {
|
|
|
12
12
|
readonly scroll: readonly [number, number];
|
|
13
13
|
/** The document this entry shows — what back/forward compares with the one on screen. */
|
|
14
14
|
readonly doc: string;
|
|
15
|
+
/** The modal open over `doc` (`navigation-modal-rules.ts`), on an entry this router pushed. */
|
|
16
|
+
readonly modal?: string;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
/** Back/forward, as the router passes it: `none` restores; the others start at the top. */
|
|
@@ -38,7 +40,12 @@ export function saveEntryScroll(win: Window, rendered: string): void {
|
|
|
38
40
|
const state = (win.history.state ?? {}) as Record<string, unknown>;
|
|
39
41
|
const held = entryOf(state)?.doc;
|
|
40
42
|
if (held !== undefined && held !== rendered) return;
|
|
41
|
-
|
|
43
|
+
// Spread: the entry's modal mark is what lets Escape go Back (`navigation-modal.ts`).
|
|
44
|
+
const entry: EntryState = {
|
|
45
|
+
...entryOf(state),
|
|
46
|
+
scroll: [win.scrollX, win.scrollY],
|
|
47
|
+
doc: held ?? rendered,
|
|
48
|
+
};
|
|
42
49
|
win.history.replaceState({ ...state, [STATE_KEY]: entry }, '');
|
|
43
50
|
}
|
|
44
51
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route-presented modals, as pure rules: the hash grammar (`/runs#/runs/new` — the PATH is the page
|
|
3
|
+
* on screen, the HASH is the one modal over it), whether an answer is shown as a modal, as the page,
|
|
4
|
+
* or dropped back to "just the page", and what leaving a modal does to history. The controller
|
|
5
|
+
* (`navigation-modal.ts`) only gathers the facts; every rule is testable without a DOM.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ResponseVerdict } from './navigation-rules';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `<meta name="ultimate-presentation" content="modal">` — on the document of a route that declared
|
|
12
|
+
* `navigation: 'modal'`. The SERVER's word that this page is presented over another: the router
|
|
13
|
+
* never decides it from a link, so a redirect target and a hand-typed hash are answered alike.
|
|
14
|
+
*/
|
|
15
|
+
export const NAVIGATION_PRESENTATION_META = 'ultimate-presentation';
|
|
16
|
+
/** On the `<dialog>` the router opens. `@ultimat3/ui`'s `global.scss` styles it, by this name. */
|
|
17
|
+
export const NAVIGATION_MODAL_ATTRIBUTE = 'data-x-modal';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The modal a hash addresses — `#/runs/new?x=1` → `/runs/new?x=1` — or `null` for every other
|
|
21
|
+
* fragment (`#below`, `#`, `#//host`, one that leaves the origin). NEVER throws: a stale or
|
|
22
|
+
* hand-edited hash degrades to "just the page", it is never an error.
|
|
23
|
+
*/
|
|
24
|
+
export function modalAddress(hash: string, base: string): string | null {
|
|
25
|
+
// `#//host` would resolve to another origin; a backslash is a slash to the URL parser.
|
|
26
|
+
if (!hash.startsWith('#/') || hash.startsWith('#//') || hash.includes('\\')) return null;
|
|
27
|
+
const said = hash.slice(1);
|
|
28
|
+
if (!URL.canParse(base) || !URL.canParse(said, base)) return null;
|
|
29
|
+
const url = new URL(said, base);
|
|
30
|
+
if (url.origin !== new URL(base).origin) return null;
|
|
31
|
+
return `${url.pathname}${url.search}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The URL the address bar shows for `address` over the page `page`: `/runs#/runs/new`. */
|
|
35
|
+
export const modalLocation = (page: string, address: string): string =>
|
|
36
|
+
`${page.split('#')[0] ?? page}#${address}`;
|
|
37
|
+
|
|
38
|
+
/** `/runs/new?x=1` of an absolute URL — the hash a modal at that URL is addressed by. */
|
|
39
|
+
export const addressOf = (url: string): string => {
|
|
40
|
+
const parsed = new URL(url);
|
|
41
|
+
return `${parsed.pathname}${parsed.search}`;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* - `modal`: shown in the dialog over the page on screen.
|
|
46
|
+
* - `page`: the router's ordinary handling (a swap, a follow, a load…).
|
|
47
|
+
* - `degrade`: the hash named something that is not a modal for this tab — the hash is dropped in
|
|
48
|
+
* place and the page stays, with nothing loaded, followed or shown as an error.
|
|
49
|
+
*/
|
|
50
|
+
export type Presentation = 'modal' | 'page' | 'degrade';
|
|
51
|
+
|
|
52
|
+
/** What decides a presentation, read off the answer, the tab and how the request came about. */
|
|
53
|
+
export interface PresentationFacts {
|
|
54
|
+
/** The address came from the URL's hash (a load, Back/Forward, a typed hash), not a click. */
|
|
55
|
+
readonly fromHash: boolean;
|
|
56
|
+
readonly method: 'GET' | 'POST';
|
|
57
|
+
readonly verdict: ResponseVerdict['kind'];
|
|
58
|
+
readonly status: number;
|
|
59
|
+
/** The answer carries `NAVIGATION_PRESENTATION_META` = `modal`. */
|
|
60
|
+
readonly modalPage: boolean;
|
|
61
|
+
/** A modal is open now. */
|
|
62
|
+
readonly modalOpen: boolean;
|
|
63
|
+
/** The answered URL, and the page on screen — both without a fragment. */
|
|
64
|
+
readonly landed: string;
|
|
65
|
+
readonly rendered: string;
|
|
66
|
+
/** The answer belongs to another principal or build (`answerMovesTab`). */
|
|
67
|
+
readonly movesTab: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function presentation(facts: PresentationFacts): Presentation {
|
|
71
|
+
const modal = facts.verdict === 'swap' && facts.modalPage && !facts.movesTab;
|
|
72
|
+
// From the hash, only a 2xx modal of this tab opens; anything else (a 404, a redirect, a page that
|
|
73
|
+
// is not a modal, another principal) is a stale address — never followed, never loaded.
|
|
74
|
+
if (facts.fromHash) {
|
|
75
|
+
return modal && facts.status >= 200 && facts.status < 300 ? 'modal' : 'degrade';
|
|
76
|
+
}
|
|
77
|
+
if (!modal) return 'page';
|
|
78
|
+
// A POST re-renders a modal only from inside one: the full page's own form stays a page.
|
|
79
|
+
if (facts.method === 'POST') return facts.modalOpen ? 'modal' : 'page';
|
|
80
|
+
// A link to the page on screen is that page, whatever it declared.
|
|
81
|
+
return facts.landed === facts.rendered ? 'page' : 'modal';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The history move for a modal shown: a POST answered in place re-renders it where it is (the
|
|
86
|
+
* entry is the modal's already); Back/Forward and a hash move nothing; a click pushes, so Back
|
|
87
|
+
* steps out of it (nesting deeper is a deeper path in the same dialog, with its own Back stop).
|
|
88
|
+
*/
|
|
89
|
+
export function modalHistory(
|
|
90
|
+
method: 'GET' | 'POST',
|
|
91
|
+
history: 'push' | 'replace' | 'none' | undefined,
|
|
92
|
+
): 'push' | 'replace' | 'none' {
|
|
93
|
+
if (history === 'none') return 'none';
|
|
94
|
+
return method === 'POST' ? 'replace' : (history ?? 'push');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Leaving an open modal for a PAGE: the modal's entry is never left behind to reopen a spent form
|
|
99
|
+
* on Back. Onto the page beneath, through an entry this router pushed: Back to it (two entries for
|
|
100
|
+
* one URL read as a Back that did nothing). Anywhere else: the modal's entry is replaced.
|
|
101
|
+
*/
|
|
102
|
+
export function leaveModal(facts: {
|
|
103
|
+
readonly history: 'push' | 'replace' | 'none' | undefined;
|
|
104
|
+
readonly landed: string;
|
|
105
|
+
readonly rendered: string;
|
|
106
|
+
readonly owned: boolean;
|
|
107
|
+
}): 'back' | 'replace' | 'none' {
|
|
108
|
+
if (facts.history === 'none') return 'none';
|
|
109
|
+
return facts.landed === facts.rendered && facts.owned ? 'back' : 'replace';
|
|
110
|
+
}
|