@timber-js/app 0.2.0-alpha.212 → 0.2.0-alpha.213

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 (92) hide show
  1. package/dist/_chunks/{actions-CCdnVtWm.js → actions-CEootpB1.js} +42 -7
  2. package/dist/_chunks/{actions-CCdnVtWm.js.map → actions-CEootpB1.js.map} +1 -1
  3. package/dist/_chunks/{error-boundary-DsNScGRM.js → error-boundary-9g_Lb2na.js} +3 -3
  4. package/dist/_chunks/{error-boundary-DsNScGRM.js.map → error-boundary-9g_Lb2na.js.map} +1 -1
  5. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  6. package/dist/client/browser-entry/action-queue.d.ts +1 -0
  7. package/dist/client/browser-entry/action-queue.d.ts.map +1 -1
  8. package/dist/client/browser-entry/form-state.d.ts +22 -0
  9. package/dist/client/browser-entry/form-state.d.ts.map +1 -0
  10. package/dist/client/browser-entry/hydrate.d.ts +9 -1
  11. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  12. package/dist/client/browser-entry/index.d.ts +2 -0
  13. package/dist/client/browser-entry/index.d.ts.map +1 -1
  14. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  15. package/dist/client/error-boundary.js +1 -1
  16. package/dist/client/index.d.ts +1 -0
  17. package/dist/client/index.d.ts.map +1 -1
  18. package/dist/client/index.js +90 -2
  19. package/dist/client/index.js.map +1 -1
  20. package/dist/client/internal.js +8 -7
  21. package/dist/client/internal.js.map +1 -1
  22. package/dist/client/navigation-transition.d.ts +11 -2
  23. package/dist/client/navigation-transition.d.ts.map +1 -1
  24. package/dist/client/router-effects.d.ts +7 -3
  25. package/dist/client/router-effects.d.ts.map +1 -1
  26. package/dist/client/router-pipeline.d.ts +3 -1
  27. package/dist/client/router-pipeline.d.ts.map +1 -1
  28. package/dist/client/router-types.d.ts +12 -1
  29. package/dist/client/router-types.d.ts.map +1 -1
  30. package/dist/client/router.d.ts.map +1 -1
  31. package/dist/client/use-form-field.d.ts +39 -0
  32. package/dist/client/use-form-field.d.ts.map +1 -0
  33. package/dist/config-types.d.ts +2 -1
  34. package/dist/config-types.d.ts.map +1 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/server/action-client.d.ts +11 -2
  37. package/dist/server/action-client.d.ts.map +1 -1
  38. package/dist/server/flight-scripts.d.ts +9 -0
  39. package/dist/server/flight-scripts.d.ts.map +1 -1
  40. package/dist/server/form-data.d.ts +13 -4
  41. package/dist/server/form-data.d.ts.map +1 -1
  42. package/dist/server/form-state-flight.d.ts +32 -0
  43. package/dist/server/form-state-flight.d.ts.map +1 -0
  44. package/dist/server/index.js +37 -23
  45. package/dist/server/index.js.map +1 -1
  46. package/dist/server/internal.js +1 -1
  47. package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -1
  48. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  49. package/dist/server/rsc-entry/render-route.d.ts +2 -0
  50. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  51. package/dist/server/rsc-entry/ssr-renderer.d.ts +6 -0
  52. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  53. package/dist/server/ssr-bridge-types.d.ts +7 -5
  54. package/dist/server/ssr-bridge-types.d.ts.map +1 -1
  55. package/dist/server/ssr-entry.d.ts.map +1 -1
  56. package/dist/server/ssr-form-state.d.ts +30 -0
  57. package/dist/server/ssr-form-state.d.ts.map +1 -0
  58. package/dist/shared/form-state-flight.d.ts +36 -0
  59. package/dist/shared/form-state-flight.d.ts.map +1 -0
  60. package/docs/api/31-api-client.mdx +22 -0
  61. package/docs/api/34-api-config.mdx +1 -1
  62. package/docs/learn/08-forms-and-actions.mdx +109 -22
  63. package/package.json +1 -1
  64. package/src/client/browser-entry/action-dispatch.ts +34 -10
  65. package/src/client/browser-entry/action-queue.ts +1 -1
  66. package/src/client/browser-entry/form-state.ts +48 -0
  67. package/src/client/browser-entry/hydrate.ts +9 -18
  68. package/src/client/browser-entry/index.ts +25 -7
  69. package/src/client/browser-entry/router-init.ts +3 -2
  70. package/src/client/index.ts +1 -0
  71. package/src/client/navigation-transition.ts +13 -2
  72. package/src/client/router-effects.ts +8 -4
  73. package/src/client/router-pipeline.ts +7 -3
  74. package/src/client/router-types.ts +12 -1
  75. package/src/client/router.ts +16 -3
  76. package/src/client/use-form-field.ts +132 -0
  77. package/src/config-types.ts +2 -1
  78. package/src/server/action-client.ts +68 -53
  79. package/src/server/flight-scripts.ts +13 -0
  80. package/src/server/form-data.ts +62 -10
  81. package/src/server/form-state-flight.ts +67 -0
  82. package/src/server/rsc-entry/action-dispatcher.ts +3 -0
  83. package/src/server/rsc-entry/index.ts +1 -0
  84. package/src/server/rsc-entry/render-route.ts +16 -0
  85. package/src/server/rsc-entry/ssr-renderer.ts +12 -14
  86. package/src/server/ssr-bridge-types.ts +7 -6
  87. package/src/server/ssr-entry.ts +16 -3
  88. package/src/server/ssr-form-state.ts +58 -0
  89. package/src/shared/form-state-flight.ts +74 -0
  90. package/dist/server/form-state-embed.d.ts +0 -32
  91. package/dist/server/form-state-embed.d.ts.map +0 -1
  92. package/src/server/form-state-embed.ts +0 -63
