@timber-js/app 0.2.0-alpha.146 → 0.2.0-alpha.148

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 (297) hide show
  1. package/dist/_chunks/{actions-OIZ8siQu.js → actions-a0RczDnB.js} +8 -34
  2. package/dist/_chunks/{actions-OIZ8siQu.js.map → actions-a0RczDnB.js.map} +1 -1
  3. package/dist/_chunks/cache-api-Co-yI-gP.js +424 -0
  4. package/dist/_chunks/cache-api-Co-yI-gP.js.map +1 -0
  5. package/dist/_chunks/{metadata-routes-BjepjefT.js → canonicalize-P41GR6tY.js} +65 -2
  6. package/dist/_chunks/canonicalize-P41GR6tY.js.map +1 -0
  7. package/dist/_chunks/{cli-schema-sync-D2IpXicg.js → cli-schema-sync-DJDdyEae.js} +157 -313
  8. package/dist/_chunks/cli-schema-sync-DJDdyEae.js.map +1 -0
  9. package/dist/_chunks/cli-schema-sync-D_IhHEUW.js +2 -0
  10. package/dist/_chunks/{define-DMVhsipD.js → define-Bssfp6ot.js} +3 -33
  11. package/dist/_chunks/define-Bssfp6ot.js.map +1 -0
  12. package/dist/_chunks/{define-C3RvHQPK.js → define-CFmvb4Bt.js} +15 -7
  13. package/dist/_chunks/define-CFmvb4Bt.js.map +1 -0
  14. package/dist/_chunks/{href-validation-DyZIzfMJ.js → href-validation-FiA_rToW.js} +2 -1
  15. package/dist/_chunks/{href-validation-DyZIzfMJ.js.map → href-validation-FiA_rToW.js.map} +1 -1
  16. package/dist/_chunks/{tracing-M_zKubhk.js → logger-CM0clugc.js} +394 -107
  17. package/dist/_chunks/logger-CM0clugc.js.map +1 -0
  18. package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -1
  19. package/dist/_chunks/plugin-context-DurqFBuY.js +2 -0
  20. package/dist/_chunks/plugin-context-GfDWsdd2.js +268 -0
  21. package/dist/_chunks/plugin-context-GfDWsdd2.js.map +1 -0
  22. package/dist/_chunks/{resolve-schema-Dj8tQloi.js → resolve-schema-3iUvBV5T.js} +3 -30
  23. package/dist/_chunks/resolve-schema-3iUvBV5T.js.map +1 -0
  24. package/dist/_chunks/{schema-bridge-DK_89h8O.js → schema-bridge-BY3QLBL7.js} +35 -2
  25. package/dist/_chunks/schema-bridge-BY3QLBL7.js.map +1 -0
  26. package/dist/_chunks/tree-match-D2l830j2.js +102 -0
  27. package/dist/_chunks/tree-match-D2l830j2.js.map +1 -0
  28. package/dist/_chunks/use-segment-params-D1CzlgBo.js.map +1 -1
  29. package/dist/_chunks/{walkers-7FBPM-hX.js → walkers-DvWDj453.js} +154 -88
  30. package/dist/_chunks/walkers-DvWDj453.js.map +1 -0
  31. package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
  32. package/dist/adapters/cloudflare-kv-cache.js +36 -14
  33. package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
  34. package/dist/adapters/compress-module.d.ts +5 -4
  35. package/dist/adapters/compress-module.d.ts.map +1 -1
  36. package/dist/adapters/nitro.d.ts +1 -3
  37. package/dist/adapters/nitro.d.ts.map +1 -1
  38. package/dist/adapters/nitro.js +14 -134
  39. package/dist/adapters/nitro.js.map +1 -1
  40. package/dist/cache/cache-api.d.ts.map +1 -1
  41. package/dist/cache/index.d.ts +3 -1
  42. package/dist/cache/index.d.ts.map +1 -1
  43. package/dist/cache/index.js +20 -235
  44. package/dist/cache/index.js.map +1 -1
  45. package/dist/cache/invalidation-epoch.d.ts +43 -0
  46. package/dist/cache/invalidation-epoch.d.ts.map +1 -0
  47. package/dist/cache/redis-handler.d.ts +13 -0
  48. package/dist/cache/redis-handler.d.ts.map +1 -1
  49. package/dist/cache/timber-cache.d.ts +17 -9
  50. package/dist/cache/timber-cache.d.ts.map +1 -1
  51. package/dist/cli-schema-sync.d.ts +21 -1
  52. package/dist/cli-schema-sync.d.ts.map +1 -1
  53. package/dist/cli.d.ts +12 -0
  54. package/dist/cli.d.ts.map +1 -1
  55. package/dist/cli.js +26 -31
  56. package/dist/cli.js.map +1 -1
  57. package/dist/client/browser-entry/hmr.d.ts.map +1 -1
  58. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  59. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  60. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  61. package/dist/client/error-boundary.d.ts.map +1 -1
  62. package/dist/client/error-boundary.js +13 -4
  63. package/dist/client/error-boundary.js.map +1 -1
  64. package/dist/client/error-reconstituter.d.ts +8 -1
  65. package/dist/client/error-reconstituter.d.ts.map +1 -1
  66. package/dist/client/history.d.ts +10 -17
  67. package/dist/client/history.d.ts.map +1 -1
  68. package/dist/client/index.js +1 -1
  69. package/dist/client/index.js.map +1 -1
  70. package/dist/client/internal.js +30 -33
  71. package/dist/client/internal.js.map +1 -1
  72. package/dist/client/navigation-api.d.ts +9 -2
  73. package/dist/client/navigation-api.d.ts.map +1 -1
  74. package/dist/client/navigation-context.d.ts +0 -11
  75. package/dist/client/navigation-context.d.ts.map +1 -1
  76. package/dist/client/navigation-root.d.ts +0 -5
  77. package/dist/client/navigation-root.d.ts.map +1 -1
  78. package/dist/client/router.d.ts.map +1 -1
  79. package/dist/client/rsc-fetch.d.ts.map +1 -1
  80. package/dist/client/use-search-params.d.ts +10 -5
  81. package/dist/client/use-search-params.d.ts.map +1 -1
  82. package/dist/codec.js +1 -1
  83. package/dist/config-validation.d.ts.map +1 -1
  84. package/dist/cookies/define-cookie.d.ts.map +1 -1
  85. package/dist/cookies/index.js +2 -4
  86. package/dist/cookies/index.js.map +1 -1
  87. package/dist/dev-tools/dev-page-shell.d.ts +1 -0
  88. package/dist/dev-tools/dev-page-shell.d.ts.map +1 -1
  89. package/dist/dev-tools/hydration-overlay.d.ts +11 -0
  90. package/dist/dev-tools/hydration-overlay.d.ts.map +1 -0
  91. package/dist/dev-tools/index.d.ts +1 -2
  92. package/dist/dev-tools/index.d.ts.map +1 -1
  93. package/dist/fonts/ast-walk.d.ts +68 -0
  94. package/dist/fonts/ast-walk.d.ts.map +1 -0
  95. package/dist/fonts/ast.d.ts +23 -11
  96. package/dist/fonts/ast.d.ts.map +1 -1
  97. package/dist/fonts/bundle.d.ts +3 -1
  98. package/dist/fonts/bundle.d.ts.map +1 -1
  99. package/dist/fonts/dev-middleware.d.ts +1 -1
  100. package/dist/fonts/dev-middleware.d.ts.map +1 -1
  101. package/dist/fonts/fallbacks.d.ts +0 -7
  102. package/dist/fonts/fallbacks.d.ts.map +1 -1
  103. package/dist/fonts/google.d.ts +4 -2
  104. package/dist/fonts/google.d.ts.map +1 -1
  105. package/dist/fonts/local.d.ts +3 -3
  106. package/dist/fonts/local.d.ts.map +1 -1
  107. package/dist/fonts/paths.d.ts +43 -0
  108. package/dist/fonts/paths.d.ts.map +1 -0
  109. package/dist/fonts/pipeline.d.ts +7 -21
  110. package/dist/fonts/pipeline.d.ts.map +1 -1
  111. package/dist/fonts/transform.d.ts.map +1 -1
  112. package/dist/fonts/virtual-modules.d.ts +1 -18
  113. package/dist/fonts/virtual-modules.d.ts.map +1 -1
  114. package/dist/index.d.ts.map +1 -1
  115. package/dist/index.js +609 -406
  116. package/dist/index.js.map +1 -1
  117. package/dist/params/index.js +1 -1
  118. package/dist/params/resolve-schema.d.ts.map +1 -1
  119. package/dist/plugin-context.d.ts +28 -5
  120. package/dist/plugin-context.d.ts.map +1 -1
  121. package/dist/plugins/cache.d.ts +13 -5
  122. package/dist/plugins/cache.d.ts.map +1 -1
  123. package/dist/plugins/dev-server.d.ts.map +1 -1
  124. package/dist/plugins/fonts.d.ts +1 -2
  125. package/dist/plugins/fonts.d.ts.map +1 -1
  126. package/dist/plugins/routing.d.ts.map +1 -1
  127. package/dist/routing/codegen-types.d.ts +4 -0
  128. package/dist/routing/codegen-types.d.ts.map +1 -1
  129. package/dist/routing/codegen.d.ts.map +1 -1
  130. package/dist/routing/convention-lint.d.ts.map +1 -1
  131. package/dist/routing/file-cache.d.ts +8 -0
  132. package/dist/routing/file-cache.d.ts.map +1 -0
  133. package/dist/routing/index.js +2 -2
  134. package/dist/routing/interception.d.ts +0 -5
  135. package/dist/routing/interception.d.ts.map +1 -1
  136. package/dist/routing/manifest-codegen.d.ts +32 -0
  137. package/dist/routing/manifest-codegen.d.ts.map +1 -0
  138. package/dist/routing/scanner.d.ts.map +1 -1
  139. package/dist/schema-bridge.d.ts +15 -0
  140. package/dist/schema-bridge.d.ts.map +1 -1
  141. package/dist/search-params/define.d.ts.map +1 -1
  142. package/dist/search-params/index.js +7 -3
  143. package/dist/search-params/index.js.map +1 -1
  144. package/dist/search-params/wrappers.d.ts.map +1 -1
  145. package/dist/segment-params/define.d.ts.map +1 -1
  146. package/dist/segment-params/index.js +1 -1
  147. package/dist/server/access-gate.d.ts.map +1 -1
  148. package/dist/server/actions.d.ts +0 -14
  149. package/dist/server/actions.d.ts.map +1 -1
  150. package/dist/server/build-manifest.d.ts +0 -29
  151. package/dist/server/build-manifest.d.ts.map +1 -1
  152. package/dist/server/canonicalize.d.ts +10 -0
  153. package/dist/server/canonicalize.d.ts.map +1 -1
  154. package/dist/server/deny-boundary.d.ts +27 -2
  155. package/dist/server/deny-boundary.d.ts.map +1 -1
  156. package/dist/server/deny-renderer.d.ts +1 -1
  157. package/dist/server/deny-renderer.d.ts.map +1 -1
  158. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  159. package/dist/server/html-injector-core.d.ts +8 -0
  160. package/dist/server/html-injector-core.d.ts.map +1 -1
  161. package/dist/server/html-injectors.d.ts.map +1 -1
  162. package/dist/server/index.js +2 -2
  163. package/dist/server/internal.d.ts +1 -5
  164. package/dist/server/internal.d.ts.map +1 -1
  165. package/dist/server/internal.js +238 -349
  166. package/dist/server/internal.js.map +1 -1
  167. package/dist/server/node-stream-transforms.d.ts +0 -11
  168. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  169. package/dist/server/render-utils.d.ts +25 -0
  170. package/dist/server/render-utils.d.ts.map +1 -0
  171. package/dist/server/route-element-builder.d.ts +11 -25
  172. package/dist/server/route-element-builder.d.ts.map +1 -1
  173. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  174. package/dist/server/rsc-entry/helpers.d.ts +2 -18
  175. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  176. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  177. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  178. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  179. package/dist/server/ssr-render.d.ts.map +1 -1
  180. package/dist/server/status-code-resolver.d.ts +6 -0
  181. package/dist/server/status-code-resolver.d.ts.map +1 -1
  182. package/dist/server/utils/mdx-file.d.ts +15 -0
  183. package/dist/server/utils/mdx-file.d.ts.map +1 -0
  184. package/dist/shared/href-validation.d.ts.map +1 -1
  185. package/dist/shims/image.d.ts +15 -15
  186. package/package.json +3 -3
  187. package/src/adapters/cloudflare-kv-cache.ts +56 -19
  188. package/src/adapters/compress-module.ts +27 -118
  189. package/src/adapters/nitro.ts +3 -41
  190. package/src/adapters/raw-imports.d.ts +5 -0
  191. package/src/cache/cache-api.ts +9 -1
  192. package/src/cache/index.ts +5 -2
  193. package/src/cache/invalidation-epoch.ts +93 -0
  194. package/src/cache/redis-handler.ts +33 -2
  195. package/src/cache/timber-cache.ts +109 -53
  196. package/src/cli-schema-sync.ts +123 -30
  197. package/src/cli.ts +33 -37
  198. package/src/client/browser-entry/hmr.ts +0 -11
  199. package/src/client/browser-entry/hydrate.ts +39 -5
  200. package/src/client/browser-entry/post-hydration.ts +6 -15
  201. package/src/client/browser-entry/router-init.ts +98 -3
  202. package/src/client/error-boundary.tsx +14 -0
  203. package/src/client/error-reconstituter.tsx +15 -1
  204. package/src/client/history.ts +10 -24
  205. package/src/client/navigation-api.ts +14 -9
  206. package/src/client/navigation-context.ts +1 -21
  207. package/src/client/navigation-root.tsx +0 -8
  208. package/src/client/router.ts +18 -8
  209. package/src/client/rsc-fetch.ts +7 -2
  210. package/src/client/use-search-params.ts +10 -5
  211. package/src/config-validation.ts +33 -25
  212. package/src/cookies/define-cookie.ts +2 -18
  213. package/src/dev-tools/dev-page-shell.ts +6 -2
  214. package/src/dev-tools/hydration-overlay.ts +158 -0
  215. package/src/dev-tools/index.ts +3 -16
  216. package/src/fonts/ast-walk.ts +235 -0
  217. package/src/fonts/ast.ts +91 -249
  218. package/src/fonts/bundle.ts +16 -9
  219. package/src/fonts/dev-middleware.ts +16 -5
  220. package/src/fonts/fallbacks.ts +0 -23
  221. package/src/fonts/google.ts +7 -3
  222. package/src/fonts/local.ts +7 -6
  223. package/src/fonts/paths.ts +58 -0
  224. package/src/fonts/pipeline.ts +27 -48
  225. package/src/fonts/transform.ts +27 -0
  226. package/src/fonts/virtual-modules.ts +1 -57
  227. package/src/index.ts +5 -11
  228. package/src/params/resolve-schema.ts +2 -34
  229. package/src/plugin-context.ts +95 -17
  230. package/src/plugins/cache.ts +64 -17
  231. package/src/plugins/dev-server.ts +61 -9
  232. package/src/plugins/fonts.ts +21 -6
  233. package/src/plugins/routing.ts +5 -348
  234. package/src/routing/codegen-types.ts +4 -0
  235. package/src/routing/codegen.ts +66 -8
  236. package/src/routing/convention-lint.ts +4 -3
  237. package/src/routing/file-cache.ts +23 -0
  238. package/src/routing/interception.ts +2 -21
  239. package/src/routing/manifest-codegen.ts +290 -0
  240. package/src/routing/scanner.ts +85 -80
  241. package/src/routing/schema-validation.ts +2 -2
  242. package/src/schema-bridge.ts +63 -2
  243. package/src/search-params/define.ts +34 -2
  244. package/src/search-params/wrappers.ts +21 -3
  245. package/src/segment-params/define.ts +2 -62
  246. package/src/server/access-gate.tsx +8 -0
  247. package/src/server/actions.ts +8 -31
  248. package/src/server/build-manifest.ts +0 -56
  249. package/src/server/canonicalize.ts +6 -3
  250. package/src/server/deny-boundary.ts +106 -13
  251. package/src/server/deny-renderer.ts +46 -10
  252. package/src/server/error-boundary-wrapper.ts +1 -14
  253. package/src/server/html-injector-core.ts +19 -0
  254. package/src/server/html-injectors.ts +14 -4
  255. package/src/server/internal.ts +1 -18
  256. package/src/server/node-stream-transforms.ts +5 -72
  257. package/src/server/render-utils.ts +31 -0
  258. package/src/server/route-element-builder.ts +77 -47
  259. package/src/server/route-matcher.ts +22 -6
  260. package/src/server/rsc-entry/error-renderer.ts +2 -12
  261. package/src/server/rsc-entry/helpers.ts +6 -34
  262. package/src/server/rsc-entry/index.ts +11 -0
  263. package/src/server/rsc-entry/render-route.ts +7 -9
  264. package/src/server/rsc-entry/rsc-payload.ts +2 -1
  265. package/src/server/rsc-entry/ssr-renderer.ts +2 -2
  266. package/src/server/ssr-render.ts +1 -11
  267. package/src/server/status-code-resolver.ts +3 -2
  268. package/src/server/tree-match.ts +15 -5
  269. package/src/server/utils/mdx-file.ts +22 -0
  270. package/src/shared/href-validation.ts +8 -0
  271. package/dist/_chunks/cli-schema-sync-D2IpXicg.js.map +0 -1
  272. package/dist/_chunks/cli-schema-sync-DH-M8Poy.js +0 -2
  273. package/dist/_chunks/define-C3RvHQPK.js.map +0 -1
  274. package/dist/_chunks/define-DMVhsipD.js.map +0 -1
  275. package/dist/_chunks/handler-store-BVDgyTh7.js +0 -78
  276. package/dist/_chunks/handler-store-BVDgyTh7.js.map +0 -1
  277. package/dist/_chunks/logger-Dt-jQaBO.js +0 -291
  278. package/dist/_chunks/logger-Dt-jQaBO.js.map +0 -1
  279. package/dist/_chunks/metadata-routes-BjepjefT.js.map +0 -1
  280. package/dist/_chunks/resolve-schema-Dj8tQloi.js.map +0 -1
  281. package/dist/_chunks/schema-bridge-DK_89h8O.js.map +0 -1
  282. package/dist/_chunks/tracing-M_zKubhk.js.map +0 -1
  283. package/dist/_chunks/walkers-7FBPM-hX.js.map +0 -1
  284. package/dist/_chunks/warnings-CNPXaypM.js +0 -125
  285. package/dist/_chunks/warnings-CNPXaypM.js.map +0 -1
  286. package/dist/client/link-pending-store.d.ts +0 -65
  287. package/dist/client/link-pending-store.d.ts.map +0 -1
  288. package/dist/client/nav-link-store.d.ts +0 -27
  289. package/dist/client/nav-link-store.d.ts.map +0 -1
  290. package/dist/dev-tools/warnings.d.ts +0 -109
  291. package/dist/dev-tools/warnings.d.ts.map +0 -1
  292. package/dist/server/flush.d.ts +0 -74
  293. package/dist/server/flush.d.ts.map +0 -1
  294. package/src/client/link-pending-store.ts +0 -116
  295. package/src/client/nav-link-store.ts +0 -37
  296. package/src/dev-tools/warnings.ts +0 -238
  297. package/src/server/flush.ts +0 -183
