@kasperrt/smolter 0.3.1 → 0.3.2

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 (67) hide show
  1. package/.generated/client/deployment-check.ts +70 -0
  2. package/.generated/client/deployment.ts +21 -58
  3. package/.generated/client/index.ts +8 -2
  4. package/.generated/core/responses.ts +8 -3
  5. package/.generated/core/router.ts +32 -0
  6. package/.generated/core/types.ts +2 -0
  7. package/.generated/preact/client.tsx +48 -6
  8. package/.generated/preact/document.tsx +4 -2
  9. package/.generated/preact/lazy-route.ts +2 -0
  10. package/.generated/preact/root-context.ts +2 -0
  11. package/.generated/preact/route-types.ts +1 -0
  12. package/.generated/preact/router.tsx +9 -3
  13. package/.generated/server/application.tsx +83 -19
  14. package/.generated/server/handler.ts +8 -2
  15. package/.generated/server/http.ts +24 -2
  16. package/.generated/server/index.ts +17 -5
  17. package/.generated/server/preact.ts +1 -0
  18. package/.generated/server/private-response.ts +11 -0
  19. package/.generated/server/stream-document.ts +121 -0
  20. package/.generated/vite/application/lazy-files.ts +8 -2
  21. package/.generated/vite/application/vite.ts +9 -3
  22. package/.generated/vite/index.ts +4 -2
  23. package/.generated/vite/transport.ts +4 -1
  24. package/CHANGELOG.md +10 -0
  25. package/README.md +29 -11
  26. package/dist/client/deployment-check.d.ts +6 -0
  27. package/dist/client/deployment-check.js +51 -0
  28. package/dist/client/deployment.d.ts +2 -0
  29. package/dist/client/deployment.js +11 -39
  30. package/dist/client/index.d.ts +3 -1
  31. package/dist/client/index.js +3 -3
  32. package/dist/core/responses.d.ts +1 -1
  33. package/dist/core/responses.js +3 -3
  34. package/dist/core/router.js +16 -0
  35. package/dist/core/types.d.ts +2 -0
  36. package/dist/preact/client.d.ts +6 -1
  37. package/dist/preact/client.js +27 -8
  38. package/dist/preact/document.js +4 -2
  39. package/dist/preact/lazy-route.d.ts +1 -0
  40. package/dist/preact/lazy-route.js +2 -1
  41. package/dist/preact/root-context.d.ts +2 -0
  42. package/dist/preact/route-types.d.ts +1 -0
  43. package/dist/preact/router.d.ts +1 -1
  44. package/dist/preact/router.js +3 -3
  45. package/dist/server/application.d.ts +4 -1
  46. package/dist/server/application.js +47 -13
  47. package/dist/server/handler.d.ts +3 -1
  48. package/dist/server/handler.js +5 -3
  49. package/dist/server/http.d.ts +5 -1
  50. package/dist/server/http.js +10 -1
  51. package/dist/server/index.d.ts +1 -1
  52. package/dist/server/index.js +12 -2
  53. package/dist/server/preact.d.ts +1 -1
  54. package/dist/server/private-response.d.ts +2 -0
  55. package/dist/server/private-response.js +13 -0
  56. package/dist/server/stream-document.d.ts +9 -0
  57. package/dist/server/stream-document.js +78 -0
  58. package/dist/vite/application/lazy-files.d.ts +1 -1
  59. package/dist/vite/application/lazy-files.js +5 -2
  60. package/dist/vite/application/vite.d.ts +2 -1
  61. package/dist/vite/application/vite.js +7 -4
  62. package/dist/vite/index.d.ts +2 -1
  63. package/dist/vite/index.js +6 -3
  64. package/dist/vite/transport.d.ts +1 -1
  65. package/dist/vite/transport.js +3 -2
  66. package/jsr.json +1 -1
  67. package/package.json +2 -2
