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

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 (134) hide show
  1. package/dist/_chunks/{actions-CCdnVtWm.js → actions-CUh3cClk.js} +43 -8
  2. package/dist/_chunks/{actions-CCdnVtWm.js.map → actions-CUh3cClk.js.map} +1 -1
  3. package/dist/_chunks/{canonicalize-CgHoscYO.js → canonicalize-BAWkWiKK.js} +28 -2
  4. package/dist/_chunks/{canonicalize-CgHoscYO.js.map → canonicalize-BAWkWiKK.js.map} +1 -1
  5. package/dist/_chunks/{chains-BfoPFraI.js → chains-CjK1Eu6a.js} +2 -2
  6. package/dist/_chunks/{chains-BfoPFraI.js.map → chains-CjK1Eu6a.js.map} +1 -1
  7. package/dist/_chunks/{classify-BT66U83D.js → classify-GAdt6aiA.js} +2 -2
  8. package/dist/_chunks/{classify-BT66U83D.js.map → classify-GAdt6aiA.js.map} +1 -1
  9. package/dist/_chunks/{cli-check-BfQ54-UJ.js → cli-check-DOzjlycg.js} +3 -3
  10. package/dist/_chunks/{cli-check-BfQ54-UJ.js.map → cli-check-DOzjlycg.js.map} +1 -1
  11. package/dist/_chunks/{cli-schema-sync-czh2dsLs.js → cli-schema-sync-B9wDaQvW.js} +2 -2
  12. package/dist/_chunks/{cli-schema-sync-czh2dsLs.js.map → cli-schema-sync-B9wDaQvW.js.map} +1 -1
  13. package/dist/_chunks/{client-dep-entries-CQwpb8dI.js → client-dep-entries-DyDqXOF9.js} +2 -2
  14. package/dist/_chunks/{client-dep-entries-CQwpb8dI.js.map → client-dep-entries-DyDqXOF9.js.map} +1 -1
  15. package/dist/_chunks/{convention-lint-BEVW4EID.js → convention-lint-B3QEGJX7.js} +2 -2
  16. package/dist/_chunks/{convention-lint-BEVW4EID.js.map → convention-lint-B3QEGJX7.js.map} +1 -1
  17. package/dist/_chunks/{dev-server-FKxptbnI.js → dev-server-_9L4KWC-.js} +3 -3
  18. package/dist/_chunks/{dev-server-FKxptbnI.js.map → dev-server-_9L4KWC-.js.map} +1 -1
  19. package/dist/_chunks/{error-boundary-DsNScGRM.js → error-boundary-xxxLtXt6.js} +38 -5
  20. package/dist/_chunks/{error-boundary-DsNScGRM.js.map → error-boundary-xxxLtXt6.js.map} +1 -1
  21. package/dist/_chunks/{live-graph-C_4v-fHv.js → live-graph-Cd3YHvrH.js} +4 -4
  22. package/dist/_chunks/{live-graph-C_4v-fHv.js.map → live-graph-Cd3YHvrH.js.map} +1 -1
  23. package/dist/_chunks/{poison-scan-C92liMAr.js → poison-scan-BnJjBOkn.js} +2 -2
  24. package/dist/_chunks/{poison-scan-C92liMAr.js.map → poison-scan-BnJjBOkn.js.map} +1 -1
  25. package/dist/_chunks/{scanner-CQt12vE2.js → scanner-CKAT5gRx.js} +2 -2
  26. package/dist/_chunks/{scanner-CQt12vE2.js.map → scanner-CKAT5gRx.js.map} +1 -1
  27. package/dist/_chunks/{walkers-DAT4avhZ.js → walkers-DwCXEyRu.js} +3 -3
  28. package/dist/_chunks/{walkers-DAT4avhZ.js.map → walkers-DwCXEyRu.js.map} +1 -1
  29. package/dist/adapters/nitro-preview.d.ts.map +1 -1
  30. package/dist/adapters/nitro.js +11 -2
  31. package/dist/adapters/nitro.js.map +1 -1
  32. package/dist/analyze/crawl-entry.js +3 -3
  33. package/dist/analyze/graph-command.js +2 -2
  34. package/dist/cli.js +3 -3
  35. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  36. package/dist/client/browser-entry/action-queue.d.ts +1 -0
  37. package/dist/client/browser-entry/action-queue.d.ts.map +1 -1
  38. package/dist/client/browser-entry/form-state.d.ts +22 -0
  39. package/dist/client/browser-entry/form-state.d.ts.map +1 -0
  40. package/dist/client/browser-entry/hydrate.d.ts +9 -1
  41. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  42. package/dist/client/browser-entry/index.d.ts +2 -0
  43. package/dist/client/browser-entry/index.d.ts.map +1 -1
  44. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  45. package/dist/client/error-boundary.js +1 -1
  46. package/dist/client/index.d.ts +1 -0
  47. package/dist/client/index.d.ts.map +1 -1
  48. package/dist/client/index.js +90 -2
  49. package/dist/client/index.js.map +1 -1
  50. package/dist/client/internal.js +26 -10
  51. package/dist/client/internal.js.map +1 -1
  52. package/dist/client/navigation-transition.d.ts +11 -2
  53. package/dist/client/navigation-transition.d.ts.map +1 -1
  54. package/dist/client/router-effects.d.ts +70 -8
  55. package/dist/client/router-effects.d.ts.map +1 -1
  56. package/dist/client/router-pipeline.d.ts +3 -1
  57. package/dist/client/router-pipeline.d.ts.map +1 -1
  58. package/dist/client/router-types.d.ts +12 -1
  59. package/dist/client/router-types.d.ts.map +1 -1
  60. package/dist/client/router.d.ts.map +1 -1
  61. package/dist/client/use-form-field.d.ts +39 -0
  62. package/dist/client/use-form-field.d.ts.map +1 -0
  63. package/dist/config-types.d.ts +2 -1
  64. package/dist/config-types.d.ts.map +1 -1
  65. package/dist/index.js +5 -5
  66. package/dist/index.js.map +1 -1
  67. package/dist/routing/index.js +2 -2
  68. package/dist/server/action-client.d.ts +11 -2
  69. package/dist/server/action-client.d.ts.map +1 -1
  70. package/dist/server/canonicalize.d.ts +22 -0
  71. package/dist/server/canonicalize.d.ts.map +1 -1
  72. package/dist/server/flight-scripts.d.ts +9 -0
  73. package/dist/server/flight-scripts.d.ts.map +1 -1
  74. package/dist/server/form-data.d.ts +13 -4
  75. package/dist/server/form-data.d.ts.map +1 -1
  76. package/dist/server/form-state-flight.d.ts +32 -0
  77. package/dist/server/form-state-flight.d.ts.map +1 -0
  78. package/dist/server/index.js +37 -23
  79. package/dist/server/index.js.map +1 -1
  80. package/dist/server/internal.js +54 -3
  81. package/dist/server/internal.js.map +1 -1
  82. package/dist/server/pipeline-helpers.d.ts +31 -0
  83. package/dist/server/pipeline-helpers.d.ts.map +1 -1
  84. package/dist/server/pipeline.d.ts.map +1 -1
  85. package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -1
  86. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  87. package/dist/server/rsc-entry/render-route.d.ts +2 -0
  88. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  89. package/dist/server/rsc-entry/ssr-renderer.d.ts +6 -0
  90. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  91. package/dist/server/ssr-bridge-types.d.ts +7 -5
  92. package/dist/server/ssr-bridge-types.d.ts.map +1 -1
  93. package/dist/server/ssr-entry.d.ts.map +1 -1
  94. package/dist/server/ssr-form-state.d.ts +30 -0
  95. package/dist/server/ssr-form-state.d.ts.map +1 -0
  96. package/dist/shared/form-state-flight.d.ts +36 -0
  97. package/dist/shared/form-state-flight.d.ts.map +1 -0
  98. package/docs/api/31-api-client.mdx +22 -0
  99. package/docs/api/34-api-config.mdx +1 -1
  100. package/docs/learn/08-forms-and-actions.mdx +109 -22
  101. package/package.json +1 -1
  102. package/src/adapters/nitro-preview.ts +10 -1
  103. package/src/client/browser-entry/action-dispatch.ts +34 -10
  104. package/src/client/browser-entry/action-queue.ts +1 -1
  105. package/src/client/browser-entry/form-state.ts +48 -0
  106. package/src/client/browser-entry/hydrate.ts +9 -18
  107. package/src/client/browser-entry/index.ts +25 -7
  108. package/src/client/browser-entry/router-init.ts +3 -2
  109. package/src/client/index.ts +1 -0
  110. package/src/client/navigation-transition.ts +13 -2
  111. package/src/client/router-effects.ts +99 -10
  112. package/src/client/router-pipeline.ts +7 -3
  113. package/src/client/router-types.ts +12 -1
  114. package/src/client/router.ts +35 -6
  115. package/src/client/use-form-field.ts +132 -0
  116. package/src/config-types.ts +2 -1
  117. package/src/server/action-client.ts +68 -53
  118. package/src/server/canonicalize.ts +29 -0
  119. package/src/server/flight-scripts.ts +13 -0
  120. package/src/server/form-data.ts +62 -10
  121. package/src/server/form-state-flight.ts +67 -0
  122. package/src/server/pipeline-helpers.ts +61 -0
  123. package/src/server/pipeline.ts +13 -1
  124. package/src/server/rsc-entry/action-dispatcher.ts +3 -0
  125. package/src/server/rsc-entry/index.ts +1 -0
  126. package/src/server/rsc-entry/render-route.ts +16 -0
  127. package/src/server/rsc-entry/ssr-renderer.ts +12 -14
  128. package/src/server/ssr-bridge-types.ts +7 -6
  129. package/src/server/ssr-entry.ts +16 -3
  130. package/src/server/ssr-form-state.ts +58 -0
  131. package/src/shared/form-state-flight.ts +74 -0
  132. package/dist/server/form-state-embed.d.ts +0 -32
  133. package/dist/server/form-state-embed.d.ts.map +0 -1
  134. package/src/server/form-state-embed.ts +0 -63