@@ -38,6 +38,8 @@ import type { DenyFallbackRenderer } from './deny-fallback.ts';
38
38
  import { isRscRequest } from '../../shared/rsc-media-type.ts';
39
39
  import { buildRscPayloadResponse } from './rsc-payload.ts';
40
40
  import { renderRscStream } from './rsc-stream.ts';
41
+ import { encodeFormState } from '../form-state-flight.ts';
42
+ import { getFormStateForSsr } from '../request-context.ts';
41
43
  import { renderSsrResponse } from './ssr-renderer.ts';
42
44
 
43
45
  /**
@@ -51,6 +53,8 @@ export interface RenderRouteDeps {
51
53
  clientSegmentCache: boolean;
52
54
  renderDenyFallback: DenyFallbackRenderer;
53
55
  buildManifest: BuildManifest;
56
+ /** The resolved `renderTimeoutMs`: bounds encoding a no-JS form state. */
57
+ renderTimeoutMs: number;
54
58
  globalError?: { load: () => Promise<unknown>; filePath: string };
55
59
  }
56
60
 
@@ -210,6 +214,17 @@ export async function renderRoute(
210
214
  );
211
215
  }
212
216
 
217
+ // The form state of the no-JS action this page answers, if any, as the
218
+ // Flight bytes both SSR and the browser decode (design/08 §"No-JS Result
219
+ // Round-Trip"). Encoded while the RSC render above is already running,
220
+ // under the render timeout and the request's abort signal. A promise in
221
+ // the result holds the response until it settles: the page must carry the
222
+ // whole state, as Fizz would wait for it anyway.
223
+ const pendingFormState = getFormStateForSsr();
224
+ const formState = pendingFormState
225
+ ? await encodeFormState(pendingFormState, deps.renderTimeoutMs, req.signal)
226
+ : null;
227
+
213
228
  // Pipe through SSR for HTML rendering with streaming Suspense support.
214
229
  return renderSsrResponse({
215
230
  req,
@@ -225,5 +240,6 @@ export async function renderRoute(
225
240
  deferSuspenseFor,
226
241
  globalError,
227
242
  slotSkipInfo,
243
+ formState,
228
244
  });
229
245
  }
@@ -28,8 +28,6 @@ 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';
33
31
  import { getDenyStatus, getFirstDeniedSegmentIndex } from '../deny-boundary.ts';
34
32
  import { renderErrorPage, type GlobalErrorFile } from './error-renderer.ts';
35
33
  import { callSsr } from './ssr-bridge.ts';
@@ -103,6 +101,12 @@ interface SsrRenderOptions {
103
101
  /** Tier 2 global-error.tsx file, if present in app/. */
104
102
  globalError?: GlobalErrorFile;
105
103
  slotSkipInfo?: SlotSkipEntry[];
104
+ /**
105
+ * The Flight bytes of the no-JS action's form state this page answers
106
+ * (`encodeFormState`); absent on every other render. Fizz and hydrateRoot
107
+ * both decode them.
108
+ */
109
+ formState?: Uint8Array | null;
106
110
  }