@@ -0,0 +1,70 @@
1
+ import type { DeploymentOptions } from "./deployment.ts";
2
+
3
+ export function deploymentCheck(options: DeploymentOptions) {
4
+ const { version, manifestURL, timeout = 2000 } = options;
5
+ const manifest = new URL(manifestURL, location.href);
6
+ const key = `smolter:deployment:${manifest.href}:${version}`;
7
+ let stopped = false;
8
+ let pending: Promise<boolean> | undefined;
9
+ let controller: AbortController | undefined;
10
+ function changed() {
11
+ if (pending) return pending;
12
+
13
+ controller = new AbortController();
14
+ const timer = setTimeout(() => controller?.abort(), timeout);
15
+ pending = fetch(manifest, { cache: "no-store", signal: controller.signal })
16
+ .then(async (response) => {
17
+ if (!response.ok) return false;
18
+
19
+ const data: unknown = await response.json();
20
+
21
+ return (
22
+ typeof data === "object" &&
23
+ data !== null &&
24
+ "version" in data &&
25
+ typeof data.version === "string" &&
26
+ data.version.length > 0 &&
27
+ data.version !== version
28
+ );
29
+ })
30
+ .catch(() => false)
31
+ .finally(() => {
32
+ clearTimeout(timer);
33
+ pending = undefined;
34
+ });
35
+
36
+ return pending;
37
+ }
38
+
39
+ async function reload(target: URL) {
40
+ // If storage is unavailable, do not risk reloading the same cached document forever.
41
+ const allowed = await Promise.resolve()
42
+ .then(() => {
43
+ if (sessionStorage.getItem(key)) return false;
44
+
45
+ sessionStorage.setItem(key, "1");
46
+
47
+ return true;
48
+ })
49
+ .catch(() => false);
50
+
51
+ if (allowed && !stopped) {
52
+ if (options?.reload) options.reload(target);
53
+ if (!options?.reload) {
54
+ if (target.href === location.href) location.reload();
55
+ if (target.href !== location.href) location.assign(target.href);
56
+ }
57
+ }
58
+
59
+ return allowed && !stopped;
60
+ }
61
+
62
+ return {
63
+ changed,
64
+ reload,
65
+ dispose() {
66
+ stopped = true;
67
+ controller?.abort();
68
+ },
69
+ };
70
+ }
@@ -1,4 +1,5 @@
1
1
  import type { NavigationRouter } from "../core/index.ts";
2
+ import { deploymentCheck } from "./deployment-check.ts";
2
3
 
3
4
  /** Build identity and the fresh, non-cacheable manifest URL emitted by deploymentPlugin. */
