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.
Files changed (160) hide show
  1. package/README.md +13 -11
  2. package/cli/agents.mjs +25 -2
  3. package/cli/commands.mjs +56 -6
  4. package/cli/help.mjs +7 -2
  5. package/cli/info.mjs +275 -33
  6. package/cli/routes.mjs +35 -8
  7. package/cli/scaffold.mjs +31 -4
  8. package/dist/actions.d.ts +7 -2
  9. package/dist/actions.d.ts.map +1 -1
  10. package/dist/actions.js +9 -3
  11. package/dist/actions.js.map +1 -1
  12. package/dist/client-entry.d.ts +1 -0
  13. package/dist/client-entry.d.ts.map +1 -1
  14. package/dist/client-entry.js +19 -2
  15. package/dist/client-entry.js.map +1 -1
  16. package/dist/deferred.d.ts +11 -0
  17. package/dist/deferred.d.ts.map +1 -1
  18. package/dist/deferred.js +15 -1
  19. package/dist/deferred.js.map +1 -1
  20. package/dist/document.d.ts.map +1 -1
  21. package/dist/document.js +10 -1
  22. package/dist/document.js.map +1 -1
  23. package/dist/errors.d.ts +7 -0
  24. package/dist/errors.d.ts.map +1 -0
  25. package/dist/errors.js +11 -0
  26. package/dist/errors.js.map +1 -0
  27. package/dist/form.d.ts +5 -5
  28. package/dist/form.d.ts.map +1 -1
  29. package/dist/form.js +5 -2
  30. package/dist/form.js.map +1 -1
  31. package/dist/href.d.ts +31 -3
  32. package/dist/href.d.ts.map +1 -1
  33. package/dist/href.js +131 -10
  34. package/dist/href.js.map +1 -1
  35. package/dist/image.d.ts +3 -3
  36. package/dist/image.d.ts.map +1 -1
  37. package/dist/image.js.map +1 -1
  38. package/dist/index.d.ts +3 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +3 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/link.d.ts +15 -7
  43. package/dist/link.d.ts.map +1 -1
  44. package/dist/link.js +38 -7
  45. package/dist/link.js.map +1 -1
  46. package/dist/metadata.d.ts +8 -2
  47. package/dist/metadata.d.ts.map +1 -1
  48. package/dist/metadata.js +14 -5
  49. package/dist/metadata.js.map +1 -1
  50. package/dist/modules.d.ts +15 -1
  51. package/dist/modules.d.ts.map +1 -1
  52. package/dist/modules.js.map +1 -1
  53. package/dist/mounted.d.ts +55 -0
  54. package/dist/mounted.d.ts.map +1 -0
  55. package/dist/mounted.js +110 -0
  56. package/dist/mounted.js.map +1 -0
  57. package/dist/navigation-focus.d.ts +38 -0
  58. package/dist/navigation-focus.d.ts.map +1 -0
  59. package/dist/navigation-focus.js +71 -0
  60. package/dist/navigation-focus.js.map +1 -0
  61. package/dist/payload.d.ts +8 -0
  62. package/dist/payload.d.ts.map +1 -1
  63. package/dist/payload.js +28 -0
  64. package/dist/payload.js.map +1 -1
  65. package/dist/query.d.ts +5 -2
  66. package/dist/query.d.ts.map +1 -1
  67. package/dist/query.js +11 -8
  68. package/dist/query.js.map +1 -1
  69. package/dist/render-document.d.ts +74 -0
  70. package/dist/render-document.d.ts.map +1 -0
  71. package/dist/render-document.js +393 -0
  72. package/dist/render-document.js.map +1 -0
  73. package/dist/route-announcer.d.ts +18 -0
  74. package/dist/route-announcer.d.ts.map +1 -0
  75. package/dist/route-announcer.js +58 -0
  76. package/dist/route-announcer.js.map +1 -0
  77. package/dist/route-tree.d.ts +6 -2
  78. package/dist/route-tree.d.ts.map +1 -1
  79. package/dist/route-tree.js +17 -7
  80. package/dist/route-tree.js.map +1 -1
  81. package/dist/router.d.ts +28 -5
  82. package/dist/router.d.ts.map +1 -1
  83. package/dist/router.js +71 -26
  84. package/dist/router.js.map +1 -1
  85. package/dist/script.d.ts +28 -5
  86. package/dist/script.d.ts.map +1 -1
  87. package/dist/script.js +192 -21
  88. package/dist/script.js.map +1 -1
  89. package/dist/server-render.d.ts.map +1 -1
  90. package/dist/server-render.js +4 -1
  91. package/dist/server-render.js.map +1 -1
  92. package/dist/testing-vitest.d.ts +8 -0
  93. package/dist/testing-vitest.d.ts.map +1 -0
  94. package/dist/testing-vitest.js +26 -0
  95. package/dist/testing-vitest.js.map +1 -0
  96. package/dist/testing.d.ts +17 -1
  97. package/dist/testing.d.ts.map +1 -1
  98. package/dist/testing.js +18 -3
  99. package/dist/testing.js.map +1 -1
  100. package/dist/zod-jitless.d.ts +2 -0
  101. package/dist/zod-jitless.d.ts.map +1 -0
  102. package/dist/zod-jitless.js +4 -0
  103. package/dist/zod-jitless.js.map +1 -0
  104. package/package.json +1 -1
  105. package/sidecar/server.mjs +109 -15
  106. package/skills/netjes/SKILL.md +15 -7
  107. package/skills/netjes/references/caching-and-streaming.md +37 -4
  108. package/skills/netjes/references/data-and-actions.md +15 -7
  109. package/skills/netjes/references/deployment.md +7 -3
  110. package/skills/netjes/references/routing.md +32 -9
  111. package/skills/netjes/references/testing.md +23 -7
  112. package/src/actions.tsx +9 -3
  113. package/src/client-entry.tsx +20 -2
  114. package/src/deferred.ts +14 -1
  115. package/src/document.tsx +22 -3
  116. package/src/errors.ts +12 -0
  117. package/src/form.tsx +10 -7
  118. package/src/href.ts +137 -11
  119. package/src/image.tsx +3 -3
  120. package/src/index.ts +19 -1
  121. package/src/link.tsx +47 -13
  122. package/src/metadata.tsx +19 -6
  123. package/src/modules.ts +15 -2
  124. package/src/mounted.tsx +160 -0
  125. package/src/navigation-focus.ts +75 -0
  126. package/src/payload.ts +32 -0
  127. package/src/query.tsx +14 -8
  128. package/src/render-document.tsx +483 -0
  129. package/src/route-announcer.tsx +69 -0
  130. package/src/route-tree.tsx +17 -6
  131. package/src/router.tsx +116 -31
  132. package/src/script.tsx +214 -23
  133. package/src/server-render.tsx +4 -1
  134. package/src/testing-vitest.ts +37 -0
  135. package/src/testing.tsx +38 -5
  136. package/src/virtual.d.ts +8 -1
  137. package/src/zod-jitless.ts +15 -0
  138. package/template/.dockerignore +3 -0
  139. package/template/Dockerfile +11 -1
  140. package/template/README.md +11 -7
  141. package/template/_AGENTS.md +38 -9
  142. package/template/__NAME__.csproj +2 -0
  143. package/template/app/document.test.ts +29 -0
  144. package/template/app/error.tsx +3 -2
  145. package/template/app/layout.tsx +10 -1
  146. package/template/docker-compose.override.yml +4 -3
  147. package/template/docker-compose.prod.yml +2 -1
  148. package/template/e2e/home.spec.ts +32 -0
  149. package/template/generated/netjes.ts +43 -14
  150. package/template/package.json +4 -1
  151. package/template/playwright.config.ts +36 -0
  152. package/template/tests/__NAME__.Tests/AppFactory.cs +31 -0
  153. package/template/tests/__NAME__.Tests/HomePageTests.cs +55 -0
  154. package/template/tests/__NAME__.Tests/__NAME__.Tests.csproj +30 -0
  155. package/template/tsconfig.app.json +20 -0
  156. package/template/tsconfig.json +2 -17
  157. package/template/tsconfig.node.json +8 -0
  158. package/vite/packages.mjs +345 -0
  159. package/vite/plugin.d.ts +16 -1
  160. 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