107
111
 
108
112
  /**
@@ -142,6 +146,7 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
142
146
  headHtml,
143
147
  deferSuspenseFor,
144
148
  globalError,
149
+ formState,
145
150
  } = opts;
146
151
 
147
152
  // Tee the RSC stream — one copy goes to SSR for HTML rendering,
@@ -172,15 +177,6 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
172
177
  // that is the value every inline script must wear.
173
178
  const nonce = getCspNonce();
174
179
 
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
-
184
180
  const reqUrl = new URL(req.url);
185
181
  const navContext: NavContext = {
186
182
  pathname: reqUrl.pathname,
@@ -192,8 +188,7 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
192
188
  clientBootstrap.preloadLinks +
193
189
  // Initialize __timber_f in <head> so it exists before any streaming
194
190
  // chunk scripts arrive in <body>. See flight-scripts.ts, LOCAL-415.
195
- (clientJsDisabled ? '' : flightInitScript(nonce)) +
196
- formStateScript,
191
+ (clientJsDisabled ? '' : flightInitScript(nonce)),
197
192
  getHeadSegmentScript: clientJsDisabled
198
193
  ? undefined
199
194
  : () =>
@@ -205,7 +200,10 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
205
200
  deferSuspenseFor: deferSuspenseFor > 0 ? deferSuspenseFor : undefined,
206
201
  signal: req.signal,
207
202
  cookies: getCookiesForSsr(),
208
- formState,
203
+ // Only a page answering a no-JS action has form state (design/08
204
+ // §"No-JS Result Round-Trip"). SSR decodes it for Fizz and embeds it
205
+ // for hydrateRoot (server/ssr-form-state.ts).
206
+ formState: formState ?? undefined,
209
207
  // Stops the Flight injector writing <script> tags once an unhandled RSC
210
208
  // error is captured. Without this, React's Flight encoder may write model
211
209
  // data for a chunk that already received an error row, causing the
@@ -50,8 +50,6 @@
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
-
55
53
  /**
56
54
  * Navigation context passed from the RSC environment to SSR.
57
55
  *
@@ -107,11 +105,14 @@ export interface NavContext {
107
105
  cookies?: Map<string, string>;
108
106
  /**
109
107
  * 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".
108
+ * `decodeFormState`), as Flight bytes. SSR decodes them for Fizz and,
109
+ * once they decoded, embeds the same bytes for hydration
110
+ * (server/ssr-form-state.ts), so the `useActionState` hook that submitted
111
+ * renders with the value the browser hydrates with. Absent
112
+ * on every other render. See design/08-forms-and-actions.md §"No-JS Result
113
+ * Round-Trip".
113
114
  */
