@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.
Files changed (140) hide show
  1. package/agent-skill.md +10 -5
  2. package/dist/_chunks/{actions-Rjk4htmA.js → actions-CCdnVtWm.js} +8 -6
  3. package/dist/_chunks/actions-CCdnVtWm.js.map +1 -0
  4. package/dist/_chunks/{als-registry-DaxkVjt5.js → als-registry-BZqHCtq-.js} +2 -4
  5. package/dist/_chunks/als-registry-BZqHCtq-.js.map +1 -0
  6. package/dist/_chunks/{cache-api-DGdYfNJn.js → cache-api-LA3sBpUS.js} +5 -5
  7. package/dist/_chunks/{cache-api-DGdYfNJn.js.map → cache-api-LA3sBpUS.js.map} +1 -1
  8. package/dist/_chunks/{chains-DGX9zmg9.js → chains-BfoPFraI.js} +2 -2
  9. package/dist/_chunks/{chains-DGX9zmg9.js.map → chains-BfoPFraI.js.map} +1 -1
  10. package/dist/_chunks/{classify-QwG5rxKI.js → classify-BT66U83D.js} +2 -2
  11. package/dist/_chunks/{classify-QwG5rxKI.js.map → classify-BT66U83D.js.map} +1 -1
  12. package/dist/_chunks/{cli-check-ajNY3B2e.js → cli-check-BfQ54-UJ.js} +3 -3
  13. package/dist/_chunks/{cli-check-ajNY3B2e.js.map → cli-check-BfQ54-UJ.js.map} +1 -1
  14. package/dist/_chunks/{cli-schema-sync-D2eI8jEg.js → cli-schema-sync-czh2dsLs.js} +2 -2
  15. package/dist/_chunks/{cli-schema-sync-D2eI8jEg.js.map → cli-schema-sync-czh2dsLs.js.map} +1 -1
  16. package/dist/_chunks/{client-dep-entries-2HCF09no.js → client-dep-entries-CQwpb8dI.js} +2 -2
  17. package/dist/_chunks/{client-dep-entries-2HCF09no.js.map → client-dep-entries-CQwpb8dI.js.map} +1 -1
  18. package/dist/_chunks/{convention-lint-DLmhGsRS.js → convention-lint-BEVW4EID.js} +3 -3
  19. package/dist/_chunks/{convention-lint-DLmhGsRS.js.map → convention-lint-BEVW4EID.js.map} +1 -1
  20. package/dist/_chunks/{dev-server-TFpEwm3H.js → dev-server-FKxptbnI.js} +2 -2
  21. package/dist/_chunks/{dev-server-TFpEwm3H.js.map → dev-server-FKxptbnI.js.map} +1 -1
  22. package/dist/_chunks/{json-lossy-check-CVuRs2hG.js → json-lossy-check-C8zBY2uZ.js} +2 -2
  23. package/dist/_chunks/{json-lossy-check-CVuRs2hG.js.map → json-lossy-check-C8zBY2uZ.js.map} +1 -1
  24. package/dist/_chunks/{live-graph-cNuWMYQI.js → live-graph-C_4v-fHv.js} +4 -4
  25. package/dist/_chunks/{live-graph-cNuWMYQI.js.map → live-graph-C_4v-fHv.js.map} +1 -1
  26. package/dist/_chunks/{logger-BP0LN6vP.js → logger-CbLdcy-W.js} +2 -2
  27. package/dist/_chunks/{logger-BP0LN6vP.js.map → logger-CbLdcy-W.js.map} +1 -1
  28. package/dist/_chunks/{poison-scan-CfQ3unZR.js → poison-scan-C92liMAr.js} +2 -2
  29. package/dist/_chunks/{poison-scan-CfQ3unZR.js.map → poison-scan-C92liMAr.js.map} +1 -1
  30. package/dist/_chunks/{scanner-DmqdxzbW.js → scanner-CQt12vE2.js} +2 -2
  31. package/dist/_chunks/{scanner-DmqdxzbW.js.map → scanner-CQt12vE2.js.map} +1 -1
  32. package/dist/_chunks/{sizeof-BM1409x2.js → sizeof-QPE5nd3u.js} +2 -2
  33. package/dist/_chunks/{sizeof-BM1409x2.js.map → sizeof-QPE5nd3u.js.map} +1 -1
  34. package/dist/_chunks/{walkers-Czu2jXFq.js → walkers-DAT4avhZ.js} +3 -3
  35. package/dist/_chunks/{walkers-Czu2jXFq.js.map → walkers-DAT4avhZ.js.map} +1 -1
  36. package/dist/adapters/cloudflare-kv-cache.js +1 -1
  37. package/dist/analyze/crawl-entry.js +3 -3
  38. package/dist/analyze/graph-command.js +2 -2
  39. package/dist/cache/index.js +2 -2
  40. package/dist/cache/stores/memory.js +1 -1
  41. package/dist/cdn/workers-cache-purge.js +1 -1
  42. package/dist/cli.js +3 -3
  43. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  44. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  45. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  46. package/dist/client/form.d.ts +17 -64
  47. package/dist/client/form.d.ts.map +1 -1
  48. package/dist/client/index.d.ts +2 -2
  49. package/dist/client/index.d.ts.map +1 -1
  50. package/dist/client/index.js +23 -51
  51. package/dist/client/index.js.map +1 -1
  52. package/dist/client/internal.js +25 -25
  53. package/dist/client/internal.js.map +1 -1
  54. package/dist/client/navigation-api.d.ts +25 -63
  55. package/dist/client/navigation-api.d.ts.map +1 -1
  56. package/dist/client/router-lifecycle.d.ts +17 -11
  57. package/dist/client/router-lifecycle.d.ts.map +1 -1
  58. package/dist/client/router-pipeline.d.ts +0 -1
  59. package/dist/client/router-pipeline.d.ts.map +1 -1
  60. package/dist/client/router-types.d.ts +14 -45
  61. package/dist/client/router-types.d.ts.map +1 -1
  62. package/dist/client/router.d.ts.map +1 -1
  63. package/dist/index.js +6 -7
  64. package/dist/index.js.map +1 -1
  65. package/dist/plugins/shims.d.ts.map +1 -1
  66. package/dist/routing/index.js +2 -2
  67. package/dist/rsc-runtime/rsc.d.ts +1 -1
  68. package/dist/rsc-runtime/rsc.d.ts.map +1 -1
  69. package/dist/server/action-client.d.ts +9 -5
  70. package/dist/server/action-client.d.ts.map +1 -1
  71. package/dist/server/action-handler.d.ts +27 -8
  72. package/dist/server/action-handler.d.ts.map +1 -1
  73. package/dist/server/als-registry.d.ts +22 -2
  74. package/dist/server/als-registry.d.ts.map +1 -1
  75. package/dist/server/client-error-message.d.ts +12 -0
  76. package/dist/server/client-error-message.d.ts.map +1 -0
  77. package/dist/server/form-state-embed.d.ts +32 -0
  78. package/dist/server/form-state-embed.d.ts.map +1 -0
  79. package/dist/server/index.d.ts +0 -2
  80. package/dist/server/index.d.ts.map +1 -1
  81. package/dist/server/index.js +7 -40
  82. package/dist/server/index.js.map +1 -1
  83. package/dist/server/internal.js +10 -6
  84. package/dist/server/internal.js.map +1 -1
  85. package/dist/server/logger.d.ts +1 -0
  86. package/dist/server/logger.d.ts.map +1 -1
  87. package/dist/server/pipeline.d.ts +20 -6
  88. package/dist/server/pipeline.d.ts.map +1 -1
  89. package/dist/server/request-context.d.ts +27 -2
  90. package/dist/server/request-context.d.ts.map +1 -1
  91. package/dist/server/route-element-builder.d.ts.map +1 -1
  92. package/dist/server/rsc-entry/action-dispatcher.d.ts +6 -5
  93. package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -1
  94. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  95. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  96. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  97. package/dist/server/ssr-bridge-types.d.ts +8 -0
  98. package/dist/server/ssr-bridge-types.d.ts.map +1 -1
  99. package/dist/server/ssr-entry.d.ts.map +1 -1
  100. package/dist/server/ssr-render.d.ts +3 -0
  101. package/dist/server/ssr-render.d.ts.map +1 -1
  102. package/docs/api/31-api-client.mdx +9 -3
  103. package/docs/learn/08-forms-and-actions.mdx +21 -28
  104. package/package.json +1 -1
  105. package/src/client/browser-entry/action-dispatch.ts +74 -18
  106. package/src/client/browser-entry/hydrate.ts +18 -0
  107. package/src/client/browser-entry/router-init.ts +4 -18
  108. package/src/client/form.tsx +33 -98
  109. package/src/client/index.ts +2 -2
  110. package/src/client/navigation-api.ts +47 -173
  111. package/src/client/navigation-transition.ts +2 -2
  112. package/src/client/router-lifecycle.ts +40 -20
  113. package/src/client/router-pipeline.ts +3 -9
  114. package/src/client/router-types.ts +14 -49
  115. package/src/client/router.ts +38 -58
  116. package/src/plugins/shims.ts +0 -2
  117. package/src/rsc-runtime/rsc.ts +3 -0
  118. package/src/rsc-runtime/vendor-types.d.ts +14 -0
  119. package/src/server/action-client.ts +18 -20
  120. package/src/server/action-handler.ts +133 -95
  121. package/src/server/als-registry.ts +23 -9
  122. package/src/server/client-error-message.ts +18 -0
  123. package/src/server/form-state-embed.ts +63 -0
  124. package/src/server/index.ts +0 -4
  125. package/src/server/logger.ts +6 -1
  126. package/src/server/pipeline.ts +27 -8
  127. package/src/server/request-context.ts +39 -2
  128. package/src/server/route-element-builder.ts +12 -1
  129. package/src/server/rsc-entry/action-dispatcher.ts +37 -34
  130. package/src/server/rsc-entry/error-renderer.ts +2 -1
  131. package/src/server/rsc-entry/rsc-stream.ts +20 -11
  132. package/src/server/rsc-entry/ssr-renderer.ts +14 -1
  133. package/src/server/ssr-bridge-types.ts +9 -0
  134. package/src/server/ssr-entry.ts +1 -0
  135. package/src/server/ssr-render.ts +5 -0
  136. package/dist/_chunks/actions-Rjk4htmA.js.map +0 -1
  137. package/dist/_chunks/als-registry-DaxkVjt5.js.map +0 -1
  138. package/dist/server/form-flash.d.ts +0 -78
  139. package/dist/server/form-flash.d.ts.map +0 -1
  140. 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 { requestContextAls, type RequestContextStore } from './als-registry.ts';
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 (isClientReference(PageComponent)) {
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 validation rerender path.
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 { runWithFormFlash } from '../form-flash.ts';
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 handles the
73
- * no-JS validation rerender path.
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, cookies: Map<string, string>) => Promise<Response>
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, cookies: Map<string, string>) => Promise<Response>
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
- return null;
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: devMode ? err.message : 'An unexpected error occurred.',
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
- const errorId = randomUUID();
139
- reportRenderError(error, req, { errorId });
140
- return encodeErrorDigest({
141
- message: isDevMode()
142
- ? error instanceof Error
143
- ? error.message
144
- : String(error)
145
- : 'An unexpected error occurred.',
146
- errorId,
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` /
@@ -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,
@@ -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;