@@ -118,7 +118,11 @@ export type ActionResult<TData = unknown> =
118
118
  data?: never;
119
119
  validationErrors?: never;
120
120
  serverError: { code: string; data?: Record<string, unknown> };
121
- submittedValues?: never;
121
+ /**
122
+ * The submitted form, when the input was FormData — for repopulating
123
+ * form fields, which React resets after the action (TIM-1573).
124
+ */
125
+ submittedValues?: Record<string, unknown>;
122
126
  };
123
127
 
124
128
  /** Context passed to the action body. */
@@ -288,7 +292,9 @@ function extractStandardSchemaErrors(issues: ReadonlyArray<StandardSchemaIssue>)
288
292
  * `createActionClient` actions only. A raw `'use server'` function that
289
293
  * throws is rejected on the client instead (action-handler.ts).
290
294
  */
291
- export function handleActionError(error: unknown): ActionResult<never> {
295
+ export function handleActionError(error: unknown): {
296
+ serverError: { code: string; data?: Record<string, unknown> };
297
+ } {
292
298
  if (error instanceof ActionError) {
293
299
  return {
294
300
  serverError: {
@@ -339,43 +345,49 @@ export function createActionClient<TCtx = Record<string, never>>(
339
345
  fn: (ctx: ActionContext<TCtx, TInput>) => Promise<TData>
340
346
  ): ActionFn<TData, TInput> {
341
347
  async function actionHandler(...args: unknown[]): Promise<ActionResult<TData>> {
348
+ // The input is the last argument in every call shape: `(input)`,
349
+ // `(formData)`, `(prevState, payload)` from useActionState's
350
+ // dispatch, `(initialState, formData)` on the no-JS path (Fizz binds
351
+ // the initial state into the form, and decodeAction binds the
352
+ // FormData after it), and `(...bound, prevState, payload)` for an
353
+ // action with bound arguments. Nothing before it is input:
354
+ // `prevState` comes from the client, so it is never read.
355
+ const last = args.at(-1);
356
+ const form = last instanceof FormData ? parseFormData(last) : undefined;
357
+
358
+ // Resolve the sensitive-field stripping predicate once per invocation.
359
+ // Precedence: per-action (config.stripSensitiveFields) > global
360
+ // (forms.stripSensitiveFields from timber.config.ts) > built-in deny-list.
361
+ // See TIM-816.
362
+ const sensitivePredicate = resolveSensitivePredicate(
363
+ config.stripSensitiveFields,
364
+ getGlobalSensitiveFieldsConfig()
365
+ );
366
+
367
+ // A "safe-to-echo" copy of the input. Files are stripped (can't
368
+ // serialize, shouldn't echo back) and sensitive fields (passwords,
369
+ // tokens, CVV, etc.) are removed before they would land in the RSC
370
+ // payload → client form `defaultValue` → DOM.
371
+ const echo = (value: unknown): Record<string, unknown> | undefined => {
372
+ const withoutFiles = stripFiles(value);
373
+ if (withoutFiles === undefined) return undefined;
374
+ return stripSensitiveFields(withoutFiles, sensitivePredicate);
375
+ };
376
+
342
377
  try {
343
378
  // Run middleware
344
379
  const ctx = await runActionMiddleware(config.middleware);
345
380
 
346
- // The input is the last argument in every call shape: `(input)`,
347
- // `(formData)`, `(prevState, payload)` from useActionState's
348
- // dispatch, `(initialState, formData)` on the no-JS path (Fizz binds
349
- // the initial state into the form, and decodeAction binds the
350
- // FormData after it), and `(...bound, prevState, payload)` for an
351
- // action with bound arguments. Nothing before it is input:
352
- // `prevState` comes from the client, so it is never read.
353
- const last = args.at(-1);
354
- const rawInput: unknown = schema && last instanceof FormData ? parseFormData(last) : last;
355
-
356
- // Resolve the sensitive-field stripping predicate once per invocation.
357
- // Precedence: per-action (config.stripSensitiveFields) > global
358
- // (forms.stripSensitiveFields from timber.config.ts) > built-in deny-list.
359
- // See TIM-816.
360
- const sensitivePredicate = resolveSensitivePredicate(
361
- config.stripSensitiveFields,
362
- getGlobalSensitiveFieldsConfig()
363
- );
364
-
365
- // Capture a "safe-to-echo" snapshot of the raw input once. Files are
366
- // stripped (can't serialize, shouldn't echo back) and sensitive fields
367
- // (passwords, tokens, CVV, etc.) are removed before they would land
368
- // in the RSC payload → client form `defaultValue` → DOM.
369
- const buildSubmittedValues = (): Record<string, unknown> | undefined => {
370
- const withoutFiles = stripFiles(rawInput);
371
- if (withoutFiles === undefined) return undefined;
372
- return stripSensitiveFields(withoutFiles, sensitivePredicate);
373
- };
381
+ // Without a schema the action receives the FormData itself. Checks
382
+ // and echoes read the parsed form: a FormData has no own entries.
383
+ const rawInput: unknown = schema && form ? form : last;
384
+ const submitted: unknown = form ?? rawInput;
385
+ const buildSubmittedValues = () => echo(submitted);
374
386
 
375
387
  // Validate file sizes before schema validation.
376
- if (config.fileSizeLimit !== undefined && rawInput && typeof rawInput === 'object') {
388
+ if (config.fileSizeLimit !== undefined && submitted && typeof submitted === 'object') {
377
389
  const fileSizeErrors = validateFileSizes(
378
- rawInput as Record<string, unknown>,
390
+ submitted as Record<string, unknown>,
379
391
  config.fileSizeLimit
380
392
  );
381
393
  if (fileSizeErrors) {
@@ -435,7 +447,12 @@ export function createActionClient<TCtx = Record<string, never>>(
435
447
  if (isRedirectSignal(error) || isDenySignal(error)) {
436
448
  throw error;
437
449
  }
438
- return handleActionError(error);
450
+ // A `serverError` echoes the form like a validation failure does.
451
+ // React resets the form after the action, and a form whose defaults
452
+ // fell back to page data would lose what the user typed (TIM-1573).
453
+ const result = handleActionError(error);
454
+ const submittedValues = form && echo(form);
455
+ return submittedValues ? { ...result, submittedValues } : result;
439
456
  }
440
457
  }
441
458
 
@@ -508,6 +525,8 @@ function logValidationFailure(errors: ValidationErrors): void {
508
525
  /**
509
526
  * Validate that all File objects in the input are within the size limit.
510
527
  * Returns validation errors keyed by field name, or null if all files are ok.
528
+ * Keys are dot paths (`rows.0.file`), the form a field name and a Standard
529
+ * Schema error use, so `getFieldError` finds a file in a list.
511
530
  */
512
531
  function validateFileSizes(input: Record<string, unknown>, limit: number): ValidationErrors | null {
513
532
  const limitKb = Math.round(limit / 1024);
@@ -526,7 +545,7 @@ function validateFileSizes(input: Record<string, unknown>, limit: number): Valid
526
545
  } else if (Array.isArray(value)) {
527
546
  for (let i = 0; i < value.length; i++) {
528
547
  const item = value[i];
529
- const itemPath = `${path}[${i}]`;
548
+ const itemPath = `${path}.${i}`;
530
549
  if (item instanceof File && item.size > limit) {
531
550
  (errors[itemPath] ??= []).push(
532
551
  `File "${item.name}" (${formatSize(item.size)}) exceeds the ${limitLabel} limit`
@@ -546,29 +565,25 @@ function validateFileSizes(input: Record<string, unknown>, limit: number): Valid
546
565
  }
547
566
 
548
567
  /**
549
- * Strip File objects from a value, returning a plain object safe for
550
- * serialization. File objects can't be serialized and shouldn't be echoed back.
568
+ * Strip File objects from a value, returning a copy safe for serialization.
569
+ * File objects can't be serialized and shouldn't be echoed back.
570
+ *
571
+ * Mirrors `parseFormData`'s output: objects and arrays at any depth, arrays
572
+ * of arrays included. A File in an array becomes `undefined` rather than
573
+ * being removed, so `rows.2` still names the third element (TIM-1573).
551
574
  */
552
575
  function stripFiles(value: unknown): Record<string, unknown> | undefined {
553
- if (value === null || value === undefined) return undefined;
554
- if (typeof value !== 'object') return undefined;
576
+ if (value === null || typeof value !== 'object' || value instanceof File) return undefined;
577
+ return stripFilesFrom(value) as Record<string, unknown>;
578
+ }
555
579
 
580
+ function stripFilesFrom(value: unknown): unknown {
581
+ if (value instanceof File) return undefined;
582
+ if (Array.isArray(value)) return value.map(stripFilesFrom);
583
+ if (typeof value !== 'object' || value === null) return value;
556
584
  const result: Record<string, unknown> = {};
557
- for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
558
- if (v instanceof File) continue;
559
- if (Array.isArray(v)) {
560
- result[k] = v
561
- .filter((item) => !(item instanceof File))
562
- .map((item) =>
563
- typeof item === 'object' && item !== null && !(item instanceof File)
564
- ? (stripFiles(item) ?? {})
565
- : item
566
- );
567
- } else if (typeof v === 'object' && v !== null && !(v instanceof File)) {
568
- result[k] = stripFiles(v) ?? {};
569
- } else {
570
- result[k] = v;
571
- }
585
+ for (const [k, v] of Object.entries(value)) {
586
+ if (!(v instanceof File)) result[k] = stripFilesFrom(v);
572
587
  }
573
588
  return result;
574
589
  }
@@ -91,3 +91,32 @@ export function canonicalize(rawPathname: string, stripTrailingSlash = true): Ca
91
91
 
92
92
  return { ok: true, pathname };
93
93
  }
94
+
95
+ /**
96
+ * The path a safe request for `rawPathname` should be redirected to — or
97
+ * `rawPathname` itself when it is already canonical.
98
+ *
99
+ * Applies the structural steps of `canonicalize()` — collapse `//` (step 3)
100
+ * and strip the trailing slash (step 5) — to the **encoded** path, without
101
+ * decoding. That is what the browser will send back, so the redirect is
102
+ * idempotent: `/%61dmin` differs from its canonical form `/admin` only by
103
+ * percent-encoding, and redirecting it to `/admin` would compare against the
104
+ * decoded form forever. Decoding cannot introduce a `/` (`%2f` is rejected),
105
+ * so the structure is the same before and after decoding.
106
+ *
107
+ * Dot segments are not handled here: the request URL is parsed by the WHATWG
108
+ * URL parser, which has already resolved `.`, `..` and their encoded forms.
109
+ *
110
+ * Only meaningful for a path `canonicalize()` accepted. The collapse also
111
+ * means the result can never start with `//`, so it is never a
112
+ * protocol-relative redirect target.
113
+ *
114
+ * See design/07-routing.md §"Non-Canonical URLs Redirect"
115
+ */
116
+ export function canonicalRequestPath(rawPathname: string, stripTrailingSlash = true): string {
117
+ let pathname = rawPathname.replace(/\/\/+/g, '/');
118
+ if (stripTrailingSlash && pathname.length > 1 && pathname.endsWith('/')) {
119
+ pathname = pathname.slice(0, -1);
120
+ }
121
+ return pathname;
122
+ }
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { nonceAttr } from './render-utils.ts';
19
+ import { formStateToBase64 } from '../shared/form-state-flight.ts';
19
20
 
20
21
  // ─── JSON Escaping ────────────────────────────────────────────────────────
21
22
 
@@ -65,3 +66,15 @@ export function flightChunkScript(data: string, nonce?: string): string {
65
66
  const escaped = htmlEscapeJsonString(JSON.stringify([1, data]));
66
67
  return `<script${nonceAttr(nonce)}>(${FLIGHT_VAR}=${FLIGHT_VAR}||[]).push(${escaped})</script>`;
67
68
  }
69
+
70
+ /**
71
+ * Generate the script that embeds a no-JS action's form state for
72
+ * hydration: its Flight bytes (server/form-state-flight.ts), as base64. Only
73
+ * a page answering a no-JS action has one. Base64 carries Flight's binary
74
+ * rows, which a text chunk would corrupt, and has no character that can end
75
+ * the script element or the string. See design/08-forms-and-actions.md
76
+ * §"No-JS Result Round-Trip".
77
+ */
78
+ export function formStateScript(bytes: Uint8Array, nonce?: string): string {
79
+ return `<script${nonceAttr(nonce)}>self.__timber_form_state="${formStateToBase64(bytes)}"</script>`;
80
+ }
@@ -18,9 +18,15 @@
18
18
  * Handles:
19
19
  * - **Duplicate keys → arrays**: `tags=js&tags=ts` → `{ tags: ["js", "ts"] }`
20
20
  * - **Nested dot-paths**: `user.name=Alice` → `{ user: { name: "Alice" } }`
21
- * - **Empty strings → undefined**: Enables `.optional()` semantics in schemas
21
+ * - **Indexed lists → arrays**: `rows.0.name=A&rows.1.name=B` →
22
+ * `{ rows: [{ name: "A" }, { name: "B" }] }`, when the indexes are exactly
23
+ * `0..n-1`. A list with a gap stays an object keyed by index.
24
+ * - **Empty strings stay `""`**: a blank text input is `""`, so
25
+ * `z.string().min(1, 'Required')` reports its own message. Use
26
+ * `coerce.text` to make a blank optional field `undefined`
22
27
  * - **Empty Files → undefined**: File inputs with no selection become `undefined`
23
28
  * - **Strips `$ACTION_*` fields**: React's internal hidden fields are excluded
29
+ * - **Drops `__proto__` paths and names deeper than 32 segments**
24
30
  */
25
31
  export function parseFormData(formData: FormData): Record<string, unknown> {
26
32
  const flat: Record<string, unknown> = {};
@@ -97,6 +103,11 @@ function expandDotPaths(flat: Record<string, unknown>): Record<string, unknown>
97
103
  // `result['__proto__']` resolves to the prototype object itself.
98
104
  const parts = key.split('.');
99
105
  if (parts.some((p) => DANGEROUS_KEYS.has(p))) continue;
106
+ // Bound the nesting depth. Every walk over the parsed value recurses
107
+ // once per level (list conversion here, then file and sensitive-field
108
+ // stripping and schema validation), and a 10 KB key of 5,000 segments
109
+ // fits well inside the body limits. No form nests this deep.
110
+ if (parts.length > MAX_PATH_DEPTH) continue;
100
111
 
101
112
  if (parts.length === 1) {
102
113
  result[parts[0]] = value;
@@ -106,12 +117,13 @@ function expandDotPaths(flat: Record<string, unknown>): Record<string, unknown>
106
117
  let current: Record<string, unknown> = result;
107
118
  for (let i = 0; i < parts.length - 1; i++) {
108
119
  const part = parts[i];
109
- if (current[part] === undefined || current[part] === null) {
110
- current[part] = {};
111
- }
112
- // If current[part] is not an object (e.g., a string from a non-dotted key),
113
- // the dot-path takes precedence
114
- if (typeof current[part] !== 'object' || current[part] instanceof File) {
120
+ // Step only into an object this walk built. Anything else under the
121
+ // name — a string, a File, or an array from a duplicate key
122
+ // (`rows=x&rows=y`) — is replaced: the dot-path takes precedence.
123
+ // Stepping into an array would let `rows.4294967294` set its length
124
+ // to 2^32 - 1, and every later walk (file and sensitive-field
125
+ // stripping) maps over that length (TIM-1573).
126
+ if (!isPlainObject(current[part])) {
115
127
  current[part] = {};
116
128
  }
117
129
  current = current[part] as Record<string, unknown>;
@@ -120,9 +132,39 @@ function expandDotPaths(flat: Record<string, unknown>): Record<string, unknown>
120
132
  current[parts[parts.length - 1]] = value;
121
133
  }
122
134
 
135
+ // The top level is always an object: it is the form, not a list.
136
+ for (const [key, value] of Object.entries(result)) {
137
+ if (isPlainObject(value)) result[key] = indexedListsToArrays(value);
138
+ }
123
139
  return result;
124
140
  }
125
141
 
142
+ /**
143
+ * `node`, with every nested object whose keys are exactly `"0".."n-1"`
144
+ * turned into an array, depth first. Only a dense, canonical index set
145
+ * converts: `{ "0", "2" }` (a gap), `{ "01" }` and `{ "0", "name" }` stay
146
+ * objects. So a forged `rows.99999` stays one key instead of allocating a
147
+ * sparse array, and the array's length is bounded by the field count limit.
148
+ *
149
+ * Object.keys lists integer-like keys first, in ascending order, so a dense
150
+ * set reads as `0, 1, … n-1` and `Object.values` is already in index order.
151
+ */
152
+ function indexedListsToArrays(node: Record<string, unknown>): Record<string, unknown> | unknown[] {
153
+ for (const [key, value] of Object.entries(node)) {
154
+ if (isPlainObject(value)) node[key] = indexedListsToArrays(value);
155
+ }
156
+ const keys = Object.keys(node);
157
+ const dense = keys.length > 0 && keys.every((key, i) => key === String(i));
158
+ return dense ? Object.values(node) : node;
159
+ }
160
+
161
+ /** An object the dot-path walk built — not an array, File or other instance. */
162
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
163
+ return (
164
+ typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype
165
+ );
166
+ }
167
+
126
168
  // `__proto__` is the only key in this parser that escapes the dot-path
127
169
  // walk's `typeof !== 'object'` reset: accessing `obj['__proto__']` returns
128
170
  // `Object.prototype` (itself an object), so the walk steps into the global
@@ -132,13 +174,22 @@ function expandDotPaths(flat: Record<string, unknown>): Record<string, unknown>
132
174
  // keys and do not mutate anything global.
133
175
  const DANGEROUS_KEYS = new Set(['__proto__']);
134
176
 
177
+ /**
178
+ * The most segments a dot-path field name may have. A deeper name is dropped,
179
+ * like a `__proto__` path, so no walk over the parsed value can exhaust the
180
+ * stack (TIM-1573).
181
+ */
182
+ const MAX_PATH_DEPTH = 32;
183
+
135
184
  // ─── Coercion Helpers ────────────────────────────────────────────────────
136
185
 
137
186
  /**
138
187
  * Schema-agnostic coercion primitives for common FormData patterns.
139
188
  *
140
189
  * These are plain transform functions — they compose with any schema library's
141
- * `transform`/`preprocess` pipeline:
190
+ * `transform`/`preprocess` pipeline. In Zod, use `z.preprocess`: it runs on an
191
+ * absent key (an unchecked checkbox), where `z.unknown().transform()` fails
192
+ * with "expected nonoptional" before the transform runs.
142
193
  *
143
194
  * ```ts
144
195
  * // Zod
@@ -153,8 +204,9 @@ export const coerce = {
153
204
  * Use with `.optional()` schemas where an empty input means "not provided".
154
205
  *
155
206
  * ```ts
156
- * // Zod
157
- * z.unknown().transform(coerce.text).pipe(z.string().optional())
207
+ * // Zod — preprocess, not `z.unknown().transform(…)`: Zod 4 rejects an
208
+ * // absent key before a transform runs ("expected nonoptional")
209
+ * z.preprocess(coerce.text, z.string().optional())
158
210
  * // Valibot
159
211
  * v.pipe(v.unknown(), v.transform(coerce.text), v.optional(v.string()))
160
212
  * ```
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The no-JS action form state a page render hands React (TIM-1570), encoded
3
+ * with Flight (TIM-1572).
4
+ *
5
+ * Fizz renders the `useActionState` hook that submitted with the action's
6
+ * result, and the browser must hydrate with the same value, or the hook
7
+ * resets. Both get it from the Flight bytes encoded here: SSR decodes them
8
+ * for Fizz (`NavContext.formState`) and, once they decode, embeds them for
9
+ * `hydrateRoot` (server/ssr-form-state.ts; decoded by
10
+ * client/browser-entry/form-state.ts). Flight is what carries a
11
+ * `useActionState` result with JS, so a result has the same types with and
12
+ * without JS. See shared/form-state-flight.ts for the decode.
13
+ *
14
+ * A state Flight cannot encode must not turn the page into a 500 after the
15
+ * action's mutation committed, so it is dropped from both renders: the page
16
+ * renders with no result, as for a form without `useActionState`. That is a
17
+ * value Flight cannot serialize (a plain function, a class instance), which
18
+ * fails the action with JS too, or a nested promise that rejects or does not
19
+ * settle in time, which with JS reaches the hook as that rejected or pending
20
+ * promise. The no-JS page cannot carry a pending promise, and does not embed
21
+ * an error row.
22
+ *
23
+ * See design/08-forms-and-actions.md §"No-JS Result Round-Trip".
24
+ */
25
+
26
+ import type { ReactFormState } from 'react-dom/client';
27
+ import { renderToReadableStream } from '../rsc-runtime/rsc.ts';
28
+ import { swallow } from './logger.ts';
29
+ import { readAllBytesWithDeadline } from './stream-utils.ts';
30
+
31
+ /**
32
+ * Serialize the form state with Flight, or return `null` when it cannot be,
33
+ * with a warning. The whole payload is read under one `timeoutMs` deadline,
34
+ * and the render stops when `signal` (the request's) aborts.
35
+ */
36
+ export async function encodeFormState(
37
+ formState: ReactFormState,
38
+ timeoutMs: number,
39
+ signal?: AbortSignal
40
+ ): Promise<Uint8Array | null> {
41
+ let failure: unknown;
42
+ try {
43
+ // Flight reports a value it cannot serialize (anywhere in the tree,
44
+ // including a rejected nested promise) here, and writes an error row in
45
+ // its place; a result with an error row in it is not the action's result.
46
+ const flightErrors: unknown[] = [];
47
+ const stream = renderToReadableStream(formState, {
48
+ signal,
49
+ onError(error: unknown) {
50
+ flightErrors.push(error);
51
+ },
52
+ });
53
+ const bytes = await readAllBytesWithDeadline(stream, timeoutMs, 'no-JS form state encode');
54
+ if (flightErrors.length === 0) return bytes;
55
+ failure = flightErrors[0];
56
+ } catch (error) {
57
+ // The deadline, reported as itself: cancelling the read after it makes
58
+ // Flight report an abort to onError too, which says nothing of the cause.
59
+ failure = error;
60
+ }
61
+ swallow(
62
+ failure,
63
+ "a no-JS action's result could not be serialized with Flight, so the page renders without it",
64
+ { level: 'warn' }
65
+ );
66
+ return null;
67
+ }
@@ -28,6 +28,9 @@ import { isApiRouteChain, isCsrfExemptRoute, type ManifestSegmentNode } from './
28
28
  import type { ProxyConfig, RouteMatcher } from './pipeline.ts';
29
29
  import { csrfRejectionResponse, validateCsrf, type CsrfConfig } from './csrf.ts';
30
30
  import type { PhaseName } from './pipeline-outcome.ts';
31
+ import { canonicalRequestPath } from './canonicalize.ts';
32
+ import { isRscRequest } from '../shared/rsc-media-type.ts';
33
+ import { appVisibleSearch } from '../shared/rsc-cache-key.ts';
31
34
 
32
35
  // ─── Prototype-Pollution-Safe Sanitizer ────────────────────────────────────
33
36
 
@@ -66,6 +69,64 @@ export function csrfGate(
66
69
  return exempt ? null : csrfRejectionResponse(req, result);
67
70
  }
68
71
 
72
+ // ─── Canonical Redirect ────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * The redirect for a safe request whose URL is not canonical, or null to
76
+ * serve it.
77
+ *
78
+ * A GET for `/admin/` used to render with a 200 at that URL. The server
79
+ * rendered with the canonical `/admin` while the browser hydrated with the
80
+ * raw `/admin/`, so anything branching on `usePathname()` hydrated
81
+ * mismatched (TIM-1571). Redirecting makes the address bar — the client's
82
+ * copy of the URL — the canonical one, and leaves one URL per page for
83
+ * caches and crawlers.
84
+ *
85
+ * Only GET and HEAD are redirected. Every other method — an action's POST,
86
+ * a CORS preflight — is served at the canonical path, as before: a
87
+ * redirected POST is re-sent by the browser, which an action must not
88
+ * depend on.
89
+ *
90
+ * Runs inside proxy.ts's `next()`, not before it, so proxy.ts still runs
91
+ * on every request and its headers land on the redirect. A cross-origin
92
+ * `GET /api/items/` needs proxy.ts's CORS headers on the 308 itself:
93
+ * browsers apply the CORS check to a redirect response.
94
+ *
95
+ * A document request gets a relative 308 (never built from `Host`). A page
96
+ * navigation gets the router's soft redirect, decided exactly as for a
97
+ * `redirect()` outcome: an RSC request that does not match a route.ts
98
+ * handler (`buildRedirectResponse`). The framework's `_rsc` cache key is
99
+ * dropped from the soft target; the router appends a fresh one when it
100
+ * fetches the canonical URL.
101
+ *
102
+ * See design/07-routing.md §"Non-Canonical URLs Redirect".
103
+ */
104
+ export function canonicalRedirect(
105
+ req: Request,
106
+ canonicalPath: string,
107
+ stripTrailingSlash: boolean,
108
+ matchRoute: RouteMatcher
109
+ ): Response | null {
110
+ if (req.method !== 'GET' && req.method !== 'HEAD') return null;
111
+ const url = new URL(req.url);
112
+ const target = canonicalRequestPath(url.pathname, stripTrailingSlash);
113
+ if (target === url.pathname) return null;
114
+ const navigation = isRscRequest(req) && !isApiRoutePath(canonicalPath, matchRoute);
115
+ const search = navigation ? appVisibleSearch(url).search : url.search;
116
+ return buildRedirectResponse(new RedirectSignal(target + search, 308), new Headers(), navigation);
117
+ }
118
+
119
+ /** Whether `path` matches a route.ts handler. A matcher that throws: no. */
120
+ function isApiRoutePath(path: string, matchRoute: RouteMatcher): boolean {
121
+ try {
122
+ const match = matchRoute(path);
123
+ return match !== null && isApiRouteChain(match.segments);
124
+ } catch (err) {
125
+ swallow(err, 'canonical redirect: route match for route.ts lookup threw');
126
+ return false;
127
+ }
128
+ }
129
+
69
130
  // ─── Proxy Resolver ────────────────────────────────────────────────────────
70
131
 
71
132
  /**
@@ -32,7 +32,7 @@ import {
32
32
  import { logRequestReceived, logRequestCompleted, logSlowRequest } from './logger.ts';
33
33
  import { DenySignal } from './primitives.ts';
34
34
  import type { ManifestSegmentNode } from './route-matcher.ts';
35
- import { csrfGate, makeProxyResolver } from './pipeline-helpers.ts';
35
+ import { canonicalRedirect, csrfGate, makeProxyResolver } from './pipeline-helpers.ts';
36
36
  import type { CsrfConfig } from './csrf.ts';
37
37
  import { handleRequest, runProxyPhase } from './pipeline-phases.ts';
38
38
  import { outcomeToResponse } from './pipeline-outcome.ts';
@@ -402,6 +402,18 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
402
402
  // TIM-1213: action dispatch is now INSIDE the pipeline so
403
403
  // proxy.ts runs on every request, including action POSTs.
404
404
  const innerHandler = async (): Promise<Response> => {
405
+ // A safe request for a non-canonical URL (`/x/`, `/a//b`) is
406
+ // redirected to the canonical one instead of served, so the
407
+ // client hydrates with the pathname the server renders with
408
+ // (TIM-1571). Inside proxy.ts's next(), so proxy.ts still runs
409
+ // on every request and its headers (CORS) reach the redirect.
410
+ const redirect = canonicalRedirect(
411
+ req,
412
+ canonicalPath,
413
+ stripTrailingSlash,
414
+ config.matchRoute
415
+ );
416
+ if (redirect) return redirect;
405
417
  if (config.dispatchAction) {
406
418
  const actionResult = await config.dispatchAction(
407
419
  canonicalReq,
@@ -123,11 +123,14 @@ export function buildActionDispatcher(
123
123
  // render's request-context cookies, so the page reads what the
124
124
  // action wrote, not what the request carried (TIM-868).
125
125
  // - Method GET because it is a page render.
126
+ // - The POST's abort signal, so a client that disconnects stops the
127
+ // render (and the form state encode) as it would any page's.
126
128
  const rerenderHeaders = new Headers(req.headers);
127
129
  rerenderHeaders.delete('cookie');
128
130
  const rerenderReq = new Request(req.url, {
129
131
  method: 'GET',
130
132
  headers: rerenderHeaders,
133
+ signal: req.signal,
131
134
  });
132
135
  const { cookies } = actionResponse;
133
136
  const response = await reenter(
@@ -322,6 +322,7 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
322
322
  clientSegmentCache,
323
323
  renderDenyFallback,
324
324
  buildManifest: typedBuildManifest,
325
+ renderTimeoutMs: (config as Record<string, unknown>).renderTimeoutMs as number,
325
326
  globalError: manifest.globalError,
326
327
  },
327
328
  interception
@@ -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
  }