@timber-js/app 0.2.0-alpha.211 → 0.2.0-alpha.212
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/agent-skill.md +10 -5
- package/dist/_chunks/{actions-Rjk4htmA.js → actions-CCdnVtWm.js} +8 -6
- package/dist/_chunks/actions-CCdnVtWm.js.map +1 -0
- package/dist/_chunks/{als-registry-DaxkVjt5.js → als-registry-BZqHCtq-.js} +2 -4
- package/dist/_chunks/als-registry-BZqHCtq-.js.map +1 -0
- package/dist/_chunks/{cache-api-DGdYfNJn.js → cache-api-LA3sBpUS.js} +5 -5
- package/dist/_chunks/{cache-api-DGdYfNJn.js.map → cache-api-LA3sBpUS.js.map} +1 -1
- package/dist/_chunks/{chains-DGX9zmg9.js → chains-BfoPFraI.js} +2 -2
- package/dist/_chunks/{chains-DGX9zmg9.js.map → chains-BfoPFraI.js.map} +1 -1
- package/dist/_chunks/{classify-QwG5rxKI.js → classify-BT66U83D.js} +2 -2
- package/dist/_chunks/{classify-QwG5rxKI.js.map → classify-BT66U83D.js.map} +1 -1
- package/dist/_chunks/{cli-check-ajNY3B2e.js → cli-check-BfQ54-UJ.js} +3 -3
- package/dist/_chunks/{cli-check-ajNY3B2e.js.map → cli-check-BfQ54-UJ.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-D2eI8jEg.js → cli-schema-sync-czh2dsLs.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-D2eI8jEg.js.map → cli-schema-sync-czh2dsLs.js.map} +1 -1
- package/dist/_chunks/{client-dep-entries-2HCF09no.js → client-dep-entries-CQwpb8dI.js} +2 -2
- package/dist/_chunks/{client-dep-entries-2HCF09no.js.map → client-dep-entries-CQwpb8dI.js.map} +1 -1
- package/dist/_chunks/{convention-lint-DLmhGsRS.js → convention-lint-BEVW4EID.js} +3 -3
- package/dist/_chunks/{convention-lint-DLmhGsRS.js.map → convention-lint-BEVW4EID.js.map} +1 -1
- package/dist/_chunks/{dev-server-TFpEwm3H.js → dev-server-FKxptbnI.js} +2 -2
- package/dist/_chunks/{dev-server-TFpEwm3H.js.map → dev-server-FKxptbnI.js.map} +1 -1
- package/dist/_chunks/{json-lossy-check-CVuRs2hG.js → json-lossy-check-C8zBY2uZ.js} +2 -2
- package/dist/_chunks/{json-lossy-check-CVuRs2hG.js.map → json-lossy-check-C8zBY2uZ.js.map} +1 -1
- package/dist/_chunks/{live-graph-cNuWMYQI.js → live-graph-C_4v-fHv.js} +4 -4
- package/dist/_chunks/{live-graph-cNuWMYQI.js.map → live-graph-C_4v-fHv.js.map} +1 -1
- package/dist/_chunks/{logger-BP0LN6vP.js → logger-CbLdcy-W.js} +2 -2
- package/dist/_chunks/{logger-BP0LN6vP.js.map → logger-CbLdcy-W.js.map} +1 -1
- package/dist/_chunks/{poison-scan-CfQ3unZR.js → poison-scan-C92liMAr.js} +2 -2
- package/dist/_chunks/{poison-scan-CfQ3unZR.js.map → poison-scan-C92liMAr.js.map} +1 -1
- package/dist/_chunks/{scanner-DmqdxzbW.js → scanner-CQt12vE2.js} +2 -2
- package/dist/_chunks/{scanner-DmqdxzbW.js.map → scanner-CQt12vE2.js.map} +1 -1
- package/dist/_chunks/{sizeof-BM1409x2.js → sizeof-QPE5nd3u.js} +2 -2
- package/dist/_chunks/{sizeof-BM1409x2.js.map → sizeof-QPE5nd3u.js.map} +1 -1
- package/dist/_chunks/{walkers-Czu2jXFq.js → walkers-DAT4avhZ.js} +3 -3
- package/dist/_chunks/{walkers-Czu2jXFq.js.map → walkers-DAT4avhZ.js.map} +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +1 -1
- package/dist/analyze/crawl-entry.js +3 -3
- package/dist/analyze/graph-command.js +2 -2
- package/dist/cache/index.js +2 -2
- package/dist/cache/stores/memory.js +1 -1
- package/dist/cdn/workers-cache-purge.js +1 -1
- package/dist/cli.js +3 -3
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/form.d.ts +17 -64
- package/dist/client/form.d.ts.map +1 -1
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +23 -51
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +25 -25
- package/dist/client/internal.js.map +1 -1
- package/dist/client/navigation-api.d.ts +25 -63
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/router-lifecycle.d.ts +17 -11
- package/dist/client/router-lifecycle.d.ts.map +1 -1
- package/dist/client/router-pipeline.d.ts +0 -1
- package/dist/client/router-pipeline.d.ts.map +1 -1
- package/dist/client/router-types.d.ts +14 -45
- package/dist/client/router-types.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/index.js +6 -7
- package/dist/index.js.map +1 -1
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/rsc-runtime/rsc.d.ts +1 -1
- package/dist/rsc-runtime/rsc.d.ts.map +1 -1
- package/dist/server/action-client.d.ts +9 -5
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts +27 -8
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +22 -2
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/client-error-message.d.ts +12 -0
- package/dist/server/client-error-message.d.ts.map +1 -0
- package/dist/server/form-state-embed.d.ts +32 -0
- package/dist/server/form-state-embed.d.ts.map +1 -0
- package/dist/server/index.d.ts +0 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +7 -40
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +10 -6
- package/dist/server/internal.js.map +1 -1
- package/dist/server/logger.d.ts +1 -0
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +20 -6
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +27 -2
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/action-dispatcher.d.ts +6 -5
- package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/ssr-bridge-types.d.ts +8 -0
- package/dist/server/ssr-bridge-types.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts +3 -0
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/docs/api/31-api-client.mdx +9 -3
- package/docs/learn/08-forms-and-actions.mdx +21 -28
- package/package.json +1 -1
- package/src/client/browser-entry/action-dispatch.ts +74 -18
- package/src/client/browser-entry/hydrate.ts +18 -0
- package/src/client/browser-entry/router-init.ts +4 -18
- package/src/client/form.tsx +33 -98
- package/src/client/index.ts +2 -2
- package/src/client/navigation-api.ts +47 -173
- package/src/client/navigation-transition.ts +2 -2
- package/src/client/router-lifecycle.ts +40 -20
- package/src/client/router-pipeline.ts +3 -9
- package/src/client/router-types.ts +14 -49
- package/src/client/router.ts +38 -58
- package/src/plugins/shims.ts +0 -2
- package/src/rsc-runtime/rsc.ts +3 -0
- package/src/rsc-runtime/vendor-types.d.ts +14 -0
- package/src/server/action-client.ts +18 -20
- package/src/server/action-handler.ts +133 -95
- package/src/server/als-registry.ts +23 -9
- package/src/server/client-error-message.ts +18 -0
- package/src/server/form-state-embed.ts +63 -0
- package/src/server/index.ts +0 -4
- package/src/server/logger.ts +6 -1
- package/src/server/pipeline.ts +27 -8
- package/src/server/request-context.ts +39 -2
- package/src/server/route-element-builder.ts +12 -1
- package/src/server/rsc-entry/action-dispatcher.ts +37 -34
- package/src/server/rsc-entry/error-renderer.ts +2 -1
- package/src/server/rsc-entry/rsc-stream.ts +20 -11
- package/src/server/rsc-entry/ssr-renderer.ts +14 -1
- package/src/server/ssr-bridge-types.ts +9 -0
- package/src/server/ssr-entry.ts +1 -0
- package/src/server/ssr-render.ts +5 -0
- package/dist/_chunks/actions-Rjk4htmA.js.map +0 -1
- package/dist/_chunks/als-registry-DaxkVjt5.js.map +0 -1
- package/dist/server/form-flash.d.ts +0 -78
- package/dist/server/form-flash.d.ts.map +0 -1
- package/src/server/form-flash.ts +0 -89
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { CoercedParams } from '../shared/param-value.ts';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
requestContextAls,
|
|
20
|
+
type ActionErrorForRender,
|
|
21
|
+
type RequestContextStore,
|
|
22
|
+
} from './als-registry.ts';
|
|
19
23
|
import { _setGetSearchParamsFn, _setGetSegmentParamsFn } from '../shared/als-slots.ts';
|
|
20
24
|
import { appVisibleSearch } from '../shared/rsc-cache-key.ts';
|
|
21
25
|
import {
|
|
@@ -25,6 +29,7 @@ import {
|
|
|
25
29
|
} from '../shared/slot-params.ts';
|
|
26
30
|
import { isDebug } from './debug.ts';
|
|
27
31
|
import { runWithReactCacheScope, type ReactCacheScope } from './react-cache-scope.ts';
|
|
32
|
+
import type { ReactFormState } from 'react-dom/client';
|
|
28
33
|
|
|
29
34
|
// Re-export the ALS for framework-internal consumers that need direct access.
|
|
30
35
|
export { requestContextAls };
|
|
@@ -321,6 +326,16 @@ export interface RequestContextOptions {
|
|
|
321
326
|
* (TIM-868).
|
|
322
327
|
*/
|
|
323
328
|
cookies?: Map<string, string>;
|
|
329
|
+
/**
|
|
330
|
+
* The form state of the no-JS action this render answers. Only the
|
|
331
|
+
* action dispatcher's re-entry passes it; see `RequestContextStore.formState`.
|
|
332
|
+
*/
|
|
333
|
+
formState?: ReactFormState;
|
|
334
|
+
/**
|
|
335
|
+
* The error of the no-JS action this render answers. Only the action
|
|
336
|
+
* dispatcher's re-entry passes it; see `RequestContextStore.actionError`.
|
|
337
|
+
*/
|
|
338
|
+
actionError?: ActionErrorForRender;
|
|
324
339
|
}
|
|
325
340
|
|
|
326
341
|
/**
|
|
@@ -342,7 +357,7 @@ export interface RequestContextOptions {
|
|
|
342
357
|
export function runWithRequestContext<T>(
|
|
343
358
|
req: Request,
|
|
344
359
|
fn: () => T,
|
|
345
|
-
{ reactCacheScope, cookies }: RequestContextOptions = {}
|
|
360
|
+
{ reactCacheScope, cookies, formState, actionError }: RequestContextOptions = {}
|
|
346
361
|
): T {
|
|
347
362
|
const originalCopy = new Headers(req.headers);
|
|
348
363
|
const appVisible = appVisibleSearch(new URL(req.url));
|
|
@@ -366,6 +381,8 @@ export function runWithRequestContext<T>(
|
|
|
366
381
|
cookieJar: new Map(),
|
|
367
382
|
flushed: false,
|
|
368
383
|
mutableContext: false,
|
|
384
|
+
formState,
|
|
385
|
+
actionError,
|
|
369
386
|
};
|
|
370
387
|
return requestContextAls.run(store, () => runWithReactCacheScope(fn, reactCacheScope));
|
|
371
388
|
}
|
|
@@ -463,3 +480,23 @@ function freezeHeaders(source: Headers): Headers {
|
|
|
463
480
|
},
|
|
464
481
|
});
|
|
465
482
|
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The form state of the no-JS action this render answers, or `null`. Read by
|
|
486
|
+
* the SSR renderer only (`rsc-entry/ssr-renderer.ts`), which passes it to
|
|
487
|
+
* Fizz and embeds it for `hydrateRoot`. Framework-internal: not exported
|
|
488
|
+
* from `@timber-js/app/server`.
|
|
489
|
+
*/
|
|
490
|
+
export function getFormStateForSsr(): ReactFormState | null {
|
|
491
|
+
return requestContextAls.getStore()?.formState ?? null;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* The error of the no-JS action this render answers, or `null`. Read by the
|
|
496
|
+
* route element builder, which renders it in place of the page, and by the
|
|
497
|
+
* Flight `onError`, which gives it the ID the action logged it with.
|
|
498
|
+
* Framework-internal: not exported from `@timber-js/app/server`.
|
|
499
|
+
*/
|
|
500
|
+
export function getActionErrorForRender(): ActionErrorForRender | null {
|
|
501
|
+
return requestContextAls.getStore()?.actionError ?? null;
|
|
502
|
+
}
|
|
@@ -50,6 +50,7 @@ import { setFirstDeniedSegmentIndex } from './deny-boundary.ts';
|
|
|
50
50
|
import { loadModule, type ManifestLoader } from './safe-load.ts';
|
|
51
51
|
import { loadFallbackModule } from './pipeline-helpers.ts';
|
|
52
52
|
import { replaceOutermostSegmentOutlet } from './route-element-helpers.ts';
|
|
53
|
+
import { getActionErrorForRender } from './request-context.ts';
|
|
53
54
|
|
|
54
55
|
/**
|
|
55
56
|
* The `segments` array handed to a layout's `SegmentProvider` — the value
|
|
@@ -264,8 +265,18 @@ export async function buildRouteElement(
|
|
|
264
265
|
// so they go through createElement normally — no wrapper needed.
|
|
265
266
|
const leafIndex = segments.length - 1;
|
|
266
267
|
const leafDenyPages = denyPageChains.get(leafIndex);
|
|
268
|
+
// A no-JS action that threw is answered by this route with the error in
|
|
269
|
+
// place of its page (design/08 §"The Basic Wire-Up"). The layouts and
|
|
270
|
+
// access gates above still render, so the nearest error page answers and
|
|
271
|
+
// a denied segment's layout never wraps it — the render-error path.
|
|
272
|
+
const actionError = getActionErrorForRender();
|
|
267
273
|
let element: React.ReactElement;
|
|
268
|
-
if (
|
|
274
|
+
if (actionError) {
|
|
275
|
+
const ActionError = (): never => {
|
|
276
|
+
throw actionError.error;
|
|
277
|
+
};
|
|
278
|
+
element = h(ActionError, {});
|
|
279
|
+
} else if (isClientReference(PageComponent)) {
|
|
269
280
|
element = h(PageComponent, {});
|
|
270
281
|
} else if (leafDenyPages && leafDenyPages.length > 0) {
|
|
271
282
|
element = h(PageDenyBoundary, {
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* `createPipeline` calls it after proxy.ts runs (TIM-1213), so proxy.ts
|
|
9
9
|
* sees action POSTs — "proxy.ts runs on every request, no exclusions." It
|
|
10
10
|
* encapsulates action detection, the route-type check, action handler
|
|
11
|
-
* dispatch, and the no-JS
|
|
11
|
+
* dispatch, and the answer to a no-JS action that did not redirect: the
|
|
12
|
+
* page rerendered with React's form state, or the error page.
|
|
12
13
|
*
|
|
13
14
|
* The CSRF gate is not here: it runs in `createPipeline` before proxy.ts
|
|
14
15
|
* (`csrfGate` in pipeline-helpers.ts). The action handler repeats the
|
|
@@ -19,12 +20,10 @@
|
|
|
19
20
|
* auth/validation belongs in `createActionClient({ middleware })`.
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
|
-
import type { FormRerender } from '../action-handler.ts';
|
|
23
23
|
import { handleActionRequest, isActionRequest } from '../action-handler.ts';
|
|
24
24
|
import type { BodyLimitsConfig } from '../body-limits.ts';
|
|
25
25
|
import type { CsrfConfig } from '../csrf.ts';
|
|
26
|
-
import {
|
|
27
|
-
import type { RouteMatcher } from '../pipeline.ts';
|
|
26
|
+
import type { PipelineReentry, RouteMatcher } from '../pipeline.ts';
|
|
28
27
|
import { isApiRouteChain } from '../route-matcher.ts';
|
|
29
28
|
import type { SensitiveFieldsOption } from '../sensitive-fields.ts';
|
|
30
29
|
import type { RevalidateRenderer } from '../actions.ts';
|
|
@@ -69,8 +68,8 @@ export interface ActionDispatcherDeps {
|
|
|
69
68
|
*
|
|
70
69
|
* Called after proxy.ts runs (inside the pipeline) for every request.
|
|
71
70
|
* Checks if the request is a server action POST, handles the route-type
|
|
72
|
-
* check (TIM-870), dispatches to the action handler, and
|
|
73
|
-
*
|
|
71
|
+
* check (TIM-870), dispatches to the action handler, and answers a no-JS
|
|
72
|
+
* action that did not redirect.
|
|
74
73
|
*
|
|
75
74
|
* Returns `Response` if the request was handled as an action, `null` to
|
|
76
75
|
* fall through to normal routing.
|
|
@@ -80,12 +79,12 @@ export function buildActionDispatcher(
|
|
|
80
79
|
): (
|
|
81
80
|
req: Request,
|
|
82
81
|
canonicalPath: string,
|
|
83
|
-
reenter: (req: Request,
|
|
82
|
+
reenter: (req: Request, reentry: PipelineReentry) => Promise<Response>
|
|
84
83
|
) => Promise<Response | null> {
|
|
85
84
|
return async (
|
|
86
85
|
req: Request,
|
|
87
86
|
canonicalPath: string,
|
|
88
|
-
reenter: (req: Request,
|
|
87
|
+
reenter: (req: Request, reentry: PipelineReentry) => Promise<Response>
|
|
89
88
|
): Promise<Response | null> => {
|
|
90
89
|
if (!isActionRequest(req)) return null;
|
|
91
90
|
|
|
@@ -109,34 +108,38 @@ export function buildActionDispatcher(
|
|
|
109
108
|
revalidateRenderer: deps.buildRevalidateRenderer(req),
|
|
110
109
|
});
|
|
111
110
|
|
|
112
|
-
if (actionResponse) {
|
|
113
|
-
// No-JS validation rerender
|
|
114
|
-
if ('rerender' in actionResponse) {
|
|
115
|
-
const formRerender = actionResponse as FormRerender;
|
|
116
|
-
// Build a synthetic GET request for the rerender pipeline:
|
|
117
|
-
// - Same URL (so route matching lands on the same page)
|
|
118
|
-
// - Cookie header DROPPED entirely. The post-action RYW state
|
|
119
|
-
// is handed to `reenter` as a parsed `Map<string, string>`,
|
|
120
|
-
// which becomes the rerender request context's cookies.
|
|
121
|
-
// - Method GET because the rerender is conceptually a page render.
|
|
122
|
-
const rerenderHeaders = new Headers(req.headers);
|
|
123
|
-
rerenderHeaders.delete('cookie');
|
|
124
|
-
const rerenderReq = new Request(req.url, {
|
|
125
|
-
method: 'GET',
|
|
126
|
-
headers: rerenderHeaders,
|
|
127
|
-
});
|
|
128
|
-
const response = await runWithFormFlash(formRerender.rerender, () =>
|
|
129
|
-
reenter(rerenderReq, formRerender.cookies)
|
|
130
|
-
);
|
|
131
|
-
// Apply Set-Cookie headers snapshotted from the action's ALS scope.
|
|
132
|
-
for (const value of formRerender.setCookieHeaders) {
|
|
133
|
-
response.headers.append('Set-Cookie', value);
|
|
134
|
-
}
|
|
135
|
-
return response;
|
|
136
|
-
}
|
|
111
|
+
if (actionResponse === null || actionResponse instanceof Response) {
|
|
137
112
|
return actionResponse;
|
|
138
113
|
}
|
|
139
114
|
|
|
140
|
-
|
|
115
|
+
// Both answers re-enter the pipeline for a page render of the same URL,
|
|
116
|
+
// as a GET — the rerender with the action's form state, the throw with
|
|
117
|
+
// its error, which the route renders in place of its page so the
|
|
118
|
+
// nearest error page answers (SSR has no error boundaries, so that page
|
|
119
|
+
// comes from the shell-failure path):
|
|
120
|
+
// - Same URL (so route matching lands on the same page)
|
|
121
|
+
// - Cookie header DROPPED entirely. The post-action RYW state is
|
|
122
|
+
// handed over as a parsed `Map<string, string>` and becomes the
|
|
123
|
+
// render's request-context cookies, so the page reads what the
|
|
124
|
+
// action wrote, not what the request carried (TIM-868).
|
|
125
|
+
// - Method GET because it is a page render.
|
|
126
|
+
const rerenderHeaders = new Headers(req.headers);
|
|
127
|
+
rerenderHeaders.delete('cookie');
|
|
128
|
+
const rerenderReq = new Request(req.url, {
|
|
129
|
+
method: 'GET',
|
|
130
|
+
headers: rerenderHeaders,
|
|
131
|
+
});
|
|
132
|
+
const { cookies } = actionResponse;
|
|
133
|
+
const response = await reenter(
|
|
134
|
+
rerenderReq,
|
|
135
|
+
actionResponse.kind === 'error'
|
|
136
|
+
? { cookies, actionError: { error: actionResponse.error, errorId: actionResponse.errorId } }
|
|
137
|
+
: { cookies, formState: actionResponse.formState }
|
|
138
|
+
);
|
|
139
|
+
// Apply Set-Cookie headers snapshotted from the action's ALS scope.
|
|
140
|
+
for (const value of actionResponse.setCookieHeaders) {
|
|
141
|
+
response.headers.append('Set-Cookie', value);
|
|
142
|
+
}
|
|
143
|
+
return response;
|
|
141
144
|
};
|
|
142
145
|
}
|
|
@@ -29,6 +29,7 @@ import { getCookiesForSsr } from '../cookie-context.ts';
|
|
|
29
29
|
import { callSsr } from './ssr-bridge.ts';
|
|
30
30
|
import { teeWithErrorPropagation } from '../stream-utils.ts';
|
|
31
31
|
import { isDevMode } from '../debug.ts';
|
|
32
|
+
import { clientErrorMessage } from '../client-error-message.ts';
|
|
32
33
|
import { ErrorReconstituter } from '../../client/error-reconstituter.tsx';
|
|
33
34
|
import type { SerializableError } from '../../client/error-reconstituter.tsx';
|
|
34
35
|
import type { ManifestLoader } from '../safe-load.ts';
|
|
@@ -148,7 +149,7 @@ function toSerializableError(error: unknown): SerializableError {
|
|
|
148
149
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
149
150
|
const devMode = isDevMode();
|
|
150
151
|
return {
|
|
151
|
-
message:
|
|
152
|
+
message: clientErrorMessage(err),
|
|
152
153
|
name: devMode ? err.name : 'Error',
|
|
153
154
|
...(devMode && err.stack ? { stack: err.stack } : {}),
|
|
154
155
|
};
|
|
@@ -18,13 +18,14 @@ import { randomUUID } from 'node:crypto';
|
|
|
18
18
|
|
|
19
19
|
import { matchDenyEntries, type DenyPageEntry } from '../deny-boundary.ts';
|
|
20
20
|
import { reportRenderError } from '../pipeline-helpers.ts';
|
|
21
|
+
import { getActionErrorForRender } from '../request-context.ts';
|
|
22
|
+
import { clientErrorMessage } from '../client-error-message.ts';
|
|
21
23
|
import { isDenySignal, isRedirectSignal } from '../signal-identity.ts';
|
|
22
24
|
import { checkAndWarnRscPropError } from '../rsc-prop-warnings.ts';
|
|
23
25
|
|
|
24
26
|
import { isAbortError } from './helpers.ts';
|
|
25
27
|
import { createSignalRecorder, type RenderSignals } from './render-signals.ts';
|
|
26
28
|
import { isDebug } from '../debug.ts';
|
|
27
|
-
import { isDevMode } from '../debug.ts';
|
|
28
29
|
|
|
29
30
|
export interface RscStreamResult {
|
|
30
31
|
rscStream: ReadableStream<Uint8Array>;
|
|
@@ -135,16 +136,17 @@ export function renderRscStream(
|
|
|
135
136
|
// whether a Suspense boundary contains it or it crashes the shell
|
|
136
137
|
// and the renderer answers with the error page (design/49
|
|
137
138
|
// §"Deferred: render-error handoff").
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
139
|
+
//
|
|
140
|
+
// The one exception is a no-JS action's error, rendered in place of
|
|
141
|
+
// the page (route-element-builder.ts): the action already logged and
|
|
142
|
+
// reported it, so it keeps that log line's ID and is not reported
|
|
143
|
+
// twice.
|
|
144
|
+
const actionError = getActionErrorForRender();
|
|
145
|
+
const errorId =
|
|
146
|
+
actionError && actionError.error === error
|
|
147
|
+
? actionError.errorId
|
|
148
|
+
: reportedErrorId(error, req);
|
|
149
|
+
return encodeErrorDigest({ message: clientErrorMessage(error), errorId });
|
|
148
150
|
},
|
|
149
151
|
},
|
|
150
152
|
{
|
|
@@ -159,3 +161,10 @@ export function renderRscStream(
|
|
|
159
161
|
|
|
160
162
|
return { rscStream, signals: recorder.signals };
|
|
161
163
|
}
|
|
164
|
+
|
|
165
|
+
/** Log and report an unhandled render error; the ID it was logged with. */
|
|
166
|
+
function reportedErrorId(error: unknown, req: Request): string {
|
|
167
|
+
const errorId = randomUUID();
|
|
168
|
+
reportRenderError(error, req, { errorId });
|
|
169
|
+
return errorId;
|
|
170
|
+
}
|
|
@@ -28,6 +28,8 @@ import type { NavContext, SsrRenderResult } from '../ssr-bridge-types.ts';
|
|
|
28
28
|
import { htmlEscapeJsonString } from '../flight-scripts.ts';
|
|
29
29
|
import { buildSegmentInfo, isAbortError } from './helpers.ts';
|
|
30
30
|
import { getCookiesForSsr } from '../cookie-context.ts';
|
|
31
|
+
import { getFormStateForSsr } from '../request-context.ts';
|
|
32
|
+
import { formStateForRender } from '../form-state-embed.ts';
|
|
31
33
|
import { getDenyStatus, getFirstDeniedSegmentIndex } from '../deny-boundary.ts';
|
|
32
34
|
import { renderErrorPage, type GlobalErrorFile } from './error-renderer.ts';
|
|
33
35
|
import { callSsr } from './ssr-bridge.ts';
|
|
@@ -170,6 +172,15 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
|
|
|
170
172
|
// that is the value every inline script must wear.
|
|
171
173
|
const nonce = getCspNonce();
|
|
172
174
|
|
|
175
|
+
// Only a page answering a no-JS action has one (design/08 §"No-JS Result
|
|
176
|
+
// Round-Trip"). Fizz gives it to the hook that submitted; the browser
|
|
177
|
+
// needs the same value for hydrateRoot, or hydration resets that hook.
|
|
178
|
+
const { formState, script: formStateScript } = formStateForRender(
|
|
179
|
+
getFormStateForSsr(),
|
|
180
|
+
clientJsDisabled,
|
|
181
|
+
nonce
|
|
182
|
+
);
|
|
183
|
+
|
|
173
184
|
const reqUrl = new URL(req.url);
|
|
174
185
|
const navContext: NavContext = {
|
|
175
186
|
pathname: reqUrl.pathname,
|
|
@@ -181,7 +192,8 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
|
|
|
181
192
|
clientBootstrap.preloadLinks +
|
|
182
193
|
// Initialize __timber_f in <head> so it exists before any streaming
|
|
183
194
|
// chunk scripts arrive in <body>. See flight-scripts.ts, LOCAL-415.
|
|
184
|
-
(clientJsDisabled ? '' : flightInitScript(nonce))
|
|
195
|
+
(clientJsDisabled ? '' : flightInitScript(nonce)) +
|
|
196
|
+
formStateScript,
|
|
185
197
|
getHeadSegmentScript: clientJsDisabled
|
|
186
198
|
? undefined
|
|
187
199
|
: () =>
|
|
@@ -193,6 +205,7 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
|
|
|
193
205
|
deferSuspenseFor: deferSuspenseFor > 0 ? deferSuspenseFor : undefined,
|
|
194
206
|
signal: req.signal,
|
|
195
207
|
cookies: getCookiesForSsr(),
|
|
208
|
+
formState,
|
|
196
209
|
// Stops the Flight injector writing <script> tags once an unhandled RSC
|
|
197
210
|
// error is captured. Without this, React's Flight encoder may write model
|
|
198
211
|
// data for a chunk that already received an error row, causing the
|
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
* and `CLAUDE.md` §"RSC and SSR Are Separate Vite Environments".
|
|
51
51
|
*/
|
|
52
52
|
|
|
53
|
+
import type { ReactFormState } from 'react-dom/client';
|
|
54
|
+
|
|
53
55
|
/**
|
|
54
56
|
* Navigation context passed from the RSC environment to SSR.
|
|
55
57
|
*
|
|
@@ -103,6 +105,13 @@ export interface NavContext {
|
|
|
103
105
|
/** Request cookies as name→value pairs. Used by useCookie() during SSR
|
|
104
106
|
* to return correct cookie values before hydration. */
|
|
105
107
|
cookies?: Map<string, string>;
|
|
108
|
+
/**
|
|
109
|
+
* The form state of the no-JS action this page answers (React's
|
|
110
|
+
* `decodeFormState`), passed to Fizz so the `useActionState` hook that
|
|
111
|
+
* submitted renders with the action's result. Absent on every other
|
|
112
|
+
* render. See design/08-forms-and-actions.md §"No-JS Result Round-Trip".
|
|
113
|
+
*/
|
|
114
|
+
formState?: ReactFormState;
|
|
106
115
|
/**
|
|
107
116
|
* Lazy thunk that produces the `<script>self.__timber_segments=…</script>`
|
|
108
117
|
* tag. Invoked by the SSR entry AFTER `renderSsrNodeStream` /
|
package/src/server/ssr-entry.ts
CHANGED
|
@@ -198,6 +198,7 @@ export async function handleSsr(
|
|
|
198
198
|
const renderOptions: SsrStreamOptions = {
|
|
199
199
|
bootstrapScriptContent: navContext.bootstrapScriptContent || undefined,
|
|
200
200
|
nonce,
|
|
201
|
+
formState: navContext.formState,
|
|
201
202
|
deferSuspenseFor: navContext.deferSuspenseFor,
|
|
202
203
|
signal: navContext.signal,
|
|
203
204
|
renderTimeoutMs,
|
package/src/server/ssr-render.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import type { ReactNode } from 'react';
|
|
24
24
|
import { renderToReadableStream } from 'react-dom/server';
|
|
25
|
+
import type { ReactFormState } from 'react-dom/client';
|
|
25
26
|
|
|
26
27
|
import { createRenderTimeout, RenderTimeoutError } from './render-timeout.ts';
|
|
27
28
|
import { logRenderError, logStreamingError } from './logger.ts';
|
|
@@ -123,6 +124,8 @@ export interface SsrStreamOptions {
|
|
|
123
124
|
bootstrapScriptContent?: string;
|
|
124
125
|
/** Request CSP nonce — stamped by Fizz on the bootstrap and runtime scripts. */
|
|
125
126
|
nonce?: string;
|
|
127
|
+
/** A no-JS action's form state, for the `useActionState` hook that submitted. */
|
|
128
|
+
formState?: ReactFormState;
|
|
126
129
|
deferSuspenseFor?: number;
|
|
127
130
|
signal?: AbortSignal;
|
|
128
131
|
renderTimeoutMs?: number;
|
|
@@ -202,6 +205,7 @@ export async function renderSsrNodeStream(
|
|
|
202
205
|
const { pipe, abort } = _renderToPipeableStream!(element, {
|
|
203
206
|
bootstrapScriptContent: options?.bootstrapScriptContent || undefined,
|
|
204
207
|
nonce: options?.nonce,
|
|
208
|
+
formState: options?.formState,
|
|
205
209
|
|
|
206
210
|
onShellReady() {
|
|
207
211
|
if (deferMs && deferMs > 0) {
|
|
@@ -321,6 +325,7 @@ async function renderViaReadableStream(
|
|
|
321
325
|
stream = await renderToReadableStream(element, {
|
|
322
326
|
bootstrapScriptContent: options?.bootstrapScriptContent || undefined,
|
|
323
327
|
nonce: options?.nonce,
|
|
328
|
+
formState: options?.formState,
|
|
324
329
|
signal: effectiveSignal,
|
|
325
330
|
onError(error: unknown) {
|
|
326
331
|
if (isAbortError(error) || effectiveSignal?.aborted) return;
|