@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.
- package/dist/_chunks/{actions-CCdnVtWm.js → actions-CEootpB1.js} +42 -7
- package/dist/_chunks/{actions-CCdnVtWm.js.map → actions-CEootpB1.js.map} +1 -1
- package/dist/_chunks/{error-boundary-DsNScGRM.js → error-boundary-9g_Lb2na.js} +3 -3
- package/dist/_chunks/{error-boundary-DsNScGRM.js.map → error-boundary-9g_Lb2na.js.map} +1 -1
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/action-queue.d.ts +1 -0
- package/dist/client/browser-entry/action-queue.d.ts.map +1 -1
- package/dist/client/browser-entry/form-state.d.ts +22 -0
- package/dist/client/browser-entry/form-state.d.ts.map +1 -0
- package/dist/client/browser-entry/hydrate.d.ts +9 -1
- package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
- package/dist/client/browser-entry/index.d.ts +2 -0
- package/dist/client/browser-entry/index.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +90 -2
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +8 -7
- package/dist/client/internal.js.map +1 -1
- package/dist/client/navigation-transition.d.ts +11 -2
- package/dist/client/navigation-transition.d.ts.map +1 -1
- package/dist/client/router-effects.d.ts +7 -3
- package/dist/client/router-effects.d.ts.map +1 -1
- package/dist/client/router-pipeline.d.ts +3 -1
- package/dist/client/router-pipeline.d.ts.map +1 -1
- package/dist/client/router-types.d.ts +12 -1
- package/dist/client/router-types.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/use-form-field.d.ts +39 -0
- package/dist/client/use-form-field.d.ts.map +1 -0
- package/dist/config-types.d.ts +2 -1
- package/dist/config-types.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/server/action-client.d.ts +11 -2
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/flight-scripts.d.ts +9 -0
- package/dist/server/flight-scripts.d.ts.map +1 -1
- package/dist/server/form-data.d.ts +13 -4
- package/dist/server/form-data.d.ts.map +1 -1
- package/dist/server/form-state-flight.d.ts +32 -0
- package/dist/server/form-state-flight.d.ts.map +1 -0
- package/dist/server/index.js +37 -23
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +1 -1
- package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts +2 -0
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts +6 -0
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/ssr-bridge-types.d.ts +7 -5
- 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-form-state.d.ts +30 -0
- package/dist/server/ssr-form-state.d.ts.map +1 -0
- package/dist/shared/form-state-flight.d.ts +36 -0
- package/dist/shared/form-state-flight.d.ts.map +1 -0
- package/docs/api/31-api-client.mdx +22 -0
- package/docs/api/34-api-config.mdx +1 -1
- package/docs/learn/08-forms-and-actions.mdx +109 -22
- package/package.json +1 -1
- package/src/client/browser-entry/action-dispatch.ts +34 -10
- package/src/client/browser-entry/action-queue.ts +1 -1
- package/src/client/browser-entry/form-state.ts +48 -0
- package/src/client/browser-entry/hydrate.ts +9 -18
- package/src/client/browser-entry/index.ts +25 -7
- package/src/client/browser-entry/router-init.ts +3 -2
- package/src/client/index.ts +1 -0
- package/src/client/navigation-transition.ts +13 -2
- package/src/client/router-effects.ts +8 -4
- package/src/client/router-pipeline.ts +7 -3
- package/src/client/router-types.ts +12 -1
- package/src/client/router.ts +16 -3
- package/src/client/use-form-field.ts +132 -0
- package/src/config-types.ts +2 -1
- package/src/server/action-client.ts +68 -53
- package/src/server/flight-scripts.ts +13 -0
- package/src/server/form-data.ts +62 -10
- package/src/server/form-state-flight.ts +67 -0
- package/src/server/rsc-entry/action-dispatcher.ts +3 -0
- package/src/server/rsc-entry/index.ts +1 -0
- package/src/server/rsc-entry/render-route.ts +16 -0
- package/src/server/rsc-entry/ssr-renderer.ts +12 -14
- package/src/server/ssr-bridge-types.ts +7 -6
- package/src/server/ssr-entry.ts +16 -3
- package/src/server/ssr-form-state.ts +58 -0
- package/src/shared/form-state-flight.ts +74 -0
- package/dist/server/form-state-embed.d.ts +0 -32
- package/dist/server/form-state-embed.d.ts.map +0 -1
- 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
|
-
|
|
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`),
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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?:
|
|
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` /
|
package/src/server/ssr-entry.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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
|
-
}
|