4
5
  export interface DeploymentOptions {
@@ -17,69 +18,14 @@ export function watchDeployment(
17
18
  ): () => void {
18
19
  if (!options || typeof window === "undefined") return () => {};
19
20
 
20
- const { version, manifestURL, timeout = 2000 } = options;
21
+ const check = deploymentCheck(options);
22
+ const { changed, reload } = check;
21
23
  const navigate = router.navigate;
22
24
  const dispose = router.dispose;
23
- const manifest = new URL(manifestURL, location.href);
24
- const key = `smolter:deployment:${manifest.href}:${version}`;
25
25
  let sequence = 0;
26
26
  let stopped = false;
27
27
  let replay = false;
28
28
  let destination = new URL(location.href);
29
- let pending: Promise<boolean> | undefined;
30
- let controller: AbortController | undefined;
31
-
32
- function changed() {
33
- if (pending) return pending;
34
-
35
- controller = new AbortController();
36
- const timer = setTimeout(() => controller?.abort(), timeout);
37
- pending = fetch(manifest, { cache: "no-store", signal: controller.signal })
38
- .then(async (response) => {
39
- if (!response.ok) return false;
40
-
41
- const data: unknown = await response.json();
42
-
43
- return (
44
- typeof data === "object" &&
45
- data !== null &&
46
- "version" in data &&
47
- typeof data.version === "string" &&
48
- data.version.length > 0 &&
49
- data.version !== version
50
- );
51
- })
52
- .catch(() => false)
53
- .finally(() => {
54
- clearTimeout(timer);
55
- pending = undefined;
56
- });
57
-
58
- return pending;
59
- }
60
-
61
- async function reload(target: URL) {
62
- // If storage is unavailable, do not risk reloading the same cached document forever.
63
- const allowed = await Promise.resolve()
64
- .then(() => {
65
- if (sessionStorage.getItem(key)) return false;
66
-
67
- sessionStorage.setItem(key, "1");
68
-
69
- return true;
70
- })
71
- .catch(() => false);
72
-
73
- if (allowed && !stopped) {
74
- if (options?.reload) options.reload(target);
75
- if (!options?.reload) {
76
- if (target.href === location.href) location.reload();
77
- if (target.href !== location.href) location.assign(target.href);
78
- }
79
- }
80
-
81
- return allowed && !stopped;
82
- }
83
29
 
84
30
  const guarded: NavigationRouter["navigate"] = async (href, operation) => {
85
31
  const target = new URL(href, router.getSnapshot().url);
@@ -139,7 +85,7 @@ export function watchDeployment(
139
85
  function cleanup() {
140
86
  stopped = true;
141
87
  sequence++;
142
- controller?.abort();
88
+ check.dispose();
143
89
  window.removeEventListener("popstate", pop, true);
144
90
  window.removeEventListener("vite:preloadError", preloadError);
145
91
 
@@ -159,3 +105,20 @@ export function watchDeployment(
159
105
 
160
106
  return cleanup;
161
107
  }
108
+
109
+ /** Recover an initial lazy-import failure before a router has mounted. Returns whether a reload was requested. */
110
+ export async function recoverDeployment(
111
+ options: DeploymentOptions | null,
112
+ destination?: URL,
113
+ ): Promise<boolean> {
114
+ if (!options || typeof window === "undefined") return false;
115
+
116
+ const check = deploymentCheck(options);
117
+
118
+ return check
119
+ .changed()
120
+ .then((changed) =>
121
+ changed ? check.reload(destination ?? new URL(location.href)) : false,
122
+ )
123
+ .finally(check.dispose);
124
+ }
@@ -29,6 +29,8 @@ export interface ClientRouteModule {
29
29
  export interface ServerHandlers {
30
30
  loader?: boolean;
31
31
  action?: boolean;
32
+ /** Application-scoped HTTP transport path. Defaults to /_smolter. */
33
+ dataEndpoint?: string;
32
34
  }
33
35
 
34
36
  /** Browser transport for server routes. File-router builds generate this call. */
@@ -40,7 +42,7 @@ export function serverRoute<M extends RouteModule = RouteModule>(
40
42
  const remote = async ({ request, signal }: DataArgs) => {
41
43
  const url = new URL(request.url);
42
44
  const response = await fetch(
43
- `/_smolter?url=${encodeURIComponent(url.pathname + url.search)}`,
45
+ `${handlers.dataEndpoint ?? "/_smolter"}?url=${encodeURIComponent(url.pathname + url.search)}`,
44
46
  {
45
47
  method: request.method,
46
48
  signal,
@@ -84,4 +86,8 @@ export function serverRoute<M extends RouteModule = RouteModule>(
84
86
  } as Route<M>;
85
87
  }
86
88
 
87
- export { type DeploymentOptions, watchDeployment } from "./deployment.ts";
89
+ export {
90
+ type DeploymentOptions,
91
+ recoverDeployment,
92
+ watchDeployment,
93
+ } from "./deployment.ts";
@@ -33,7 +33,10 @@ export function isRouteErrorResponse(value: unknown): value is RouteError {
33
33
  return value instanceof Error && "status" in value;
34
34
  }
35
35
 
36
- export async function normalizeResult(value: unknown): Promise<unknown> {
36
+ export async function normalizeResult(
37
+ value: unknown,
38
+ resolveDeferred = true,
39
+ ): Promise<unknown> {
37
40
  if (value && typeof value === "object" && "__routeError" in value) {
38
41
  const error = value.__routeError as {
39
42
  message: string;
@@ -57,9 +60,11 @@ export async function normalizeResult(value: unknown): Promise<unknown> {
57
60
  }
58
61
 
59
62
  if (value && typeof value === "object" && "__routeData" in value)
60
- return resolveData((value as DataResponse<unknown>).value);
63
+ return resolveDeferred
64
+ ? resolveData((value as DataResponse<unknown>).value)
65
+ : (value as DataResponse<unknown>).value;
61
66
 
62
- return resolveData(value);
67
+ return resolveDeferred ? resolveData(value) : value;
63
68
  }
64
69
 
65
70
  // Smolter transfers JSON snapshots. Resolve deferred fields before serialization.
@@ -130,6 +130,15 @@ export function createDataRouter(
130
130
  if (!fn) return body && Error("No action");
131
131
 
132
132
  return (fn as DataFunction)({
133
+ reason: body
134
+ ? "action"
135
+ : navigation !== undefined
136
+ ? "navigation"
137
+ : local
138
+ ? "fetcher"
139
+ : previous.loaded
140
+ ? "revalidation"
141
+ : "initial",
133
142
  request: new Request(url, {
134
143
  signal,
135
144
  method: body && method,
@@ -154,6 +163,29 @@ export function createDataRouter(
154
163
  redirects - 1,
155
164
  );
156
165
 
166
+ if (body) {
167
+ const destination = new URL(main.getSnapshot().url);
168
+ const args: RevalidationArgs = {
169
+ url: destination,
170
+ currentUrl: destination,
171
+ nextUrl: destination,
172
+ defaultShouldRevalidate: true,
173
+ reason: "action",
174
+ formMethod: method,
175
+ formAction: url!.href,
176
+ formData: body,
177
+ actionResult: result.data,
178
+ };
179
+
180
+ // The destination loader just ran. Refresh root data and other fetchers
181
+ // without loading that page twice or replaying the mutation.
182
+ await Promise.all(
183
+ [...revalidators]
184
+ .filter((task) => task !== main)
185
+ .map((task) => task.revalidate(args)),
186
+ );
187
+ }
188
+
157
189
  if (!signal.aborted) {
158
190
  submission = undefined;
159
191
  emit("idle");
@@ -8,6 +8,8 @@ export type Params = Record<string, string>;
8
8
 
9
9
  /** Request, path parameters, and cancellation signal passed to a data handler. */
10
10
  export interface DataArgs {
11
+ /** Why this handler ran; independent of when browser history is committed. */
12
+ reason?: "initial" | "navigation" | "revalidation" | "fetcher" | "action";
11
13
  request: Request;
12
14
  params: Params;
13
15
  signal: AbortSignal;
@@ -1,6 +1,11 @@
1
1
  /** Browser mounting and hydration for Preact applications. @module */
2
2
  /** @jsxImportSource preact */
3
3
 
4
+ import {
5
+ type DeploymentOptions,
6
+ recoverDeployment,
7
+ watchDeployment,
8
+ } from "../client/index.ts";
4
9
  import { createDataRouter, matchRoutes, type Router } from "../core/index.ts";
5
10
  import { connectNavigation } from "../core/navigation.ts";
6
11
  import { retainRoute } from "../core/presentation.ts";
@@ -13,11 +18,18 @@ import { prepareRoutes, RouterApplication } from "./router.tsx";
13
18
  export * from "../client/index.ts";
14
19
  export { configureApplication } from "../core/environment.ts";
15
20
 
21
+ export interface MountApplicationOptions {
22
+ hash?: boolean;
23
+ deployment?: DeploymentOptions | null;
24
+ }
25
+
16
26
  export async function mountApplication(
17
27
  routes: AppRoute[],
18
28
  rootModule?: RootModule,
19
- hash = false,
29
+ options: boolean | MountApplicationOptions = false,
20
30
  ): Promise<Router> {
31
+ const { hash = false, deployment = null } =
32
+ typeof options === "boolean" ? { hash: options } : options;
21
33
  const initialURL = new URL(
22
34
  hash ? location.hash.slice(1) || "/" : location.href,
23
35
  location.origin,
@@ -27,12 +39,31 @@ export async function mountApplication(
27
39
  | AppRoute
28
40
  | undefined;
29
41
 
30
- await initialRoute?.preload?.();
42
+ await initialRoute?.preload?.().catch(async (error: unknown) => {
43
+ await recoverDeployment(deployment, initialURL);
44
+ throw error;
45
+ });
31
46
 
32
47
  routes = prepareRoutes(routes);
33
48
  const element = document.getElementById("smolter-data");
34
49
  const hydration: Hydration | undefined = element?.textContent
35
- ? JSON.parse(element.textContent)
50
+ ? JSON.parse(element.textContent, (_key, value) => {
51
+ if (
52
+ !value ||
53
+ typeof value !== "object" ||
54
+ !("__smolterRejected" in value)
55
+ )
56
+ return value;
57
+
58
+ const error = Object.assign(
59
+ new Error(value.__smolterRejected.message),
60
+ value.__smolterRejected,
61
+ );
62
+ const rejected = Promise.reject(error);
63
+ void rejected.catch(() => {});
64
+
65
+ return rejected;
66
+ })
36
67
  : undefined;
37
68
 
38
69
  if (hydration?.snapshot.error)
@@ -48,10 +79,11 @@ export async function mountApplication(
48
79
  : location.href,
49
80
  ...(hydration && { hydrationData: hydration.snapshot }),
50
81
  });
51
- retainRoute(router, flushSync);
52
-
82
+ watchDeployment(router, deployment);
53
83
  if (hash) connectHashHistory(router);
54
84
 
85
+ retainRoute(router, flushSync);
86
+
55
87
  connectNavigation(router);
56
88
  const rootStore = attachRootData(router, hydration?.root, rootModule);
57
89
  const props = {
@@ -60,6 +92,7 @@ export async function mountApplication(
60
92
  root: hydration?.root,
61
93
  rootModule,
62
94
  rootStore,
95
+ rootError: hydration?.rootError,
63
96
  };
64
97
 
65
98
  if (hydration) {
@@ -76,17 +109,26 @@ export async function mountApplication(
76
109
  }
77
110
 
78
111
  function connectHashHistory(router: Router) {
112
+ const read = router.getSnapshot;
79
113
  const navigate = router.navigate;
80
114
  router.navigate = async (url, options) => {
81
115
  const target = new URL(url, router.getSnapshot().url);
82
- const hash = `#${target.pathname}${target.search}`;
116
+ const hash = `#${target.pathname}${target.search}${target.hash}`;
83
117
  history[options?.replace ? "replaceState" : "pushState"](null, "", hash);
84
118
  await navigate(url, options);
85
119
  };
120
+ const unsubscribe = router.subscribe(() => {
121
+ const target = new URL(read().url);
122
+ const hash = `#${target.pathname}${target.search}${target.hash}`;
123
+
124
+ // Redirects commit inside the router rather than through navigate().
125
+ if (location.hash !== hash) history.replaceState(history.state, "", hash);
126
+ });
86
127
  const onHashChange = () => void navigate(location.hash.slice(1) || "/");
87
128
  window.addEventListener("hashchange", onHashChange);
88
129
  const dispose = router.dispose;
89
130
  router.dispose = () => {
131
+ unsubscribe();
90
132
  window.removeEventListener("hashchange", onHashChange);
91
133
  dispose();
92
134
  };
@@ -72,10 +72,12 @@ export interface ScriptProps {
72
72
  }
73
73
 
74
74
  export function Scripts({ nonce }: ScriptProps): import("preact").JSX.Element {
75
- const { root, router, entry } = useApplication();
75
+ const { root, rootError, router, entry, streaming } = useApplication();
76
76
  // The bootstrap payload describes this document's initial SSR snapshot.
77
77
  // Navigation and revalidation update the router, not the hydration script.
78
78
  const [json] = useState(() => {
79
+ if (streaming) return "";
80
+
79
81
  const current = router.getSnapshot();
80
82
  const snapshot = {
81
83
  ...current,
@@ -86,7 +88,7 @@ export function Scripts({ nonce }: ScriptProps): import("preact").JSX.Element {
86
88
  },
87
89
  };
88
90
 
89
- return serializeData({ root, snapshot });
91
+ return serializeData({ root, snapshot, ...(rootError && { rootError }) });
90
92
  });
91
93
 
92
94
  return (
@@ -8,6 +8,7 @@ import { useSyncExternalStore } from "./runtime.ts";
8
8
 
9
9
  export interface LazyRouteOptions {
10
10
  path: string;
11
+ dataEndpoint?: string;
11
12
  id?: string;
12
13
  loader?: boolean;
13
14
  action?: boolean;
@@ -48,6 +49,7 @@ export function lazyRoute(
48
49
  module = serverRoute(options.path, loaded, {
49
50
  loader: options.loader,
50
51
  action: options.action,
52
+ dataEndpoint: options.dataEndpoint,
51
53
  });
52
54
  })
53
55
  .catch((error: unknown) => {
@@ -6,6 +6,8 @@ export interface ApplicationContext {
6
6
  router: Router;
7
7
  routes: AppRoute[];
8
8
  root: unknown;
9
+ rootError?: boolean;
10
+ streaming?: boolean;
9
11
  rootModule?: RootModule;
10
12
  hydration?: Hydration;
11
13
  entry?: string;
@@ -66,6 +66,7 @@ export interface RootModule {
66
66
  }
67
67
 
68
68
  export interface Hydration {
69
+ rootError?: boolean;
69
70
  root: unknown;
70
71
  snapshot: Snapshot;
71
72
  }
@@ -51,7 +51,7 @@ export function RouterApplication(
51
51
  );
52
52
  const route = props.routes.find((item) => item.path === snapshot.path);
53
53
  const Root =
54
- snapshot.error && !route?.ErrorBoundary
54
+ snapshot.error && (props.rootError || !route?.ErrorBoundary)
55
55
  ? (props.rootModule?.ErrorBoundary ?? Outlet)
56
56
  : (props.rootModule?.default ?? Outlet);
57
57
  const Layout = props.rootModule?.Layout;
@@ -66,7 +66,10 @@ export function RouterApplication(
66
66
  );
67
67
  }
68
68
 
69
- export function prepareRoutes(routes: AppRoute[]): AppRoute[] {
69
+ export function prepareRoutes(
70
+ routes: AppRoute[],
71
+ preserveDeferred = false,
72
+ ): AppRoute[] {
70
73
  return routes.map((route) => {
71
74
  const wrapped: AppRoute = {
72
75
  ...route,
@@ -99,7 +102,10 @@ export function prepareRoutes(routes: AppRoute[]): AppRoute[] {
99
102
  wrapped[key] = (args) =>
100
103
  Promise.resolve()
101
104
  .then(() => handler(args))
102
- .then(normalizeResult, normalizeResult);
105
+ .then(
106
+ (value) => normalizeResult(value, !preserveDeferred),
107
+ (error) => normalizeResult(error, !preserveDeferred),
108
+ );
103
109
  }
104
110
 
105
111
  return wrapped;
@@ -1,6 +1,6 @@
1
1
  /** @jsxImportSource preact */
2
2
 
3
- import { createDataRouter, matchRoutes } from "../core/index.ts";
3
+ import { createDataRouter, matchRoutes, safeWrapAsync } from "../core/index.ts";
4
4
  import {
5
5
  type DataResponse,
6
6
  normalizeResult,
@@ -9,13 +9,20 @@ import {
9
9
  import type { AppRoute, RootModule } from "../preact/route-types.ts";
10
10
  import { prepareRoutes, RouterApplication } from "../preact/router.tsx";
11
11
  import { loadRouter } from "./index.ts";
12
- import { renderToStringAsync } from "preact-render-to-string";
12
+ import { renderToReadableStream } from "preact-render-to-string/stream";
13
+ import { privateResponse } from "./private-response.ts";
14
+ import { resolveHydration, streamDocument } from "./stream-document.ts";
15
+
16
+ export interface RenderApplicationOptions {
17
+ nonce?: string;
18
+ }
13
19
 
14
20
  export async function renderApplication(
15
21
  request: Request,
16
22
  routes: AppRoute[],
17
23
  rootModule: RootModule,
18
24
  entry: string,
25
+ options: RenderApplicationOptions = {},
19
26
  ): Promise<Response> {
20
27
  routes = routes.map((route) => ({
21
28
  ...route,
@@ -41,12 +48,20 @@ export async function renderApplication(
41
48
  actionData = await normalizeResult(await actionResponse.json());
42
49
  }
43
50
 
44
- const root = await resolveData(
45
- await rootModule.loader?.({
46
- request,
47
- params: {},
48
- signal: request.signal,
49
- }),
51
+ const rootResult = safeWrapAsync(() =>
52
+ Promise.resolve()
53
+ .then(() =>
54
+ rootModule.loader?.({ request, params: {}, signal: request.signal }),
55
+ )
56
+ .then(
57
+ (value) => normalizeResult(value, false),
58
+ (error: unknown) =>
59
+ error instanceof Response
60
+ ? normalizeResult(error, false)
61
+ : error instanceof Error
62
+ ? error
63
+ : new Error(String(error), { cause: error }),
64
+ ),
50
65
  );
51
66
  let redirectResponse: Response | undefined;
52
67
  const prepared = prepareRoutes(
@@ -54,6 +69,7 @@ export async function renderApplication(
54
69
  ...route,
55
70
  ErrorBoundary: route.ErrorBoundary ?? rootModule.ErrorBoundary,
56
71
  })),
72
+ true,
57
73
  ).map((route) => {
58
74
  if (!route.loader) return route;
59
75
 
@@ -71,7 +87,20 @@ export async function renderApplication(
71
87
  },
72
88
  };
73
89
  });
74
- let router = await loadRouter(prepared, request);
90
+ const [rootState, loadedRouter] = await Promise.all([
91
+ rootResult,
92
+ loadRouter(prepared, request),
93
+ ]);
94
+ let router = loadedRouter;
95
+ const rootError =
96
+ rootState.error ??
97
+ (rootState.data instanceof Error ? rootState.data : undefined);
98
+ const root = rootError ? undefined : rootState.data;
99
+
100
+ if (root instanceof Response && root.headers.has("Location")) {
101
+ router.dispose();
102
+ return root;
103
+ }
75
104
 
76
105
  if (redirectResponse) {
77
106
  router.dispose();
@@ -79,43 +108,68 @@ export async function renderApplication(
79
108
  return redirectResponse;
80
109
  }
81
110
 
82
- if (actionResponse) {
111
+ if (actionResponse || rootError) {
83
112
  const hydrationData = {
84
113
  ...router.getSnapshot(),
85
114
  actionData: actionData instanceof Error ? undefined : actionData,
86
115
  ...(actionData instanceof Error && { error: actionData }),
116
+ ...(rootError && { error: rootError }),
87
117
  };
88
118
  router.dispose();
89
119
  router = createDataRouter(router.routes, { hydrationData });
90
120
  }
91
121
 
92
122
  const snapshot = router.getSnapshot();
93
- const html = await renderToStringAsync(
123
+ const stream = renderToReadableStream(
94
124
  <RouterApplication
95
125
  router={router}
96
126
  routes={routes}
97
127
  root={root}
98
128
  rootModule={rootModule}
99
129
  entry={entry}
130
+ rootError={Boolean(rootError)}
131
+ streaming
100
132
  document
101
133
  />,
134
+ options,
102
135
  );
103
- router.dispose();
136
+ const hydration = resolveHydration({
137
+ root,
138
+ snapshot: {
139
+ ...snapshot,
140
+ error: snapshot.error && {
141
+ ...snapshot.error,
142
+ name: snapshot.error.name,
143
+ message: snapshot.error.message,
144
+ },
145
+ },
146
+ ...(rootError && { rootError: true }),
147
+ });
148
+ // The renderer reports the same rejection through its stream reader.
149
+ void stream.allReady.catch(() => {});
104
150
  const headers = new Headers(actionResponse?.headers);
105
151
  headers.delete("Content-Length");
106
152
  headers.delete("Content-Encoding");
107
153
  headers.set("Content-Type", "text/html; charset=utf-8");
108
154
  headers.set("Cache-Control", "no-store");
109
155
 
110
- return new Response(`<!doctype html>${html}`, {
111
- status: snapshot.error
112
- ? Number("status" in snapshot.error ? snapshot.error.status : 500)
113
- : (actionResponse?.status ?? 200),
114
- headers,
115
- });
156
+ return new Response(
157
+ streamDocument(stream, {
158
+ hydration,
159
+ signal: request.signal,
160
+ nonce: options.nonce,
161
+ dispose: () => router.dispose(),
162
+ }),
163
+ {
164
+ status: snapshot.error
165
+ ? Number("status" in snapshot.error ? snapshot.error.status : 500)
166
+ : (actionResponse?.status ?? 200),
167
+ headers,
168
+ },
169
+ );
116
170
  }
117
171
 
118
- export async function dispatchRoute(
172
+ async function dispatch(
119
173
  request: Request,
120
174
  routes: AppRoute[],
121
175
  document = false,
@@ -183,3 +237,13 @@ export async function dispatchRoute(
183
237
 
184
238
  return Response.json((await normalizeResult(value)) ?? null);
185
239
  }
240
+
241
+ export async function dispatchRoute(
242
+ request: Request,
243
+ routes: AppRoute[],
244
+ document = false,
245
+ ): Promise<Response> {
246
+ const response = await dispatch(request, routes, document);
247
+
248
+ return document ? response : privateResponse(response);
249
+ }