netjes 0.0.2 → 0.0.4
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/README.md +13 -11
- package/cli/agents.mjs +25 -2
- package/cli/commands.mjs +56 -6
- package/cli/help.mjs +7 -2
- package/cli/info.mjs +275 -33
- package/cli/routes.mjs +35 -8
- package/cli/scaffold.mjs +31 -4
- package/dist/actions.d.ts +7 -2
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +9 -3
- package/dist/actions.js.map +1 -1
- package/dist/client-entry.d.ts +1 -0
- package/dist/client-entry.d.ts.map +1 -1
- package/dist/client-entry.js +19 -2
- package/dist/client-entry.js.map +1 -1
- package/dist/deferred.d.ts +11 -0
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +15 -1
- package/dist/deferred.js.map +1 -1
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +10 -1
- package/dist/document.js.map +1 -1
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +11 -0
- package/dist/errors.js.map +1 -0
- package/dist/form.d.ts +5 -5
- package/dist/form.d.ts.map +1 -1
- package/dist/form.js +5 -2
- package/dist/form.js.map +1 -1
- package/dist/href.d.ts +31 -3
- package/dist/href.d.ts.map +1 -1
- package/dist/href.js +131 -10
- package/dist/href.js.map +1 -1
- package/dist/image.d.ts +3 -3
- package/dist/image.d.ts.map +1 -1
- package/dist/image.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/link.d.ts +15 -7
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +38 -7
- package/dist/link.js.map +1 -1
- package/dist/metadata.d.ts +8 -2
- package/dist/metadata.d.ts.map +1 -1
- package/dist/metadata.js +14 -5
- package/dist/metadata.js.map +1 -1
- package/dist/modules.d.ts +15 -1
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js.map +1 -1
- package/dist/mounted.d.ts +55 -0
- package/dist/mounted.d.ts.map +1 -0
- package/dist/mounted.js +110 -0
- package/dist/mounted.js.map +1 -0
- package/dist/navigation-focus.d.ts +38 -0
- package/dist/navigation-focus.d.ts.map +1 -0
- package/dist/navigation-focus.js +71 -0
- package/dist/navigation-focus.js.map +1 -0
- package/dist/payload.d.ts +8 -0
- package/dist/payload.d.ts.map +1 -1
- package/dist/payload.js +28 -0
- package/dist/payload.js.map +1 -1
- package/dist/query.d.ts +5 -2
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +11 -8
- package/dist/query.js.map +1 -1
- package/dist/render-document.d.ts +74 -0
- package/dist/render-document.d.ts.map +1 -0
- package/dist/render-document.js +393 -0
- package/dist/render-document.js.map +1 -0
- package/dist/route-announcer.d.ts +18 -0
- package/dist/route-announcer.d.ts.map +1 -0
- package/dist/route-announcer.js +58 -0
- package/dist/route-announcer.js.map +1 -0
- package/dist/route-tree.d.ts +6 -2
- package/dist/route-tree.d.ts.map +1 -1
- package/dist/route-tree.js +17 -7
- package/dist/route-tree.js.map +1 -1
- package/dist/router.d.ts +28 -5
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +71 -26
- package/dist/router.js.map +1 -1
- package/dist/script.d.ts +28 -5
- package/dist/script.d.ts.map +1 -1
- package/dist/script.js +192 -21
- package/dist/script.js.map +1 -1
- package/dist/server-render.d.ts.map +1 -1
- package/dist/server-render.js +4 -1
- package/dist/server-render.js.map +1 -1
- package/dist/testing-vitest.d.ts +8 -0
- package/dist/testing-vitest.d.ts.map +1 -0
- package/dist/testing-vitest.js +26 -0
- package/dist/testing-vitest.js.map +1 -0
- package/dist/testing.d.ts +17 -1
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +18 -3
- package/dist/testing.js.map +1 -1
- package/dist/zod-jitless.d.ts +2 -0
- package/dist/zod-jitless.d.ts.map +1 -0
- package/dist/zod-jitless.js +4 -0
- package/dist/zod-jitless.js.map +1 -0
- package/package.json +1 -1
- package/sidecar/server.mjs +109 -15
- package/skills/netjes/SKILL.md +15 -7
- package/skills/netjes/references/caching-and-streaming.md +37 -4
- package/skills/netjes/references/data-and-actions.md +15 -7
- package/skills/netjes/references/deployment.md +7 -3
- package/skills/netjes/references/routing.md +32 -9
- package/skills/netjes/references/testing.md +23 -7
- package/src/actions.tsx +9 -3
- package/src/client-entry.tsx +20 -2
- package/src/deferred.ts +14 -1
- package/src/document.tsx +22 -3
- package/src/errors.ts +12 -0
- package/src/form.tsx +10 -7
- package/src/href.ts +137 -11
- package/src/image.tsx +3 -3
- package/src/index.ts +19 -1
- package/src/link.tsx +47 -13
- package/src/metadata.tsx +19 -6
- package/src/modules.ts +15 -2
- package/src/mounted.tsx +160 -0
- package/src/navigation-focus.ts +75 -0
- package/src/payload.ts +32 -0
- package/src/query.tsx +14 -8
- package/src/render-document.tsx +483 -0
- package/src/route-announcer.tsx +69 -0
- package/src/route-tree.tsx +17 -6
- package/src/router.tsx +116 -31
- package/src/script.tsx +214 -23
- package/src/server-render.tsx +4 -1
- package/src/testing-vitest.ts +37 -0
- package/src/testing.tsx +38 -5
- package/src/virtual.d.ts +8 -1
- package/src/zod-jitless.ts +15 -0
- package/template/.dockerignore +3 -0
- package/template/Dockerfile +11 -1
- package/template/README.md +11 -7
- package/template/_AGENTS.md +38 -9
- package/template/__NAME__.csproj +2 -0
- package/template/app/document.test.ts +29 -0
- package/template/app/error.tsx +3 -2
- package/template/app/layout.tsx +10 -1
- package/template/docker-compose.override.yml +4 -3
- package/template/docker-compose.prod.yml +2 -1
- package/template/e2e/home.spec.ts +32 -0
- package/template/generated/netjes.ts +43 -14
- package/template/package.json +4 -1
- package/template/playwright.config.ts +36 -0
- package/template/tests/__NAME__.Tests/AppFactory.cs +31 -0
- package/template/tests/__NAME__.Tests/HomePageTests.cs +55 -0
- package/template/tests/__NAME__.Tests/__NAME__.Tests.csproj +30 -0
- package/template/tsconfig.app.json +20 -0
- package/template/tsconfig.json +2 -17
- package/template/tsconfig.node.json +8 -0
- package/vite/packages.mjs +345 -0
- package/vite/plugin.d.ts +16 -1
- package/vite/plugin.mjs +224 -34
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type CSSProperties, useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Out of sight and still read by screen readers. React sets these styles through the CSSOM on an element it creates
|
|
5
|
+
* in the browser, which a Content-Security-Policy without `'unsafe-inline'` in `style-src` allows.
|
|
6
|
+
*/
|
|
7
|
+
const VISUALLY_HIDDEN: CSSProperties = {
|
|
8
|
+
position: 'absolute',
|
|
9
|
+
width: 1,
|
|
10
|
+
height: 1,
|
|
11
|
+
margin: -1,
|
|
12
|
+
padding: 0,
|
|
13
|
+
border: 0,
|
|
14
|
+
overflow: 'hidden',
|
|
15
|
+
clip: 'rect(0 0 0 0)',
|
|
16
|
+
clipPath: 'inset(50%)',
|
|
17
|
+
whiteSpace: 'nowrap',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const subscribe = () => () => {};
|
|
21
|
+
const inBrowser = () => true;
|
|
22
|
+
const onServer = () => false;
|
|
23
|
+
|
|
24
|
+
/** A segment as a person reads it, `café` rather than `caf%C3%A9`; one that does not decode stays as it is. */
|
|
25
|
+
function decodeSegment(segment: string): string {
|
|
26
|
+
try {
|
|
27
|
+
return decodeURIComponent(segment);
|
|
28
|
+
} catch {
|
|
29
|
+
return segment;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** How a screen reader can name the page on screen: its title, else its first `h1`, else its path, decoded. */
|
|
34
|
+
function pageName(path: string): string {
|
|
35
|
+
return document.title.trim() || document.querySelector('h1')?.textContent?.trim() || path.split('/').map(decodeSegment).join('/');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type RouteAnnouncerProps = {
|
|
39
|
+
/** The page's path and query: a navigation that changes neither (a refresh, a fragment link) names no page. */
|
|
40
|
+
location: string;
|
|
41
|
+
/** The path without the base path, the name of a page with neither a title nor an `h1`. */
|
|
42
|
+
path: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A polite live region that names the page after a client navigation, as a screen reader names the page a document
|
|
47
|
+
* load opens. It renders in the browser only, after hydration, so the server's HTML has none. It has no role, which
|
|
48
|
+
* would make it one more `status` or `alert` for an app's tests to find. Its effect runs after the app's, as a later
|
|
49
|
+
* sibling of the pages, so it reads the title and headings the new page has set. The first run records the name of
|
|
50
|
+
* the page the document loaded with; a later one, for another path or query, puts the new page's name in the region
|
|
51
|
+
* when it differs from the name of the page before. An intercepted page keeps the title of the page under it, so it
|
|
52
|
+
* is not named.
|
|
53
|
+
*/
|
|
54
|
+
export function RouteAnnouncer({ location, path }: RouteAnnouncerProps) {
|
|
55
|
+
const rendered = useSyncExternalStore(subscribe, inBrowser, onServer);
|
|
56
|
+
const [message, setMessage] = useState('');
|
|
57
|
+
const named = useRef<string | null>(null);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const name = pageName(path);
|
|
60
|
+
if (named.current !== null && name !== named.current) setMessage(name);
|
|
61
|
+
named.current = name;
|
|
62
|
+
}, [location, path]);
|
|
63
|
+
if (!rendered) return null;
|
|
64
|
+
return (
|
|
65
|
+
<div aria-live="polite" aria-atomic="true" data-netjes-route-announcer="" style={VISUALLY_HIDDEN}>
|
|
66
|
+
{message}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
package/src/route-tree.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Component, type ReactElement, type ReactNode, Suspense, createContext } from 'react';
|
|
2
|
+
import { withheldError } from './errors.ts';
|
|
2
3
|
import { type Metadata, MetadataTags, resolveMetadata } from './metadata.tsx';
|
|
3
4
|
import type { ErrorPageProps, LoadedSegment, RouteModules } from './modules.ts';
|
|
4
5
|
import type { PageKind, Params, SegmentRef } from './payload.ts';
|
|
@@ -17,6 +18,8 @@ type RouteTreeProps = {
|
|
|
17
18
|
pageKey: string;
|
|
18
19
|
/** What `reset` does on an error page the server chose: load the page again. */
|
|
19
20
|
retry?: () => void;
|
|
21
|
+
/** The error an error page (`pageKind` error) gets in place of the one the server's props describe: one caught here. */
|
|
22
|
+
error?: ErrorPageProps['error'];
|
|
20
23
|
/** Metadata from the C# loader. */
|
|
21
24
|
metadata?: Metadata | undefined;
|
|
22
25
|
};
|
|
@@ -53,15 +56,23 @@ export class ErrorBoundary extends Component<BoundaryProps, { error: unknown; fa
|
|
|
53
56
|
}
|
|
54
57
|
}
|
|
55
58
|
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
/** What a boundary caught, as the Error `error.tsx` gets: an Error as it is, a string as its message, else a stand-in. */
|
|
60
|
+
export function asError(error: unknown): ErrorPageProps['error'] {
|
|
61
|
+
if (error instanceof Error) return error;
|
|
62
|
+
return typeof error === 'string' ? new Error(error) : withheldError();
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* The error the server reported (props of the error page it rendered), as an Error for `error.tsx`: marked withheld
|
|
67
|
+
* when the server says its message stands in for the exception's, and with the server's stack or none, never the
|
|
68
|
+
* stack of the code here that built it.
|
|
69
|
+
*/
|
|
61
70
|
function serverError(props: Record<string, unknown>): ErrorPageProps['error'] {
|
|
62
71
|
const error: ErrorPageProps['error'] = new Error(typeof props.message === 'string' ? props.message : 'Something went wrong');
|
|
63
72
|
if (typeof props.status === 'number') error.status = props.status;
|
|
64
|
-
if (
|
|
73
|
+
if (props.withheld === true) error.withheld = true;
|
|
74
|
+
// Defined rather than assigned, as an own property: Firefox keeps the stack in an accessor on Error.prototype.
|
|
75
|
+
Object.defineProperty(error, 'stack', { value: typeof props.stack === 'string' ? props.stack : undefined, writable: true, configurable: true });
|
|
65
76
|
return error;
|
|
66
77
|
}
|
|
67
78
|
|
|
@@ -70,9 +81,9 @@ function serverError(props: Record<string, unknown>): ErrorPageProps['error'] {
|
|
|
70
81
|
* layout > template > error boundary > Suspense (loading) > the next segment or the page. Layouts keep their state
|
|
71
82
|
* while the page underneath changes; templates remount.
|
|
72
83
|
*/
|
|
73
|
-
export function RouteTree({ modules, segments = [], props, params, status, pageKind, pageKey, retry, metadata }: RouteTreeProps): ReactElement {
|
|
84
|
+
export function RouteTree({ modules, segments = [], props, params, status, pageKind, pageKey, retry, metadata, error }: RouteTreeProps): ReactElement {
|
|
74
85
|
const Page = modules.page;
|
|
75
|
-
const pageProps = pageKind === 'error' ? { ...props, error: serverError(props), reset: retry ?? (() => {}) } : props;
|
|
86
|
+
const pageProps = pageKind === 'error' ? { ...props, error: error ?? serverError(props), reset: retry ?? (() => {}) } : props;
|
|
76
87
|
let element: ReactElement = Page ? <Page key={pageKey} {...pageProps} /> : <BuiltInPage key={pageKey} status={status} props={props} />;
|
|
77
88
|
for (let i = modules.segments.length - 1; i >= 0; i--) {
|
|
78
89
|
const { Layout, Template, Loading, Error: SegmentError, slots } = modules.segments[i]!;
|
package/src/router.tsx
CHANGED
|
@@ -9,13 +9,15 @@ import {
|
|
|
9
9
|
useEffect,
|
|
10
10
|
useLayoutEffect,
|
|
11
11
|
useMemo,
|
|
12
|
+
useRef,
|
|
12
13
|
useState,
|
|
13
14
|
} from 'react';
|
|
14
15
|
import { actionRequest, expectActionClient, fetchAction, setActionClient } from './actions.tsx';
|
|
15
16
|
import { deferredRegistry, revivePayload } from './deferred.ts';
|
|
16
17
|
import type { EncodedForm } from './form-data.ts';
|
|
17
18
|
import type { Href } from './href.ts';
|
|
18
|
-
import { MissingModuleError, type ModuleLoader, type RouteModules } from './modules.ts';
|
|
19
|
+
import { type ErrorPageProps, MissingModuleError, type ModuleLoader, type RouteModules } from './modules.ts';
|
|
20
|
+
import { moveFocusAfterNavigation } from './navigation-focus.ts';
|
|
19
21
|
import {
|
|
20
22
|
type ActionResponse,
|
|
21
23
|
type AntiforgeryToken,
|
|
@@ -31,7 +33,8 @@ import {
|
|
|
31
33
|
parseAntiforgery,
|
|
32
34
|
parseNavigationPayload,
|
|
33
35
|
} from './payload.ts';
|
|
34
|
-
import {
|
|
36
|
+
import { RouteAnnouncer } from './route-announcer.tsx';
|
|
37
|
+
import { ErrorBoundary, LayoutDepthContext, RouteTree, asError } from './route-tree.tsx';
|
|
35
38
|
import { type StandardSchemaV1, parseLenient, parseWith } from './schema.ts';
|
|
36
39
|
|
|
37
40
|
/** A form post that did not produce a page: a non-page error response (`status`), or no response at all (`null`). */
|
|
@@ -44,11 +47,19 @@ export type NavigationState =
|
|
|
44
47
|
|
|
45
48
|
/** The client router that `useRouter()` returns. */
|
|
46
49
|
export type Router = {
|
|
50
|
+
/**
|
|
51
|
+
* Navigates, adding a history entry. The promise resolves once the router has handed the page to React, which can be
|
|
52
|
+
* before React shows it, or once the navigation has ended another way.
|
|
53
|
+
*/
|
|
47
54
|
push(href: Href, options?: { scroll?: boolean }): Promise<void>;
|
|
55
|
+
/** Navigates, replacing the history entry. The promise resolves as `push`'s does. */
|
|
48
56
|
replace(href: Href, options?: { scroll?: boolean }): Promise<void>;
|
|
49
|
-
/** Fetches the page payload and modules ahead of a navigation. Links do this on hover and
|
|
57
|
+
/** Fetches the page payload and modules ahead of a navigation. Links do this on hover, focus and touch. */
|
|
50
58
|
prefetch(href: Href): void;
|
|
51
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* Runs every loader of the page on screen again, layout loaders included, and renders the page with the new props, in
|
|
61
|
+
* the same history entry and without scrolling. The promise resolves as `push`'s does.
|
|
62
|
+
*/
|
|
52
63
|
refresh(): Promise<void>;
|
|
53
64
|
back(): void;
|
|
54
65
|
forward(): void;
|
|
@@ -59,8 +70,17 @@ export type SubmitBody = EncodedForm;
|
|
|
59
70
|
/** Called with every server action response, e.g. by the query shell to apply query updates. */
|
|
60
71
|
export type ActionListener = (response: ActionResponse) => void;
|
|
61
72
|
|
|
62
|
-
|
|
63
|
-
|
|
73
|
+
/**
|
|
74
|
+
* What `<Link>` and `<Form>` also pass: what had the focus before the app's own `onClick` or `onSubmit` ran. Those run
|
|
75
|
+
* as the navigation starts, so where they move the focus, such as back to the button of a menu that closes, is where
|
|
76
|
+
* the navigation put it.
|
|
77
|
+
*/
|
|
78
|
+
type FocusAtStart = { focusedAtStart?: Element | null };
|
|
79
|
+
|
|
80
|
+
export type InternalRouter = Omit<Router, 'push' | 'replace'> & {
|
|
81
|
+
push(href: Href, options?: { scroll?: boolean } & FocusAtStart): Promise<void>;
|
|
82
|
+
replace(href: Href, options?: { scroll?: boolean } & FocusAtStart): Promise<void>;
|
|
83
|
+
submit(action: URL, body: SubmitBody, options?: FocusAtStart): Promise<void>;
|
|
64
84
|
onActionResponse(listener: ActionListener): () => void;
|
|
65
85
|
/**
|
|
66
86
|
* Called as soon as a server action call or form post says it set or deleted a cookie (signed someone in or out),
|
|
@@ -89,6 +109,13 @@ type PageRequest = { method: 'GET' } | ({ method: 'POST' } & SubmitBody);
|
|
|
89
109
|
function isLoaded(result: LoadResult): result is Loaded {
|
|
90
110
|
return 'payload' in result;
|
|
91
111
|
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* What remounts the page and its templates when it changes: the page's path, or for an intercepted page the key of the
|
|
115
|
+
* page under it, which stays on screen. Two views with one key show the same page.
|
|
116
|
+
*/
|
|
117
|
+
const pageKeyOf = (loaded: Loaded) => loaded.pageKey ?? loaded.url.pathname;
|
|
118
|
+
|
|
92
119
|
type ScrollIntent = { kind: 'none' } | { kind: 'top' } | { kind: 'hash'; hash: string } | { kind: 'restore'; x: number; y: number };
|
|
93
120
|
type HistoryMode = 'push' | 'replace' | 'none' | 'auto';
|
|
94
121
|
|
|
@@ -97,6 +124,13 @@ const ANTIFORGERY_URL = '/_netjes/antiforgery';
|
|
|
97
124
|
const NONE: ScrollIntent = { kind: 'none' };
|
|
98
125
|
const TOP: ScrollIntent = { kind: 'top' };
|
|
99
126
|
const STATE_KEY = '__netjes';
|
|
127
|
+
/**
|
|
128
|
+
* On `<html>` while the client router runs in the document, and the public sign that it does: end-to-end tests wait
|
|
129
|
+
* for it before they click. (The history entry's key is no such sign: the entry keeps it when the browser loads the
|
|
130
|
+
* document again.) The effect that sets it must stay after the one that registers the router as the action client,
|
|
131
|
+
* so that once it shows, the router has keyed the entry, listens for back and forward, and runs server action calls.
|
|
132
|
+
*/
|
|
133
|
+
const HYDRATED_ATTRIBUTE = 'data-netjes-hydrated';
|
|
100
134
|
const PREFETCH_TTL_MS = 30_000;
|
|
101
135
|
const MAX_REDIRECTS = 10;
|
|
102
136
|
const MAX_REMEMBERED_ENTRIES = 50;
|
|
@@ -281,7 +315,8 @@ type NavigatorDeps = {
|
|
|
281
315
|
showDocument(html: string, url: string): void;
|
|
282
316
|
download(file: Blob, name: string): void;
|
|
283
317
|
setPending(state: NavigationState): void;
|
|
284
|
-
|
|
318
|
+
/** `focusedAtStart`: what had the focus when the navigation started, which decides where the focus goes after it. */
|
|
319
|
+
commit(loaded: Loaded, scroll: ScrollIntent, focusedAtStart: Element | null): void;
|
|
285
320
|
};
|
|
286
321
|
|
|
287
322
|
type GoOptions = {
|
|
@@ -294,6 +329,8 @@ type GoOptions = {
|
|
|
294
329
|
reuseSegments: boolean;
|
|
295
330
|
/** Refreshing an intercepted page: load this page under it again too, and show the result in its slot again. */
|
|
296
331
|
interceptOver?: URL;
|
|
332
|
+
/** What had the focus as the navigation started, when the caller read it: else go() reads it as it starts. */
|
|
333
|
+
focusedAtStart?: Element | null;
|
|
297
334
|
};
|
|
298
335
|
|
|
299
336
|
/**
|
|
@@ -369,7 +406,8 @@ class Navigator {
|
|
|
369
406
|
this.channel = null;
|
|
370
407
|
}
|
|
371
408
|
|
|
372
|
-
|
|
409
|
+
/** `focusedAtStart`: what had the focus as the navigation started, by default what has it as this is called. */
|
|
410
|
+
async navigate(href: string, history: 'push' | 'replace', scroll: boolean, focusedAtStart = document.activeElement): Promise<void> {
|
|
373
411
|
const url = new URL(withBasePath(href, this.deps.basePath), window.location.href);
|
|
374
412
|
if (!isWebUrl(url)) {
|
|
375
413
|
console.error(`netjes: refusing to navigate to ${url.protocol} URL`);
|
|
@@ -387,7 +425,7 @@ class Navigator {
|
|
|
387
425
|
const key = createKey();
|
|
388
426
|
window.history[history === 'push' ? 'pushState' : 'replaceState']({ [STATE_KEY]: key }, '', url.href);
|
|
389
427
|
this.setCurrent(key, { ...this.current, url });
|
|
390
|
-
this.deps.commit(this.current, scroll ? { kind: 'hash', hash: url.hash } : NONE);
|
|
428
|
+
this.deps.commit(this.current, scroll ? { kind: 'hash', hash: url.hash } : NONE, focusedAtStart);
|
|
391
429
|
return;
|
|
392
430
|
}
|
|
393
431
|
|
|
@@ -397,11 +435,16 @@ class Navigator {
|
|
|
397
435
|
pending: 'loading',
|
|
398
436
|
usePrefetch: true,
|
|
399
437
|
reuseSegments: true,
|
|
438
|
+
focusedAtStart,
|
|
400
439
|
});
|
|
401
440
|
}
|
|
402
441
|
|
|
403
|
-
submit(action: URL, body: SubmitBody): Promise<void> {
|
|
404
|
-
return this.go(
|
|
442
|
+
submit(action: URL, body: SubmitBody, focusedAtStart = document.activeElement): Promise<void> {
|
|
443
|
+
return this.go(
|
|
444
|
+
action,
|
|
445
|
+
{ method: 'POST', ...body },
|
|
446
|
+
{ history: 'auto', scroll: 'auto', pending: 'submitting', usePrefetch: false, reuseSegments: false, focusedAtStart },
|
|
447
|
+
);
|
|
405
448
|
}
|
|
406
449
|
|
|
407
450
|
/**
|
|
@@ -553,13 +596,17 @@ class Navigator {
|
|
|
553
596
|
if (known) {
|
|
554
597
|
this.active?.controller.abort();
|
|
555
598
|
this.setCurrent(key, { ...known, url });
|
|
556
|
-
this.deps.commit(this.current, scroll);
|
|
599
|
+
this.deps.commit(this.current, scroll, document.activeElement);
|
|
557
600
|
return;
|
|
558
601
|
}
|
|
559
602
|
void this.go(url, { method: 'GET' }, { history: 'none', scroll, pending: 'loading', key, usePrefetch: false, reuseSegments: true });
|
|
560
603
|
}
|
|
561
604
|
|
|
562
605
|
private async go(url: URL, request: PageRequest, options: GoOptions): Promise<void> {
|
|
606
|
+
// Read before anything this navigation starts can move it: inside the click (or the keypress) that started it,
|
|
607
|
+
// before React has handled that event's own state updates, such as a menu that closes. navigate() and submit()
|
|
608
|
+
// pass what they read, or what a link or a form read before the app's own handler ran.
|
|
609
|
+
const focusedAtStart = options.focusedAtStart === undefined ? document.activeElement : options.focusedAtStart;
|
|
563
610
|
if (url.origin !== window.location.origin) {
|
|
564
611
|
// navigate() and Form hand other origins to the browser, and every page URL the router keeps is on this one.
|
|
565
612
|
// Refuse one that gets here all the same: a failed load would send the browser to it.
|
|
@@ -638,7 +685,7 @@ class Navigator {
|
|
|
638
685
|
this.forgetToken();
|
|
639
686
|
committed = true;
|
|
640
687
|
const scroll = shown.base ? NONE : options.scroll === 'auto' ? (shown.url.pathname === previous.pathname ? NONE : TOP) : options.scroll;
|
|
641
|
-
this.deps.commit(shown, scroll);
|
|
688
|
+
this.deps.commit(shown, scroll, focusedAtStart);
|
|
642
689
|
} catch (error) {
|
|
643
690
|
if (controller.signal.aborted) return navigation.restarted;
|
|
644
691
|
if (request.method === 'POST') {
|
|
@@ -809,21 +856,22 @@ function elementForFragment(fragment: string): HTMLElement | null {
|
|
|
809
856
|
return document.getElementById(decoded) ?? document.getElementById(fragment);
|
|
810
857
|
}
|
|
811
858
|
|
|
812
|
-
|
|
859
|
+
/** Scrolls as the intent says, and returns the element a fragment names, which then takes the focus. */
|
|
860
|
+
function applyScroll(intent: ScrollIntent): HTMLElement | null {
|
|
813
861
|
switch (intent.kind) {
|
|
814
862
|
case 'none':
|
|
815
|
-
return;
|
|
863
|
+
return null;
|
|
816
864
|
case 'top':
|
|
817
865
|
window.scrollTo(0, 0);
|
|
818
|
-
return;
|
|
866
|
+
return null;
|
|
819
867
|
case 'restore':
|
|
820
868
|
window.scrollTo(intent.x, intent.y);
|
|
821
|
-
return;
|
|
869
|
+
return null;
|
|
822
870
|
case 'hash': {
|
|
823
871
|
const target = elementForFragment(intent.hash.slice(1));
|
|
824
872
|
if (target) target.scrollIntoView();
|
|
825
873
|
else window.scrollTo(0, 0);
|
|
826
|
-
return;
|
|
874
|
+
return target;
|
|
827
875
|
}
|
|
828
876
|
default: {
|
|
829
877
|
const exhaustive: never = intent;
|
|
@@ -832,7 +880,7 @@ function applyScroll(intent: ScrollIntent) {
|
|
|
832
880
|
}
|
|
833
881
|
}
|
|
834
882
|
|
|
835
|
-
type View = { loaded: Loaded; id: number; scroll: ScrollIntent };
|
|
883
|
+
type View = { loaded: Loaded; id: number; scroll: ScrollIntent; focusedAtStart: Element | null };
|
|
836
884
|
type ViewValue = { payload: PagePayload; url: URL };
|
|
837
885
|
|
|
838
886
|
const RouterContext = createContext<InternalRouter | null>(null);
|
|
@@ -896,6 +944,7 @@ export function RouterProvider({
|
|
|
896
944
|
},
|
|
897
945
|
id: 0,
|
|
898
946
|
scroll: NONE,
|
|
947
|
+
focusedAtStart: null,
|
|
899
948
|
}));
|
|
900
949
|
const [navigation, setNavigation] = useState<NavigationState>(IDLE);
|
|
901
950
|
const [navigator] = useState(() => {
|
|
@@ -910,9 +959,9 @@ export function RouterProvider({
|
|
|
910
959
|
showDocument,
|
|
911
960
|
download,
|
|
912
961
|
setPending: setNavigation,
|
|
913
|
-
commit: (loaded, scroll) =>
|
|
962
|
+
commit: (loaded, scroll, focusedAtStart) =>
|
|
914
963
|
startTransition(() => {
|
|
915
|
-
setView((previous) => ({ loaded, id: previous.id + 1, scroll }));
|
|
964
|
+
setView((previous) => ({ loaded, id: previous.id + 1, scroll, focusedAtStart }));
|
|
916
965
|
setNavigation(IDLE);
|
|
917
966
|
}),
|
|
918
967
|
},
|
|
@@ -974,19 +1023,37 @@ export function RouterProvider({
|
|
|
974
1023
|
}),
|
|
975
1024
|
[navigator, actionListeners],
|
|
976
1025
|
);
|
|
1026
|
+
// Directly after the registration above, and in a passive effect, never a layout one: React runs a component's
|
|
1027
|
+
// effects in order, a commit's layout effects before any passive one, and the mark must not show while server
|
|
1028
|
+
// action calls still wait for the router.
|
|
1029
|
+
useEffect(() => {
|
|
1030
|
+
const html = document.documentElement;
|
|
1031
|
+
html.setAttribute(HYDRATED_ATTRIBUTE, '');
|
|
1032
|
+
return () => html.removeAttribute(HYDRATED_ATTRIBUTE);
|
|
1033
|
+
}, []);
|
|
1034
|
+
// The key of the page on screen before this commit.
|
|
1035
|
+
const committedPageKey = useRef(pageKeyOf(view.loaded));
|
|
1036
|
+
// In the commit that shows the page, before the browser paints it, and after the layout effects of the page and its
|
|
1037
|
+
// layouts, which have focused an autoFocus element by then. Every focus call keeps the scroll position applyScroll
|
|
1038
|
+
// sets, the one Back restores included.
|
|
977
1039
|
useLayoutEffect(() => {
|
|
978
|
-
if (view.id
|
|
1040
|
+
if (view.id === 0) return;
|
|
1041
|
+
const target = applyScroll(view.scroll);
|
|
1042
|
+
const key = pageKeyOf(view.loaded);
|
|
1043
|
+
const replaced = key !== committedPageKey.current;
|
|
1044
|
+
committedPageKey.current = key;
|
|
1045
|
+
moveFocusAfterNavigation({ target, replaced, keep: view.scroll.kind === 'none', focusedAtStart: view.focusedAtStart });
|
|
979
1046
|
}, [view]);
|
|
980
1047
|
|
|
981
1048
|
const router = useMemo<InternalRouter>(
|
|
982
1049
|
() => ({
|
|
983
|
-
push: (href, options) => navigator.navigate(href, 'push', options?.scroll ?? true),
|
|
984
|
-
replace: (href, options) => navigator.navigate(href, 'replace', options?.scroll ?? true),
|
|
1050
|
+
push: (href, options) => navigator.navigate(href, 'push', options?.scroll ?? true, options?.focusedAtStart),
|
|
1051
|
+
replace: (href, options) => navigator.navigate(href, 'replace', options?.scroll ?? true, options?.focusedAtStart),
|
|
985
1052
|
prefetch: (href) => navigator.prefetch(href),
|
|
986
1053
|
refresh: () => navigator.refresh(),
|
|
987
1054
|
back: () => window.history.back(),
|
|
988
1055
|
forward: () => window.history.forward(),
|
|
989
|
-
submit: (action, body) => navigator.submit(action, body),
|
|
1056
|
+
submit: (action, body, options) => navigator.submit(action, body, options?.focusedAtStart),
|
|
990
1057
|
onActionResponse: (listener) => {
|
|
991
1058
|
actionListeners.add(listener);
|
|
992
1059
|
return () => actionListeners.delete(listener);
|
|
@@ -1014,12 +1081,15 @@ export function RouterProvider({
|
|
|
1014
1081
|
params={payload.params}
|
|
1015
1082
|
status={payload.status}
|
|
1016
1083
|
pageKind={payload.pageKind}
|
|
1017
|
-
pageKey={view.loaded
|
|
1084
|
+
pageKey={pageKeyOf(view.loaded)}
|
|
1018
1085
|
retry={() => void router.refresh()}
|
|
1019
1086
|
metadata={payload.metadata}
|
|
1020
1087
|
/>
|
|
1021
1088
|
</ErrorBoundary>
|
|
1022
1089
|
</Shell>
|
|
1090
|
+
{/* Outside the app's shell and error boundaries, which cannot remove it, and after them, so it reads what the
|
|
1091
|
+
new page has set. */}
|
|
1092
|
+
<RouteAnnouncer location={url.pathname + url.search} path={withoutBasePath(url.pathname, payload.basePath ?? '')} />
|
|
1023
1093
|
</NavigationContext>
|
|
1024
1094
|
</ViewContext>
|
|
1025
1095
|
</RouterContext>
|
|
@@ -1028,8 +1098,13 @@ export function RouterProvider({
|
|
|
1028
1098
|
|
|
1029
1099
|
const errorPageModules = new WeakMap<ModuleLoader, Promise<RouteModules>>();
|
|
1030
1100
|
|
|
1101
|
+
/**
|
|
1102
|
+
* What the router shows for an error no segment's boundary caught: the app's root error page, handed the error as it
|
|
1103
|
+
* was thrown (its class, its stack, a server action's status), or the built-in page, which says 500.
|
|
1104
|
+
*/
|
|
1031
1105
|
function ErrorFallback({ error, errorPage, loader, payload }: { error: unknown; errorPage: SpecialPageRef | null; loader: ModuleLoader; payload: PagePayload }) {
|
|
1032
|
-
const
|
|
1106
|
+
const caught = asError(error);
|
|
1107
|
+
const props = { status: 500, message: caught.message };
|
|
1033
1108
|
const builtIn = <RouteTree modules={{ page: null, segments: [] }} props={props} params={payload.params} status={500} pageKey="error" />;
|
|
1034
1109
|
if (!errorPage) return builtIn;
|
|
1035
1110
|
let modules = errorPageModules.get(loader);
|
|
@@ -1041,14 +1116,24 @@ function ErrorFallback({ error, errorPage, loader, payload }: { error: unknown;
|
|
|
1041
1116
|
// If the error page (or a layout around it) throws as well, fall back to the built-in page.
|
|
1042
1117
|
<ErrorBoundary resetKey={0} fallback={() => builtIn}>
|
|
1043
1118
|
<Suspense fallback={null}>
|
|
1044
|
-
<LoadedErrorPage modules={modules} props={props} payload={payload} />
|
|
1119
|
+
<LoadedErrorPage modules={modules} props={props} error={caught} payload={payload} />
|
|
1045
1120
|
</Suspense>
|
|
1046
1121
|
</ErrorBoundary>
|
|
1047
1122
|
);
|
|
1048
1123
|
}
|
|
1049
1124
|
|
|
1050
|
-
function LoadedErrorPage({
|
|
1051
|
-
|
|
1125
|
+
function LoadedErrorPage({
|
|
1126
|
+
modules,
|
|
1127
|
+
props,
|
|
1128
|
+
error,
|
|
1129
|
+
payload,
|
|
1130
|
+
}: {
|
|
1131
|
+
modules: Promise<RouteModules>;
|
|
1132
|
+
props: Record<string, unknown>;
|
|
1133
|
+
error: ErrorPageProps['error'];
|
|
1134
|
+
payload: PagePayload;
|
|
1135
|
+
}) {
|
|
1136
|
+
return <RouteTree modules={use(modules)} props={props} params={payload.params} status={500} pageKind="error" pageKey="error" error={error} />;
|
|
1052
1137
|
}
|
|
1053
1138
|
|
|
1054
1139
|
/** The router's contexts without the router: for the test harness (netjes/testing), which fakes navigation. */
|
|
@@ -1098,7 +1183,7 @@ export function usePathname(): string {
|
|
|
1098
1183
|
return withoutBasePath(url.pathname, payload.basePath ?? '');
|
|
1099
1184
|
}
|
|
1100
1185
|
|
|
1101
|
-
/** An href for a plain `<a
|
|
1186
|
+
/** An href for a plain `<a>`, another attribute or a `fetch`, under the base path: what `<Link>` does for its own. */
|
|
1102
1187
|
export function useHref(href: string): string {
|
|
1103
1188
|
return withBasePath(href, useBasePath());
|
|
1104
1189
|
}
|