+ }
@@ -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
- function asError(error: unknown): Error {
57
- return error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Something went wrong');
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
- /** The error the server reported (props of the error page it rendered), as an Error for `error.tsx`. */
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 (typeof props.stack === 'string') error.stack = props.stack;
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 { ErrorBoundary, LayoutDepthContext, RouteTree } from './route-tree.tsx';
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 focus. */
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
- /** Re-runs the current page's loader and re-renders with the new props. */
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
- export type InternalRouter = Router & {
63
- submit(action: URL, body: SubmitBody): Promise<void>;
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
- commit(loaded: Loaded, scroll: ScrollIntent): void;
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
- async navigate(href: string, history: 'push' | 'replace', scroll: boolean): Promise<void> {
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(action, { method: 'POST', ...body }, { history: 'auto', scroll: 'auto', pending: 'submitting', usePrefetch: false, reuseSegments: false });
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
- function applyScroll(intent: ScrollIntent) {
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 > 0) applyScroll(view.scroll);
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.pageKey ?? url.pathname}
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 props = { status: 500, message: error instanceof Error ? error.message : 'Something went wrong' };
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({ modules, props, payload }: { modules: Promise<RouteModules>; props: Record<string, unknown>; payload: PagePayload }) {
1051
- return <RouteTree modules={use(modules)} props={props} params={payload.params} status={500} pageKind="error" pageKey="error" />;
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>` or attribute, under the base path: what `<Link>` does for its own. */
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
  }