114
- formState?: ReactFormState;
115
+ formState?: Uint8Array;
115
116
  /**
116
117
  * Lazy thunk that produces the `<script>self.__timber_segments=…</script>`
117
118
  * tag. Invoked by the SSR entry AFTER `renderSsrNodeStream` /
@@ -41,6 +41,8 @@ import { wrapSsrElement } from './ssr-wrappers.tsx';
41
41
  import { withSpan } from './tracing.ts';
42
42
  import { registerSsrDataProvider, type SsrData } from '../client/ssr-data.ts';
43
43
  import { splitPayloadRoot } from '../shared/payload-root.ts';
44
+ import { formStateForSsr } from './ssr-form-state.ts';
45
+ import { resolveRenderTimeoutMs } from './render-timeout.ts';
44
46
  import type { NavContext, SsrRenderResult } from './ssr-bridge-types.ts';
45
47
 
46
48
  // Pre-import Node.js stream modules at module load time — not per-request.
@@ -194,11 +196,20 @@ export async function handleSsr(
194
196
  // promise resolves, so the flag is settled once the render is awaited.
195
197
  const renderTimeoutMs = _runtimeConfig.renderTimeoutMs ?? undefined;
196
198
  const requestInfo = { method: 'GET', path: navContext.pathname };
199
+ // The no-JS action's form state, for Fizz and, only once it decoded,
200
+ // embedded for the browser to hydrate with (design/08 §"No-JS Result
201
+ // Round-Trip"). A page whose payload is inlined hydrates.
202
+ const { formState, script: formStateHead } = await formStateForSsr(navContext.formState, {
203
+ hydrates: navContext.rscStream !== undefined,
204
+ nonce,
205
+ decode: (stream) => createFromReadableStream(stream, { nonce }),
206
+ timeoutMs: resolveRenderTimeoutMs(renderTimeoutMs),
207
+ });
197
208
  let fizzAllReadyResolved = false;
198
209
  const renderOptions: SsrStreamOptions = {
199
210
  bootstrapScriptContent: navContext.bootstrapScriptContent || undefined,
200
211
  nonce,
201
- formState: navContext.formState,
212
+ formState,
202
213
  deferSuspenseFor: navContext.deferSuspenseFor,
203
214
  signal: navContext.signal,
204
215
  renderTimeoutMs,
@@ -251,7 +262,8 @@ export async function handleSsr(
251
262
  // and close cleanly (TIM-1019).
252
263
  // Resolve the lazy segment script now that the shell is ready
253
264
  // (AccessGates have settled, ALS deny index is authoritative).
254
- const resolvedHeadHtml = navContext.headHtml + (navContext.getHeadSegmentScript?.() ?? '');
265
+ const resolvedHeadHtml =
266
+ navContext.headHtml + formStateHead + (navContext.getHeadSegmentScript?.() ?? '');
255
267
  const output = runNodePipelineWithErrorHandling(
256
268
  nodeHtmlStream,
257
269
  [
@@ -281,7 +293,8 @@ export async function handleSsr(
281
293
  // structure (TIM-527).
282
294
  // Resolve the lazy segment script now that the shell is ready
283
295
  // (AccessGates have settled, ALS deny index is authoritative).
284
- const resolvedHeadHtml = navContext.headHtml + (navContext.getHeadSegmentScript?.() ?? '');
296
+ const resolvedHeadHtml =
297
+ navContext.headHtml + formStateHead + (navContext.getHeadSegmentScript?.() ?? '');
285
298
  outputStream = htmlStream.pipeThrough(createBufferedTransformStream());
286
299
  outputStream = injectHead(outputStream, resolvedHeadHtml);
287
300
  outputStream = injectRscPayload(outputStream, navContext.rscStream, {
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The no-JS action form state on the SSR side (TIM-1572, design/08
3
+ * §"No-JS Result Round-Trip").
4
+ *
5
+ * SSR is the one owner of both consumers of the Flight bytes the RSC
6
+ * environment encoded (`NavContext.formState`): it decodes them for Fizz,
7
+ * and emits the script that embeds the same bytes for `hydrateRoot` only
8
+ * when that decode succeeded. A browser must never hydrate with a state Fizz
9
+ * did not render, or the hook that submitted changes state during
10
+ * hydration. If SSR cannot decode them, neither side gets them.
11
+ */
12
+
13
+ import type { ReactFormState } from 'react-dom/client';
14
+ import { decodeFormState, type FormStateFlightDecoder } from '../shared/form-state-flight.ts';
15
+ import { formStateScript } from './flight-scripts.ts';
16
+ import { swallow } from './logger.ts';
17
+
18
+ export interface SsrFormState {
19
+ /** For Fizz's `formState` option. */
20
+ formState: ReactFormState | undefined;
21
+ /** The `<script>` that embeds the same bytes for hydration, or `''`. */
22
+ script: string;
23
+ }
24
+
25
+ const NONE: SsrFormState = { formState: undefined, script: '' };
26
+
27
+ export async function formStateForSsr(
28
+ bytes: Uint8Array | undefined,
29
+ options: {
30
+ /**
31
+ * Whether the page hydrates: its RSC payload is inlined. Without it the
32
+ * browser never calls `hydrateRoot`, so nothing is embedded.
33
+ */
34
+ hydrates: boolean;
35
+ nonce: string | undefined;
36
+ decode: FormStateFlightDecoder;
37
+ timeoutMs: number;
38
+ }
39
+ ): Promise<SsrFormState> {
40
+ if (!bytes) return NONE;
41
+ let formState: ReactFormState;
42
+ try {
43
+ formState = await decodeFormState(bytes, options.decode, options.timeoutMs);
44
+ } catch (error) {
45
+ swallow(
46
+ error,
47
+ "a no-JS action's form state did not decode for SSR, so the page renders without it",
48
+ {
49
+ level: 'warn',
50
+ }
51
+ );
52
+ return NONE;
53
+ }
54
+ return {
55
+ formState,
56
+ script: options.hydrates ? formStateScript(bytes, options.nonce) : '',
57
+ };
58
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The no-JS action form state on its way to the two renders that need it
3
+ * (TIM-1572, design/08-forms-and-actions.md §"No-JS Result Round-Trip").
4
+ *
5
+ * The RSC environment serializes the form state with Flight
6
+ * (server/form-state-flight.ts). SSR decodes those bytes for Fizz, and the
7
+ * browser decodes the same bytes, embedded in the page, for `hydrateRoot`.
8
+ * Both sides call `decodeFormState` below with their own environment's
9
+ * Flight client, so the two renders get the same value, and the value a
10
+ * `useActionState` result has with JS (Flight, through the action response):
11
+ * a `Date` stays a `Date`, a `Map` a `Map`, and an `undefined` field is
12
+ * absent on both, because the Flight client drops it.
13
+ *
14
+ * The page carries the bytes as base64: Flight may write binary rows (a typed
15
+ * array in a result), which a text chunk would corrupt, and base64 has no
16
+ * character that can end a `<script>` element.
17
+ */
18
+
19
+ import type { ReactFormState } from 'react-dom/client';
20
+
21
+ /**
22
+ * A Flight client's `createFromReadableStream`, SSR's or the browser's, typed
23
+ * to the form state tuple it decodes. React builds `ReactFormState`
24
+ * (`decodeFormState` on the server) and only React reads it; the Flight
25
+ * client's type parameter is the only place that type is asserted.
26
+ */
27
+ export type FormStateFlightDecoder = (
28
+ stream: ReadableStream<Uint8Array>
29
+ ) => PromiseLike<ReactFormState>;
30
+
31
+ /**
32
+ * Decode the form state's Flight bytes. Rejects if they do not decode within
33
+ * `timeoutMs`. Every byte is in hand, so only a Flight client bug could
34
+ * stall it, and the caller falls back to rendering without form state.
35
+ */
36
+ export async function decodeFormState(
37
+ bytes: Uint8Array,
38
+ decode: FormStateFlightDecoder,
39
+ timeoutMs: number
40
+ ): Promise<ReactFormState> {
41
+ const stream = new ReadableStream<Uint8Array>({
42
+ start(controller) {
43
+ controller.enqueue(bytes);
44
+ controller.close();
45
+ },
46
+ });
47
+ let timer: ReturnType<typeof setTimeout> | undefined;
48
+ const timeout = new Promise<never>((_, reject) => {
49
+ timer = setTimeout(
50
+ () => reject(new Error(`form state did not decode within ${timeoutMs}ms`)),
51
+ timeoutMs
52
+ );
53
+ });
54
+ try {
55
+ return await Promise.race([decode(stream), timeout]);
56
+ } finally {
57
+ clearTimeout(timer);
58
+ }
59
+ }
60
+
61
+ /** The form state's Flight bytes as the base64 the page embeds. */
62
+ export function formStateToBase64(bytes: Uint8Array): string {
63
+ let binary = '';
64
+ for (const byte of bytes) binary += String.fromCharCode(byte);
65
+ return btoa(binary);
66
+ }
67
+
68
+ /** The embedded base64 back to the Flight bytes. */
69
+ export function formStateFromBase64(text: string): Uint8Array {
70
+ const binary = atob(text);
71
+ const bytes = new Uint8Array(binary.length);
72
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
73
+ return bytes;
74
+ }
@@ -1,32 +0,0 @@
1
- /**
2
- * The no-JS action form state a page render hands React (TIM-1570).
3
- *
4
- * Fizz renders the `useActionState` hook that submitted with the action's
5
- * result, and the browser must hydrate with the same value, or the hook
6
- * resets. The browser's copy is embedded as JSON, as Next.js embeds it
7
- * (`self.__timber_form_state`, read by client/browser-entry/hydrate.ts).
8
- *
9
- * Both renders must get the same value, or the hook's state changes during
10
- * hydration. JSON is lossy (a `Date` becomes a string, a `Map` `{}`, an
11
- * `undefined` field — `{ data: undefined }` from every void action — is
12
- * dropped), so Fizz gets the JSON round-trip of the state, not the original:
13
- * exactly what the browser decodes. On the no-JS path a result is what JSON
14
- * carries. A state JSON cannot encode at all (a `BigInt`, a cycle) must not
15
- * turn the page into a 500 after the action's mutation committed, so it is
16
- * dropped from both renders: the page renders with no result, as for a form
17
- * without `useActionState`.
18
- *
19
- * With client JS disabled nothing is embedded and nothing hydrates, so Fizz
20
- * gets the original.
21
- *
22
- * See design/08-forms-and-actions.md §"No-JS Result Round-Trip".
23
- */
24
- import type { ReactFormState } from 'react-dom/client';
25
- export interface FormStateForRender {
26
- /** For Fizz — the same value the embedded script hands `hydrateRoot`. */
27
- formState: ReactFormState | undefined;
28
- /** The `<script>` that embeds it for hydration, or `''`. */
29
- script: string;
30
- }
31
- export declare function formStateForRender(formState: ReactFormState | null, clientJsDisabled: boolean, nonce: string | undefined): FormStateForRender;
32
- //# sourceMappingURL=form-state-embed.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"form-state-embed.d.ts","sourceRoot":"","sources":["../../src/server/form-state-embed.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAKvD,MAAM,WAAW,kBAAkB;IACjC,yEAAyE;IACzE,SAAS,EAAE,cAAc,GAAG,SAAS,CAAC;IACtC,4DAA4D;IAC5D,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,cAAc,GAAG,IAAI,EAChC,gBAAgB,EAAE,OAAO,EACzB,KAAK,EAAE,MAAM,GAAG,SAAS,GACxB,kBAAkB,CAsBpB"}
@@ -1,63 +0,0 @@
1
- /**
2
- * The no-JS action form state a page render hands React (TIM-1570).
3
- *
4
- * Fizz renders the `useActionState` hook that submitted with the action's
5
- * result, and the browser must hydrate with the same value, or the hook
6
- * resets. The browser's copy is embedded as JSON, as Next.js embeds it
7
- * (`self.__timber_form_state`, read by client/browser-entry/hydrate.ts).
8
- *
9
- * Both renders must get the same value, or the hook's state changes during
10
- * hydration. JSON is lossy (a `Date` becomes a string, a `Map` `{}`, an
11
- * `undefined` field — `{ data: undefined }` from every void action — is
12
- * dropped), so Fizz gets the JSON round-trip of the state, not the original:
13
- * exactly what the browser decodes. On the no-JS path a result is what JSON
14
- * carries. A state JSON cannot encode at all (a `BigInt`, a cycle) must not
15
- * turn the page into a 500 after the action's mutation committed, so it is
16
- * dropped from both renders: the page renders with no result, as for a form
17
- * without `useActionState`.
18
- *
19
- * With client JS disabled nothing is embedded and nothing hydrates, so Fizz
20
- * gets the original.
21
- *
22
- * See design/08-forms-and-actions.md §"No-JS Result Round-Trip".
23
- */
24
-
25
- import type { ReactFormState } from 'react-dom/client';
26
- import { htmlEscapeJsonString } from './flight-scripts.ts';
27
- import { swallow } from './logger.ts';
28
- import { nonceAttr } from './render-utils.ts';
29
-
30
- export interface FormStateForRender {
31
- /** For Fizz — the same value the embedded script hands `hydrateRoot`. */
32
- formState: ReactFormState | undefined;
33
- /** The `<script>` that embeds it for hydration, or `''`. */
34
- script: string;
35
- }
36
-
37
- export function formStateForRender(
38
- formState: ReactFormState | null,
39
- clientJsDisabled: boolean,
40
- nonce: string | undefined
41
- ): FormStateForRender {
42
- if (!formState) return { formState: undefined, script: '' };
43
- // No client: nothing hydrates, so nothing is embedded.
44
- if (clientJsDisabled) return { formState, script: '' };
45
- let json: string;
46
- try {
47
- json = JSON.stringify(formState);
48
- } catch (error) {
49
- swallow(
50
- error,
51
- "a no-JS action's result could not be encoded as JSON for hydration, so the page " +
52
- 'renders without it; return plain data (no BigInt or cycles) from actions',
53
- { level: 'warn' }
54
- );
55
- return { formState: undefined, script: '' };
56
- }
57
- return {
58
- // `JSON.parse` returns the decoded tuple untyped; it is the same
59
- // opaque `ReactFormState` React built, as the browser will decode it.
60
- formState: JSON.parse(json),
61
- script: `<script${nonceAttr(nonce)}>self.__timber_form_state=${htmlEscapeJsonString(json)}</script>`,
62
- };
63
- }