@@ -11,9 +11,8 @@
11
11
  * See design/19-client-navigation.md §"History Stack"
12
12
  */
13
13
 
14
- import { setCurrentParams } from '#client-internal';
15
14
  import type { RouterInstance } from '#client-internal';
16
- import { setNavigationState } from '../navigation-context.js';
15
+ import { getNavigationState } from '../navigation-context.js';
17
16
  import type { NavigationApiController } from '../navigation-api.js';
18
17
  import { getScrollY } from './scroll.js';
19
18
 
@@ -35,9 +34,14 @@ export function setupPostHydration({
35
34
  // Store the initial page in the history stack so back-button works
36
35
  // after the first navigation. We store the decoded RSC element so
37
36
  // back navigation can replay it instantly without a server fetch.
37
+ // Params were captured from the server-embedded __timber_params by
38
+ // runPreHydration() — without them on the entry, the cached replay on
39
+ // back-navigation would normalize params to {} and wipe every
40
+ // useSegmentParams() consumer on the initial page (TIM-1037).
38
41
  router.historyStack.push(window.location.pathname + window.location.search, {
39
42
  payload: initialElement,
40
43
  headElements: null, // SSR already set the correct head
44
+ params: getNavigationState().params,
41
45
  });
42
46
 
43
47
  // Initialize scroll state for the initial entry.
@@ -58,19 +62,6 @@ export function setupPostHydration({
58
62
  delete (self as unknown as Record<string, unknown>).__timber_segments;
59
63
  }
60
64
 
61
- // If the hydration path was skipped (no RSC payload), populate the
62
- // fallback params from server embed here.
63
- const lateTimberParams = (self as unknown as Record<string, unknown>).__timber_params;
64
- if (lateTimberParams && typeof lateTimberParams === 'object') {
65
- setCurrentParams(lateTimberParams as Record<string, string | string[]>);
66
- setNavigationState({
67
- params: lateTimberParams as Record<string, string | string[]>,
68
- pathname: window.location.pathname,
69
- search: window.location.search,
70
- });
71
- delete (self as unknown as Record<string, unknown>).__timber_params;
72
- }
73
-
74
65
  // Register popstate handler for back/forward navigation.
75
66
  // When Navigation API is active, the navigate event covers traversals —
76
67
  // popstate is a no-op. When unavailable, popstate handles back/forward.
@@ -14,7 +14,11 @@ import type { RouterDeps, RouterInstance } from '#client-internal';
14
14
  import type { NavigationState } from '#client-internal';
15
15
  import { applyHeadElements } from '../head.js';
16
16
  import { TimberNuqsAdapter } from '../nuqs-adapter.js';
17
- import { NavigationProvider } from '../navigation-context.js';
17
+ import {
18
+ NavigationProvider,
19
+ getNavigationState,
20
+ setNavigationState,
21
+ } from '../navigation-context.js';
18
22
  import { transitionRender, navigateTransition } from '../navigation-root.js';
19
23
  import { SegmentUpdateContext, EMPTY_SEGMENT_UPDATES } from '../segment-update-context.js';
20
24
  import {
@@ -54,10 +58,96 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
54
58
  // very first client navigation (before any wrapPayload call).
55
59
  let currentPayload: unknown = options?.initialElement ?? null;
56
60
 
61
+ // ─── History API Patch ──────────────────────────────────────────
62
+ // Patch pushState/replaceState to detect external URL changes (nuqs
63
+ // shallow updates, replaceUrl, third-party libraries). When the router
64
+ // itself calls pushState/replaceState, it sets a flag so the patch
65
+ // skips the sync — the router already updates NavigationContext.
66
+ //
67
+ // On Navigation API browsers, the navigate event catches most URL
68
+ // changes. The patch is defense-in-depth and the primary mechanism
69
+ // for History API-only browsers (older Safari/Firefox).
70
+ const ROUTER_HISTORY_FLAG = Symbol.for('__timber_router_history_update');
71
+ const gFlags = globalThis as Record<symbol, boolean>;
72
+
73
+ const originalPushState = window.history.pushState.bind(window.history);
74
+ const originalReplaceState = window.history.replaceState.bind(window.history);
75
+
76
+ /**
77
+ * Sync NavigationContext.search after a shallow URL update. Called by
78
+ * the Navigation API shallow handler and the History API patch.
79
+ *
80
+ * @param search — the new search string. The Navigation API path passes
81
+ * this from event.destination.url (window.location hasn't committed
82
+ * yet when the navigate listener fires). The History API patch omits
83
+ * it, reading window.location.search (already committed by the time
84
+ * the patched pushState/replaceState returns).
85
+ *
86
+ * Bails when search is unchanged (scroll saves, no-ops, dual-fire).
87
+ * Re-wraps currentPayload with updated NavigationProvider and commits
88
+ * via transitionRender (startTransition — non-blocking).
89
+ */
90
+ function syncShallowSearch(search?: string): void {
91
+ const currentSearch = search ?? window.location.search;
92
+ const navState = getNavigationState();
93
+ if (currentSearch === navState.search) return;
94
+ if (!currentPayload) return;
95
+
96
+ const newNavState: NavigationState = {
97
+ params: navState.params,
98
+ pathname: navState.pathname,
99
+ search: currentSearch,
100
+ };
101
+ setNavigationState(newNavState);
102
+
103
+ const withNav = createElement(
104
+ NavigationProvider,
105
+ { value: newNavState },
106
+ currentPayload as React.ReactNode
107
+ );
108
+ const withUpdates = createElement(
109
+ SegmentUpdateContext.Provider,
110
+ { value: EMPTY_SEGMENT_UPDATES },
111
+ withNav
112
+ );
113
+ const wrapped = createElement(TimberNuqsAdapter, null, withUpdates);
114
+ transitionRender(wrapped);
115
+ }
116
+
117
+ window.history.pushState = function (
118
+ data: unknown,
119
+ unused: string,
120
+ url?: string | URL | null
121
+ ): void {
122
+ originalPushState(data, unused, url);
123
+ if (!gFlags[ROUTER_HISTORY_FLAG]) {
124
+ syncShallowSearch();
125
+ }
126
+ };
127
+
128
+ window.history.replaceState = function (
129
+ data: unknown,
130
+ unused: string,
131
+ url?: string | URL | null
132
+ ): void {
133
+ originalReplaceState(data, unused, url);
134
+ if (!gFlags[ROUTER_HISTORY_FLAG]) {
135
+ syncShallowSearch();
136
+ }
137
+ };
138
+
57
139
  const deps: RouterDeps = {
58
140
  fetch: (url, init) => window.fetch(url, init),
59
- pushState: (data, unused, url) => window.history.pushState(data, unused, url),
60
- replaceState: (data, unused, url) => window.history.replaceState(data, unused, url),
141
+ pushState: (data, unused, url) => {
142
+ gFlags[ROUTER_HISTORY_FLAG] = true;
143
+ window.history.pushState(data, unused, url);
144
+ gFlags[ROUTER_HISTORY_FLAG] = false;
145
+ },
146
+ replaceState: (data, unused, url) => {
147
+ gFlags[ROUTER_HISTORY_FLAG] = true;
148
+ window.history.replaceState(data, unused, url);
149
+ gFlags[ROUTER_HISTORY_FLAG] = false;
150
+ },
61
151
  navigationApiActive: useNavApi,
62
152
  scrollTo: (x, y) => {
63
153
  // Scroll the document viewport.
@@ -94,6 +184,7 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
94
184
  // navState is passed explicitly by the router — no temporal coupling
95
185
  // with getNavigationState().
96
186
  renderRoot: (element: unknown, navState: NavigationState) => {
187
+ currentPayload = element;
97
188
  const withNav = createElement(
98
189
  NavigationProvider,
99
190
  { value: navState },
@@ -183,6 +274,10 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
183
274
  // Back/forward — delegate to the router's popstate handler.
184
275
  await router.handlePopState(url, scrollY, signal);
185
276
  },
277
+ onShallowNavigate: (destinationUrl: string) => {
278
+ const search = new URL(destinationUrl).search;
279
+ syncShallowSearch(search);
280
+ },
186
281
  });
187
282
 
188
283
  // Wire the router-navigating flag into RouterDeps.
@@ -255,7 +255,21 @@ export class TimberErrorBoundary extends Component<
255
255
 
256
256
  // Render the fallback component with the right props shape.
257
257
  if (parsed?.type === 'deny') {
258
+ // Status-code file boundaries (404.tsx, 4xx.tsx, …) get the documented
259
+ // 4xx props. error.tsx boundaries (no status filter) get their
260
+ // documented { error, digest, reset } contract — plus the deny extras
261
+ // for dual-shape implementations. Without the error prop, an error.tsx
262
+ // reading error.message crashes on deny fall-through. See TIM-1081.
263
+ if (this.props.status != null) {
264
+ return createElement(this.props.fallbackComponent as never, {
265
+ status: parsed.status,
266
+ dangerouslyPassData: parsed.data,
267
+ });
268
+ }
258
269
  return createElement(this.props.fallbackComponent as never, {
270
+ error,
271
+ digest: null,
272
+ reset: this.reset,
259
273
  status: parsed.status,
260
274
  dangerouslyPassData: parsed.data,
261
275
  });
@@ -33,6 +33,9 @@ export interface SerializableError {
33
33
  * - digest: plain JSON or null
34
34
  * - reset: undefined (only meaningful on client after boundary catch)
35
35
  * - component: client module reference (RSC Flight serializes as opaque ref)
36
+ * - status / dangerouslyPassData: set only when error.tsx serves a deny()
37
+ * as the last entry in the 4xx fallback chain — forwarded so dual-shape
38
+ * error.tsx implementations can branch on the deny status (TIM-1081)
36
39
  */
37
40
  interface ErrorReconstituterProps {
38
41
  error: SerializableError;
@@ -42,7 +45,11 @@ interface ErrorReconstituterProps {
42
45
  error: Error;
43
46
  digest: { code: string; data: unknown } | null;
44
47
  reset: (() => void) | undefined;
48
+ status?: number;
49
+ dangerouslyPassData?: unknown;
45
50
  }>;
51
+ status?: number;
52
+ dangerouslyPassData?: unknown;
46
53
  }
47
54
 
48
55
  /**
@@ -54,6 +61,8 @@ export function ErrorReconstituter({
54
61
  digest,
55
62
  reset,
56
63
  component,
64
+ status,
65
+ dangerouslyPassData,
57
66
  }: ErrorReconstituterProps): ReactNode {
58
67
  // Reconstitute a real Error so instanceof checks work in user code
59
68
  const error = Object.assign(new Error(serialized.message), {
@@ -61,5 +70,10 @@ export function ErrorReconstituter({
61
70
  ...(serialized.stack != null ? { stack: serialized.stack } : {}),
62
71
  });
63
72
 
64
- return createElement(component, { error, digest, reset });
73
+ return createElement(component, {
74
+ error,
75
+ digest,
76
+ reset,
77
+ ...(status != null ? { status, dangerouslyPassData } : {}),
78
+ });
65
79
  }
@@ -10,7 +10,12 @@ export interface HistoryEntry {
10
10
  payload: unknown;
11
11
  /** Resolved head elements for this page (title, meta tags). Null for SSR'd initial page. */
12
12
  headElements?: HeadElement[] | null;
13
- /** Route params for this page (for useParams). Null for SSR'd initial page. */
13
+ /**
14
+ * Route params for this page (for useSegmentParams). Every entry that can
15
+ * be replayed must carry them — the initial SSR entry gets them from the
16
+ * server-embedded __timber_params, navigation entries from X-Timber-Params,
17
+ * and revalidation overwrites preserve the current state (TIM-1037).
18
+ */
14
19
  params?: Record<string, string | string[]> | null;
15
20
  }
16
21
 
@@ -23,13 +28,8 @@ export interface HistoryEntry {
23
28
  * On forward navigation, the new page's payload is pushed onto the stack.
24
29
  * On popstate, the cached payload is replayed instantly.
25
30
  *
26
- * Supports two keying modes:
27
- * - **URL-keyed** (default): entries keyed by pathname + search.
28
- * Used with the History API fallback.
29
- * - **Entry-key + URL**: when the Navigation API is available,
30
- * entries can also be stored by Navigation entry key for
31
- * disambiguation of duplicate URLs in the history stack.
32
- * Falls back to URL lookup when entry key is not found.
31
+ * Entries are keyed by pathname + search. Used with the History API
32
+ * fallback and the Navigation API.
33
33
  *
34
34
  * Scroll positions are stored in history.state or Navigation API entry
35
35
  * state, not in this stack — see design/19-client-navigation.md §Scroll Restoration.
@@ -39,26 +39,12 @@ export interface HistoryEntry {
39
39
  */
40
40
  export class HistoryStack {
41
41
  private entries = new Map<string, HistoryEntry>();
42
- /** Entries keyed by Navigation API entry key for duplicate URL disambiguation. */
43
- private entryKeyMap = new Map<string, HistoryEntry>();
44
42
 
45
- push(url: string, entry: HistoryEntry, entryKey?: string): void {
43
+ push(url: string, entry: HistoryEntry): void {
46
44
  this.entries.set(url, entry);
47
- if (entryKey) {
48
- this.entryKeyMap.set(entryKey, entry);
49
- }
50
45
  }
51
46
 
52
- /**
53
- * Get an entry. When an entry key is provided (Navigation API),
54
- * tries the entry-key map first for accurate disambiguation of
55
- * duplicate URLs, then falls back to URL lookup.
56
- */
57
- get(url: string, entryKey?: string): HistoryEntry | undefined {
58
- if (entryKey) {
59
- const byKey = this.entryKeyMap.get(entryKey);
60
- if (byKey) return byKey;
61
- }
47
+ get(url: string): HistoryEntry | undefined {
62
48
  return this.entries.get(url);
63
49
  }
64
50
 
@@ -18,7 +18,6 @@
18
18
  */
19
19
 
20
20
  import type { NavigationApi, NavigateEvent } from './navigation-api-types.js';
21
- import { consumeNavLinkMetadata } from './nav-link-store.js';
22
21
  import { isHardNavigating } from './navigation-root.js';
23
22
 
24
23
  // ─── Feature Detection ───────────────────────────────────────────
@@ -55,8 +54,8 @@ export function getNavigationApi(): NavigationApi | null {
55
54
  export interface NavigationApiCallbacks {
56
55
  /**
57
56
  * Handle a push/replace navigation intercepted by the Navigation API.
58
- * This covers both Link <a> clicks (user-initiated, with metadata from
59
- * nav-link-store) and external navigations (plain <a> tags, programmatic).
57
+ * This covers both Link <a> clicks (user-initiated) and external
58
+ * navigations (plain <a> tags, programmatic).
60
59
  * The Navigation API handles the URL update via event.intercept().
61
60
  */
62
61
  onExternalNavigate: (
@@ -69,6 +68,14 @@ export interface NavigationApiCallbacks {
69
68
  * the traversal and delegates to us for RSC replay/fetch.
70
69
  */
71
70
  onTraverse: (url: string, scrollY: number, signal: AbortSignal) => Promise<void>;
71
+
72
+ /**
73
+ * Called when a shallow URL update is intercepted (e.g., nuqs with
74
+ * shallow: true, or replaceUrl). The URL has already been committed —
75
+ * this callback syncs NavigationContext.search so useSearchParams()
76
+ * reflects the new value without a full router navigation.
77
+ */
78
+ onShallowNavigate?: (url: string) => void;
72
79
  }
73
80
 
74
81
  /**
@@ -187,6 +194,7 @@ export function setupNavigationApi(callbacks: NavigationApiCallbacks): Navigatio
187
194
  focusReset: 'manual',
188
195
  scroll: 'manual',
189
196
  });
197
+ callbacks.onShallowNavigate?.(event.destination.url);
190
198
  return;
191
199
  }
192
200
 
@@ -239,11 +247,8 @@ export function setupNavigationApi(callbacks: NavigationApiCallbacks): Navigatio
239
247
  },
240
248
  });
241
249
  } else if (event.navigationType === 'push' || event.navigationType === 'replace') {
242
- // Push/replace — either a Link <a> click (with metadata in
243
- // nav-link-store) or an external navigation (plain <a>, programmatic).
244
- // Consume link metadata if present — tells us scroll preference
245
- // and which Link component to track pending state for.
246
- const linkMeta = consumeNavLinkMetadata();
250
+ // Push/replace — a Link <a> click or an external navigation
251
+ // (plain <a> tag, programmatic).
247
252
 
248
253
  // Save the departing page's scroll position BEFORE event.intercept()
249
254
  // commits the URL change. Once intercept() is called, currentEntry
@@ -273,7 +278,7 @@ export function setupNavigationApi(callbacks: NavigationApiCallbacks): Navigatio
273
278
  await callbacks.onExternalNavigate(url + destUrl.hash, {
274
279
  replace: event.navigationType === 'replace',
275
280
  signal: event.signal,
276
- scroll: linkMeta?.scroll,
281
+ scroll: undefined,
277
282
  });
278
283
  },
279
284
  });
@@ -176,7 +176,7 @@ export function getNavigationState(): NavigationState {
176
176
  * Separate context for the in-flight navigation URL. Provided by
177
177
  * NavigationRoot (urgent useState), consumed by usePendingNavigation
178
178
  * and TopLoader. Per-link pending state uses useOptimistic instead
179
- * (see link-pending-store.ts).
179
+ * (see design/19-client-navigation.md §"Per-Link Pending State").
180
180
  *
181
181
  * Uses globalThis via Symbol.for for the same reason as NavigationContext
182
182
  * above — the bundler may duplicate this module across chunks, and module-
@@ -225,23 +225,3 @@ export function PendingNavigationProvider({
225
225
  }
226
226
  return createElement(ctx.Provider, { value }, children);
227
227
  }
228
-
229
- // ---------------------------------------------------------------------------
230
- // Navigation API transition state (optional progressive enhancement)
231
- // ---------------------------------------------------------------------------
232
-
233
- /**
234
- * Check if the browser's Navigation API has an active transition.
235
- *
236
- * When the Navigation API is available and a navigation has been intercepted
237
- * via event.intercept(), `navigation.transition` is non-null until the
238
- * handler resolves. This provides browser-native progress tracking that
239
- * can be used alongside the existing pendingUrl mechanism.
240
- *
241
- * Returns false when Navigation API is unavailable or no transition is active.
242
- */
243
- export function hasNativeNavigationTransition(): boolean {
244
- if (typeof window === 'undefined') return false;
245
- const nav = (window as unknown as { navigation?: { transition?: unknown } }).navigation;
246
- return nav?.transition != null;
247
- }
@@ -324,14 +324,6 @@ export function navigateTransition(
324
324
  return perform().then(() => {});
325
325
  }
326
326
 
327
- /**
328
- * Check if the NavigationRoot is mounted and ready for renders.
329
- * Used by browser-entry.ts to guard against renders before hydration.
330
- */
331
- export function isNavigationRootReady(): boolean {
332
- return _transitionRender !== null;
333
- }
334
-
335
327
  /**
336
328
  * Install one-shot deferred callbacks for the no-RSC bootstrap path (TIM-600).
337
329
  *
@@ -778,16 +778,21 @@ export function createRouter(deps: RouterDeps): RouterInstance {
778
778
  * Called on hover of <Link prefetch> elements.
779
779
  */
780
780
  function prefetch(url: string): void {
781
+ // Strip fragment — it's client-only and would swallow the _rsc cache-bust
782
+ // param into the hash. The hash-less key also matches navigate()'s fetchUrl.
783
+ const hashIndex = url.indexOf('#');
784
+ const fetchUrl = hashIndex === -1 ? url : url.slice(0, hashIndex);
785
+
781
786
  // Don't prefetch if already cached
782
- if (prefetchCache.get(url) !== undefined) return;
783
- if (historyStack.has(url)) return;
787
+ if (prefetchCache.get(fetchUrl) !== undefined) return;
788
+ if (historyStack.has(fetchUrl)) return;
784
789
 
785
790
  // Fire-and-forget fetch
786
791
  const stateTree = segmentCache.serializeStateTree();
787
- void fetchRscPayload(url, deps, stateTree).then(
792
+ void fetchRscPayload(fetchUrl, deps, stateTree).then(
788
793
  (result) => {
789
794
  result.decodePromise?.catch(() => {});
790
- prefetchCache.set(url, result);
795
+ prefetchCache.set(fetchUrl, result);
791
796
  },
792
797
  () => {
793
798
  // Prefetch failure is non-fatal — navigation will fetch fresh
@@ -813,15 +818,20 @@ export function createRouter(deps: RouterDeps): RouterInstance {
813
818
  // Revalidation payloads are already resolved (no Flight thenable),
814
819
  // so no segment merging is needed — just cache and render.
815
820
  const currentUrl = deps.getCurrentUrl();
816
- historyStack.push(currentUrl, {
817
- payload: element,
818
- headElements,
819
- });
820
821
 
821
822
  // Revalidation doesn't change params/pathname — preserve current state.
822
823
  // DO NOT call updateNavigationState(null, ...) here: that normalizes
823
824
  // params to {}, clearing dynamic route params on every action response.
824
825
  const navState = getNavigationState();
826
+
827
+ // Keep params on the overwritten entry — dropping them would make
828
+ // away-and-back navigation replay this page with params normalized
829
+ // to {} (TIM-1037).
830
+ historyStack.push(currentUrl, {
831
+ payload: element,
832
+ headElements,
833
+ params: navState.params,
834
+ });
825
835
  renderPayload(element, navState);
826
836
  applyHead(headElements);
827
837
  },
@@ -61,10 +61,15 @@ function generateCacheBustId(): string {
61
61
  * Append a `_rsc=<id>` query parameter to the URL.
62
62
  * Follows Next.js's pattern — prevents CDN/browser from serving cached HTML
63
63
  * for RSC navigation requests and signals that this is an RSC fetch.
64
+ *
65
+ * Strips any #fragment before appending — fragments are client-only and
66
+ * fetch() discards them, so _rsc would land inside the hash and be lost.
64
67
  */
65
68
  function appendRscParam(url: string): string {
66
- const separator = url.includes('?') ? '&' : '?';
67
- return `${url}${separator}_rsc=${generateCacheBustId()}`;
69
+ const hashIndex = url.indexOf('#');
70
+ const urlWithoutHash = hashIndex === -1 ? url : url.slice(0, hashIndex);
71
+ const separator = urlWithoutHash.includes('?') ? '&' : '?';
72
+ return `${urlWithoutHash}${separator}_rsc=${generateCacheBustId()}`;
68
73
  }
69
74
 
70
75
  // ─── Deployment ID ───────────────────────────────────────────────
@@ -5,11 +5,16 @@
5
5
  * URL's query string. Updates when client-side navigation changes the URL.
6
6
  *
7
7
  * On the client, reads from NavigationContext which is updated atomically
8
- * with the RSC tree render. This replaces the previous useSyncExternalStore
9
- * approach which read window.location.search directly — causing React to
10
- * detect external store tearing during transitions and fall back to
11
- * synchronous rendering (renderRootSync instead of renderRootConcurrent),
12
- * blocking the main thread and freezing animations.
8
+ * with the RSC tree render during full navigations, AND by
9
+ * syncShallowSearch() for shallow URL updates (nuqs shallow: true,
10
+ * replaceUrl, or any external pushState/replaceState that changes the
11
+ * query string). See router-init.ts.
12
+ *
13
+ * This replaces the previous useSyncExternalStore approach which read
14
+ * window.location.search directly — causing React to detect external
15
+ * store tearing during transitions and fall back to synchronous rendering
16
+ * (renderRootSync instead of renderRootConcurrent), blocking the main
17
+ * thread and freezing animations.
13
18
  *
14
19
  * Unlike Next.js's ReadonlyURLSearchParams, this returns a standard
15
20
  * URLSearchParams. Mutation methods (set, delete, append) work on the
@@ -20,6 +20,39 @@ export interface ConfigError {
20
20
  suggestion?: string;
21
21
  }
22
22
 
23
+ // ─── Known config keys ──────────────────────────────────────────────────────
24
+
25
+ // `satisfies Record<keyof TimberUserConfig, true>` makes this drift-proof:
26
+ // adding a key to TimberUserConfig without listing it here is a compile
27
+ // error, so validateConfig can never reject a documented option (TIM-1066).
28
+ const KNOWN_CONFIG_KEYS = {
29
+ output: true,
30
+ debug: true,
31
+ clientJavascript: true,
32
+ adapter: true,
33
+ cacheHandler: true,
34
+ allowedOrigins: true,
35
+ csrf: true,
36
+ limits: true,
37
+ actions: true,
38
+ forms: true,
39
+ pageExtensions: true,
40
+ slowRequestMs: true,
41
+ renderTimeoutMs: true,
42
+ devBrowserLogs: true,
43
+ dev: true,
44
+ serverTiming: true,
45
+ appDir: true,
46
+ mdx: true,
47
+ actionEncryption: true,
48
+ reactCompiler: true,
49
+ sitemap: true,
50
+ buildDir: true,
51
+ topLoader: true,
52
+ } satisfies Record<keyof TimberUserConfig, true>;
53
+
54
+ const knownKeys = new Set<string>(Object.keys(KNOWN_CONFIG_KEYS));
55
+
23
56
  // ─── Validation ─────────────────────────────────────────────────────────────
24
57
 
25
58
  /**
@@ -147,31 +180,6 @@ export function validateConfig(config: TimberUserConfig): ConfigError[] {
147
180
  }
148
181
  }
149
182
 
150
- // Unknown top-level keys
151
- const knownKeys = new Set([
152
- 'output',
153
- 'debug',
154
- 'clientJavascript',
155
- 'adapter',
156
- 'cacheHandler',
157
- 'allowedOrigins',
158
- 'csrf',
159
- 'limits',
160
- 'pageExtensions',
161
- 'slowRequestMs',
162
- 'renderTimeoutMs',
163
- 'devBrowserLogs',
164
- 'dev',
165
- 'serverTiming',
166
- 'appDir',
167
- 'mdx',
168
- 'actionEncryption',
169
- 'reactCompiler',
170
- 'sitemap',
171
- 'buildDir',
172
- 'topLoader',
173
- ]);
174
-
175
183
  for (const key of Object.keys(config)) {
176
184
  if (!knownKeys.has(key)) {
177
185
  errors.push({
@@ -24,6 +24,7 @@ import type { ClientCookieOptions } from '../client/use-cookie.js';
24
24
 
25
25
  import type { Codec } from '../codec.js';
26
26
  import type { StandardSchemaV1 } from '../schema-bridge.js';
27
+ import { resolveCodecOrSchema } from '../schema-bridge.js';
27
28
 
28
29
  /**
29
30
  * A codec that converts between string cookie values and typed values.
@@ -87,7 +88,6 @@ export interface CookieDefinition<T> extends ServerOnlyCookieDefinition<T> {
87
88
  // Schema: fromSchema is a pure function — safe in all environments.
88
89
 
89
90
  import { getCookieJar } from '@timber-js/app/server';
90
- import { fromSchema } from '../schema-bridge.js';
91
91
  import {
92
92
  useCookie as useRawCookie,
93
93
  notifyCookieChange,
@@ -147,23 +147,7 @@ function deleteClientCookie(name: string, options?: ClientCookieOptions): void {
147
147
  // ─── Standard Schema Auto-Detection ───────────────────────────────────────
148
148
 
149
149
  function resolveCodec<T>(codecOrSchema: CookieCodec<T> | StandardSchemaV1<T>): CookieCodec<T> {
150
- // If it has parse + serialize, it's already a Codec
151
- if (
152
- typeof (codecOrSchema as Codec<T>).parse === 'function' &&
153
- typeof (codecOrSchema as Codec<T>).serialize === 'function'
154
- ) {
155
- return codecOrSchema as CookieCodec<T>;
156
- }
157
-
158
- // Auto-detect Standard Schema
159
- if (typeof codecOrSchema === 'object' && codecOrSchema !== null && '~standard' in codecOrSchema) {
160
- return fromSchema(codecOrSchema as StandardSchemaV1<unknown>) as CookieCodec<T>;
161
- }
162
-
163
- throw new Error(
164
- '[timber] defineCookie: codec must be a Codec<T> (with parse/serialize methods) ' +
165
- 'or a Standard Schema object (Zod, Valibot, ArkType).'
166
- );
150
+ return resolveCodecOrSchema('codec', codecOrSchema, 'search') as CookieCodec<T>;
167
151
  }
168
152
 
169
153
  // ─── Factory ──────────────────────────────────────────────────────────────
@@ -37,7 +37,11 @@ export interface DevErrorHmrOptions {
37
37
  * so the auto-reload WebSocket connects to the correct endpoint.
38
38
  */
39
39
  export function extractHmrOptions(config: {
40
- server?: { hmr?: { protocol?: string; host?: string; port?: number; path?: string } | boolean };
40
+ server?: {
41
+ hmr?:
42
+ | { protocol?: string; host?: string; port?: number; clientPort?: number; path?: string }
43
+ | boolean;
44
+ };
41
45
  webSocketToken?: string;
42
46
  }): DevErrorHmrOptions | undefined {
43
47
  const hmr = config.server?.hmr;
@@ -46,7 +50,7 @@ export function extractHmrOptions(config: {
46
50
  const opts: DevErrorHmrOptions = {
47
51
  protocol: hmrOpts.protocol,
48
52
  host: hmrOpts.host,
49
- port: hmrOpts.port,
53
+ port: hmrOpts.clientPort || hmrOpts.port,
50
54
  path: hmrOpts.path,
51
55
  token: config.webSocketToken,
52
56
  };