@timber-js/app 0.1.20 → 0.1.22

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 (137) hide show
  1. package/dist/_chunks/als-registry-c0AGnbqS.js +39 -0
  2. package/dist/_chunks/als-registry-c0AGnbqS.js.map +1 -0
  3. package/dist/_chunks/{interception-c-a3uODY.js → interception-DGDIjDbR.js} +10 -3
  4. package/dist/_chunks/interception-DGDIjDbR.js.map +1 -0
  5. package/dist/_chunks/{metadata-routes-BDnswgRO.js → metadata-routes-CQCnF4VK.js} +14 -2
  6. package/dist/_chunks/metadata-routes-CQCnF4VK.js.map +1 -0
  7. package/dist/_chunks/{request-context-BzES06i1.js → request-context-C69VW4xS.js} +2 -4
  8. package/dist/_chunks/request-context-C69VW4xS.js.map +1 -0
  9. package/dist/_chunks/ssr-data-B2yikEEB.js +90 -0
  10. package/dist/_chunks/ssr-data-B2yikEEB.js.map +1 -0
  11. package/dist/_chunks/{tracing-BtOwb8O6.js → tracing-tIvqStk8.js} +2 -3
  12. package/dist/_chunks/tracing-tIvqStk8.js.map +1 -0
  13. package/dist/_chunks/{use-cookie-D2cZu0jK.js → use-cookie-D5aS4slY.js} +2 -2
  14. package/dist/_chunks/{use-cookie-D2cZu0jK.js.map → use-cookie-D5aS4slY.js.map} +1 -1
  15. package/dist/_chunks/{use-query-states-wEXY2JQB.js → use-query-states-DAhgj8Gx.js} +1 -1
  16. package/dist/_chunks/{use-query-states-wEXY2JQB.js.map → use-query-states-DAhgj8Gx.js.map} +1 -1
  17. package/dist/cache/index.js +2 -1
  18. package/dist/cache/index.js.map +1 -1
  19. package/dist/client/error-boundary.js +1 -1
  20. package/dist/client/index.d.ts +1 -1
  21. package/dist/client/index.d.ts.map +1 -1
  22. package/dist/client/index.js +40 -26
  23. package/dist/client/index.js.map +1 -1
  24. package/dist/client/router-ref.d.ts.map +1 -1
  25. package/dist/client/router.d.ts.map +1 -1
  26. package/dist/client/ssr-data.d.ts +3 -0
  27. package/dist/client/ssr-data.d.ts.map +1 -1
  28. package/dist/client/state.d.ts +47 -0
  29. package/dist/client/state.d.ts.map +1 -0
  30. package/dist/client/types.d.ts +10 -1
  31. package/dist/client/types.d.ts.map +1 -1
  32. package/dist/client/unload-guard.d.ts +3 -0
  33. package/dist/client/unload-guard.d.ts.map +1 -1
  34. package/dist/client/use-params.d.ts +19 -6
  35. package/dist/client/use-params.d.ts.map +1 -1
  36. package/dist/client/use-search-params.d.ts +3 -0
  37. package/dist/client/use-search-params.d.ts.map +1 -1
  38. package/dist/cookies/index.js +4 -2
  39. package/dist/cookies/index.js.map +1 -1
  40. package/dist/index.js +4 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/plugins/shims.d.ts.map +1 -1
  43. package/dist/routing/index.js +1 -1
  44. package/dist/routing/scanner.d.ts.map +1 -1
  45. package/dist/rsc-runtime/browser.d.ts +13 -0
  46. package/dist/rsc-runtime/browser.d.ts.map +1 -0
  47. package/dist/rsc-runtime/rsc.d.ts +14 -0
  48. package/dist/rsc-runtime/rsc.d.ts.map +1 -0
  49. package/dist/rsc-runtime/ssr.d.ts +13 -0
  50. package/dist/rsc-runtime/ssr.d.ts.map +1 -0
  51. package/dist/search-params/builtin-codecs.d.ts +105 -0
  52. package/dist/search-params/builtin-codecs.d.ts.map +1 -0
  53. package/dist/search-params/index.d.ts +1 -0
  54. package/dist/search-params/index.d.ts.map +1 -1
  55. package/dist/search-params/index.js +167 -2
  56. package/dist/search-params/index.js.map +1 -1
  57. package/dist/server/actions.d.ts +2 -7
  58. package/dist/server/actions.d.ts.map +1 -1
  59. package/dist/server/als-registry.d.ts +80 -0
  60. package/dist/server/als-registry.d.ts.map +1 -0
  61. package/dist/server/early-hints-sender.d.ts.map +1 -1
  62. package/dist/server/form-flash.d.ts.map +1 -1
  63. package/dist/server/index.d.ts +1 -0
  64. package/dist/server/index.d.ts.map +1 -1
  65. package/dist/server/index.js +242 -76
  66. package/dist/server/index.js.map +1 -1
  67. package/dist/server/metadata-routes.d.ts +27 -0
  68. package/dist/server/metadata-routes.d.ts.map +1 -1
  69. package/dist/server/pipeline.d.ts +7 -0
  70. package/dist/server/pipeline.d.ts.map +1 -1
  71. package/dist/server/primitives.d.ts +14 -6
  72. package/dist/server/primitives.d.ts.map +1 -1
  73. package/dist/server/request-context.d.ts +2 -32
  74. package/dist/server/request-context.d.ts.map +1 -1
  75. package/dist/server/route-matcher.d.ts +5 -0
  76. package/dist/server/route-matcher.d.ts.map +1 -1
  77. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  78. package/dist/server/rsc-entry/rsc-payload.d.ts +25 -0
  79. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -0
  80. package/dist/server/rsc-entry/rsc-stream.d.ts +43 -0
  81. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -0
  82. package/dist/server/rsc-entry/ssr-renderer.d.ts +52 -0
  83. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -0
  84. package/dist/server/rsc-prop-warnings.d.ts +53 -0
  85. package/dist/server/rsc-prop-warnings.d.ts.map +1 -0
  86. package/dist/server/server-timing.d.ts +49 -0
  87. package/dist/server/server-timing.d.ts.map +1 -0
  88. package/dist/server/tracing.d.ts +2 -6
  89. package/dist/server/tracing.d.ts.map +1 -1
  90. package/dist/server/types.d.ts +11 -0
  91. package/dist/server/types.d.ts.map +1 -1
  92. package/package.json +1 -1
  93. package/src/client/browser-entry.ts +1 -1
  94. package/src/client/index.ts +1 -1
  95. package/src/client/router-ref.ts +6 -12
  96. package/src/client/router.ts +14 -4
  97. package/src/client/ssr-data.ts +25 -9
  98. package/src/client/state.ts +83 -0
  99. package/src/client/types.ts +18 -1
  100. package/src/client/unload-guard.ts +6 -3
  101. package/src/client/use-params.ts +42 -32
  102. package/src/client/use-search-params.ts +9 -5
  103. package/src/plugins/shims.ts +26 -2
  104. package/src/routing/scanner.ts +18 -2
  105. package/src/rsc-runtime/browser.ts +18 -0
  106. package/src/rsc-runtime/rsc.ts +19 -0
  107. package/src/rsc-runtime/ssr.ts +13 -0
  108. package/src/search-params/builtin-codecs.ts +228 -0
  109. package/src/search-params/index.ts +11 -0
  110. package/src/server/action-handler.ts +1 -1
  111. package/src/server/actions.ts +4 -10
  112. package/src/server/als-registry.ts +116 -0
  113. package/src/server/deny-renderer.ts +1 -1
  114. package/src/server/early-hints-sender.ts +1 -3
  115. package/src/server/form-flash.ts +1 -5
  116. package/src/server/index.ts +1 -0
  117. package/src/server/metadata-routes.ts +61 -0
  118. package/src/server/pipeline.ts +164 -38
  119. package/src/server/primitives.ts +110 -6
  120. package/src/server/request-context.ts +8 -36
  121. package/src/server/route-matcher.ts +25 -2
  122. package/src/server/rsc-entry/error-renderer.ts +1 -1
  123. package/src/server/rsc-entry/index.ts +42 -380
  124. package/src/server/rsc-entry/rsc-payload.ts +126 -0
  125. package/src/server/rsc-entry/rsc-stream.ts +162 -0
  126. package/src/server/rsc-entry/ssr-renderer.ts +228 -0
  127. package/src/server/rsc-prop-warnings.ts +187 -0
  128. package/src/server/server-timing.ts +132 -0
  129. package/src/server/ssr-entry.ts +1 -1
  130. package/src/server/tracing.ts +3 -11
  131. package/src/server/types.ts +16 -0
  132. package/dist/_chunks/interception-c-a3uODY.js.map +0 -1
  133. package/dist/_chunks/metadata-routes-BDnswgRO.js.map +0 -1
  134. package/dist/_chunks/request-context-BzES06i1.js.map +0 -1
  135. package/dist/_chunks/ssr-data-BgSwMbN9.js +0 -38
  136. package/dist/_chunks/ssr-data-BgSwMbN9.js.map +0 -1
  137. package/dist/_chunks/tracing-BtOwb8O6.js.map +0 -1
@@ -5,7 +5,7 @@ import { SegmentCache, PrefetchCache, buildSegmentTree } from './segment-cache';
5
5
  import type { SegmentInfo } from './segment-cache';
6
6
  import { HistoryStack } from './history';
7
7
  import type { HeadElement } from './head';
8
- import { setCurrentParams } from './use-params.js';
8
+ import { setCurrentParams, notifyParamsListeners } from './use-params.js';
9
9
 
10
10
  // ─── Types ───────────────────────────────────────────────────────
11
11
 
@@ -393,12 +393,19 @@ export function createRouter(deps: RouterDeps): RouterInstance {
393
393
  // header reflects the currently mounted segments.
394
394
  updateSegmentCache(result.segmentInfo);
395
395
 
396
- // Update useParams() with the new route's params before rendering.
396
+ // Update the params snapshot before rendering so new components in the
397
+ // RSC tree see the correct params via getSnapshot(). Subscriber
398
+ // notification is deferred until after renderPayload() so preserved
399
+ // layouts don't re-render with {old tree, new params}.
397
400
  updateParams(result.params);
398
401
 
399
402
  // Render the decoded RSC tree into the DOM.
400
403
  renderPayload(result.payload);
401
404
 
405
+ // Now notify useParams() subscribers — preserved layout components
406
+ // re-render after the new tree is committed, seeing {new tree, new params}.
407
+ notifyParamsListeners();
408
+
402
409
  // Update document.title and <meta> tags with the new page's metadata
403
410
  applyHead(result.headElements);
404
411
 
@@ -457,11 +464,12 @@ export function createRouter(deps: RouterDeps): RouterInstance {
457
464
  // Update segment cache with fresh segment info from full render
458
465
  updateSegmentCache(result.segmentInfo);
459
466
 
460
- // Update useParams() with refreshed route params
467
+ // Update params snapshot before rendering (see navigate() for rationale)
461
468
  updateParams(result.params);
462
469
 
463
- // Render the fresh RSC tree and update head elements
470
+ // Render the fresh RSC tree, then notify params subscribers
464
471
  renderPayload(result.payload);
472
+ notifyParamsListeners();
465
473
  applyHead(result.headElements);
466
474
  } finally {
467
475
  setPending(false);
@@ -478,6 +486,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
478
486
  // Replay cached payload — no server roundtrip
479
487
  updateParams(entry.params);
480
488
  renderPayload(entry.payload);
489
+ notifyParamsListeners();
481
490
  applyHead(entry.headElements);
482
491
  afterPaint(() => {
483
492
  deps.scrollTo(0, scrollY);
@@ -500,6 +509,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
500
509
  params: result.params,
501
510
  });
502
511
  renderPayload(result.payload);
512
+ notifyParamsListeners();
503
513
  applyHead(result.headElements);
504
514
  afterPaint(() => {
505
515
  deps.scrollTo(0, scrollY);
@@ -17,8 +17,18 @@
17
17
  * APIs, as it's imported by 'use client' hooks that are bundled for the browser.
18
18
  * The ALS instance lives in ssr-entry.ts (server-only); this module only holds
19
19
  * a reference to the provider function.
20
+ *
21
+ * All mutable state is delegated to client/state.ts for singleton guarantees.
22
+ * See design/18-build-system.md §"Singleton State Registry"
20
23
  */
21
24
 
25
+ import {
26
+ ssrDataProvider,
27
+ currentSsrData,
28
+ _setSsrDataProvider,
29
+ _setCurrentSsrData,
30
+ } from './state.js';
31
+
22
32
  // ─── Types ────────────────────────────────────────────────────────
23
33
 
24
34
  export interface SsrData {
@@ -44,8 +54,16 @@ export interface SsrData {
44
54
  // Server-side code (ssr-entry.ts) registers a provider that reads
45
55
  // from AsyncLocalStorage. This avoids importing node:async_hooks
46
56
  // in this browser-bundled module.
47
-
48
- let _ssrDataProvider: (() => SsrData | undefined) | undefined;
57
+ //
58
+ // Module singleton guarantee: In Vite's SSR environment, both
59
+ // ssr-entry.ts (via #/client/ssr-data.js) and client component hooks
60
+ // (via @timber-js/app/client) must resolve to the SAME module instance
61
+ // of this file. The timber-shims plugin ensures this by remapping
62
+ // @timber-js/app/client → src/client/index.ts in the SSR environment.
63
+ // Without this remap, @timber-js/app/client resolves to dist/ (via
64
+ // package.json exports), creating a split where registerSsrDataProvider
65
+ // writes to one instance but getSsrData reads from another.
66
+ // See timber-shims plugin resolveId for details.
49
67
 
50
68
  /**
51
69
  * Register an ALS-backed SSR data provider. Called once at module load
@@ -56,15 +74,13 @@ let _ssrDataProvider: (() => SsrData | undefined) | undefined;
56
74
  * concurrent requests with streaming Suspense.
57
75
  */
58
76
  export function registerSsrDataProvider(provider: () => SsrData | undefined): void {
59
- _ssrDataProvider = provider;
77
+ _setSsrDataProvider(provider);
60
78
  }
61
79
 
62
80
  // ─── Module-Level Fallback ────────────────────────────────────────
63
81
  //
64
82
  // Used by tests and as a fallback when no ALS provider is registered.
65
83
 
66
- let currentSsrData: SsrData | undefined;
67
-
68
84
  /**
69
85
  * Set the SSR data for the current request via module-level state.
70
86
  *
@@ -72,7 +88,7 @@ let currentSsrData: SsrData | undefined;
72
88
  * This function is retained for tests and as a fallback.
73
89
  */
74
90
  export function setSsrData(data: SsrData): void {
75
- currentSsrData = data;
91
+ _setCurrentSsrData(data);
76
92
  }
77
93
 
78
94
  /**
@@ -82,7 +98,7 @@ export function setSsrData(data: SsrData): void {
82
98
  * This function is retained for tests and as a fallback.
83
99
  */
84
100
  export function clearSsrData(): void {
85
- currentSsrData = undefined;
101
+ _setCurrentSsrData(undefined);
86
102
  }
87
103
 
88
104
  /**
@@ -95,8 +111,8 @@ export function clearSsrData(): void {
95
111
  * Used by client hooks' server snapshot functions.
96
112
  */
97
113
  export function getSsrData(): SsrData | undefined {
98
- if (_ssrDataProvider) {
99
- return _ssrDataProvider();
114
+ if (ssrDataProvider) {
115
+ return ssrDataProvider();
100
116
  }
101
117
  return currentSsrData;
102
118
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Centralized client singleton state registry.
3
+ *
4
+ * ALL mutable module-level state that must have singleton semantics across
5
+ * the client bundle lives here. Individual modules (router-ref.ts, ssr-data.ts,
6
+ * use-params.ts, use-search-params.ts, unload-guard.ts) import from this file
7
+ * and re-export thin wrapper functions.
8
+ *
9
+ * Why: In Vite dev, a module is instantiated separately if reached via different
10
+ * import paths (e.g., relative `./foo.js` vs barrel `@timber-js/app/client`).
11
+ * By centralizing all mutable state in a single module that is always reached
12
+ * through the same dependency chain (barrel → wrapper → state.ts), we guarantee
13
+ * a single instance of every piece of shared state.
14
+ *
15
+ * DO NOT import this file from outside client/. Server code must never depend
16
+ * on client state. The barrel (client/index.ts) is the public entry point.
17
+ *
18
+ * See design/18-build-system.md §"Module Singleton Strategy" and
19
+ * §"Singleton State Registry".
20
+ */
21
+
22
+ import type { RouterInstance } from './router.js';
23
+ import type { SsrData } from './ssr-data.js';
24
+
25
+ // ─── Router (from router-ref.ts) ──────────────────────────────────────────
26
+
27
+ /** The global router singleton — set once during bootstrap. */
28
+ export let globalRouter: RouterInstance | null = null;
29
+
30
+ export function _setGlobalRouter(router: RouterInstance | null): void {
31
+ globalRouter = router;
32
+ }
33
+
34
+ // ─── SSR Data Provider (from ssr-data.ts) ──────────────────────────────────
35
+
36
+ /**
37
+ * ALS-backed SSR data provider. When registered, getSsrData() reads from
38
+ * this function (ALS store) instead of module-level currentSsrData.
39
+ */
40
+ export let ssrDataProvider: (() => SsrData | undefined) | undefined;
41
+
42
+ export function _setSsrDataProvider(provider: (() => SsrData | undefined) | undefined): void {
43
+ ssrDataProvider = provider;
44
+ }
45
+
46
+ /** Fallback SSR data for tests and environments without ALS. */
47
+ export let currentSsrData: SsrData | undefined;
48
+
49
+ export function _setCurrentSsrData(data: SsrData | undefined): void {
50
+ currentSsrData = data;
51
+ }
52
+
53
+ // ─── Route Params (from use-params.ts) ──────────────────────────────────────
54
+
55
+ /** Current route params snapshot — replaced (not mutated) on each navigation. */
56
+ export let currentParams: Record<string, string | string[]> = {};
57
+
58
+ export function _setCurrentParams(params: Record<string, string | string[]>): void {
59
+ currentParams = params;
60
+ }
61
+
62
+ /** Listeners notified when currentParams changes. */
63
+ export const paramsListeners = new Set<() => void>();
64
+
65
+ // ─── Search Params Cache (from use-search-params.ts) ────────────────────────
66
+
67
+ /** Cached search string — avoids reparsing when URL hasn't changed. */
68
+ export let cachedSearch = '';
69
+ export let cachedSearchParams = new URLSearchParams();
70
+
71
+ export function _setCachedSearch(search: string, params: URLSearchParams): void {
72
+ cachedSearch = search;
73
+ cachedSearchParams = params;
74
+ }
75
+
76
+ // ─── Unload Guard (from unload-guard.ts) ─────────────────────────────────────
77
+
78
+ /** Whether the page is currently being unloaded. */
79
+ export let unloading = false;
80
+
81
+ export function _setUnloading(value: boolean): void {
82
+ unloading = value;
83
+ }
@@ -1,4 +1,21 @@
1
- export interface RenderErrorDigest<Code extends string = string, Data = unknown> {
1
+ /**
2
+ * A value that is safe to pass through `JSON.stringify` without data loss.
3
+ *
4
+ * Mirrors the server-side type. Defined separately to avoid importing server
5
+ * modules into the client bundle.
6
+ */
7
+ export type JsonSerializable =
8
+ | string
9
+ | number
10
+ | boolean
11
+ | null
12
+ | JsonSerializable[]
13
+ | { [key: string]: JsonSerializable };
14
+
15
+ export interface RenderErrorDigest<
16
+ Code extends string = string,
17
+ Data extends JsonSerializable = JsonSerializable,
18
+ > {
2
19
  code: Code;
3
20
  data: Data;
4
21
  }
@@ -8,20 +8,23 @@
8
8
  * unloaded so error boundaries and error handlers can suppress abort-related
9
9
  * errors during the unload window.
10
10
  *
11
+ * Mutable state is delegated to client/state.ts for singleton guarantees.
12
+ * See design/18-build-system.md §"Singleton State Registry"
13
+ *
11
14
  * See design/10-error-handling.md §"Known limitation: deny() inside Suspense and hydration"
12
15
  */
13
16
 
14
- let unloading = false;
17
+ import { unloading, _setUnloading } from './state.js';
15
18
 
16
19
  if (typeof window !== 'undefined') {
17
20
  window.addEventListener('beforeunload', () => {
18
- unloading = true;
21
+ _setUnloading(true);
19
22
  });
20
23
 
21
24
  // Also detect pagehide for bfcache-aware browsers (Safari).
22
25
  // pagehide fires for both navigations and page hide events.
23
26
  window.addEventListener('pagehide', () => {
24
- unloading = true;
27
+ _setUnloading(true);
25
28
  });
26
29
  }
27
30
 
@@ -23,31 +23,28 @@
23
23
  * params change during client-side navigation. This matches the pattern
24
24
  * used by usePathname() and useSearchParams().
25
25
  *
26
+ * All mutable state is delegated to client/state.ts for singleton guarantees.
27
+ * See design/18-build-system.md §"Singleton State Registry"
28
+ *
26
29
  * Design doc: design/09-typescript.md §"Typed Routes"
27
30
  */
28
31
 
29
32
  import { useSyncExternalStore } from 'react';
30
33
  import type { Routes } from '#/index.js';
31
34
  import { getSsrData } from './ssr-data.js';
35
+ import { currentParams, _setCurrentParams, paramsListeners } from './state.js';
32
36
 
33
37
  // ---------------------------------------------------------------------------
34
- // Module-level state + subscribe/notify pattern
38
+ // Module-level subscribe/notify pattern — state lives in state.ts
35
39
  // ---------------------------------------------------------------------------
36
40
 
37
- // The current params snapshot. Replaced (not mutated) on each navigation
38
- // so that React's Object.is check on the snapshot detects changes.
39
- let currentParams: Record<string, string | string[]> = {};
40
-
41
- // Listeners notified when currentParams changes.
42
- const listeners = new Set<() => void>();
43
-
44
41
  /**
45
42
  * Subscribe to params changes. Called by useSyncExternalStore.
46
43
  * Exported for testing — not intended for direct use by app code.
47
44
  */
48
45
  export function subscribe(callback: () => void): () => void {
49
- listeners.add(callback);
50
- return () => listeners.delete(callback);
46
+ paramsListeners.add(callback);
47
+ return () => paramsListeners.delete(callback);
51
48
  }
52
49
 
53
50
  /**
@@ -72,21 +69,33 @@ function getServerSnapshot(): Record<string, string | string[]> {
72
69
  // ---------------------------------------------------------------------------
73
70
 
74
71
  /**
75
- * Set the current route params. Called by the framework internals
76
- * during navigation not intended for direct use by app code.
72
+ * Set the current route params WITHOUT notifying subscribers.
73
+ * Called by the router before renderPayload() so that new components
74
+ * in the RSC tree see the updated params via getSnapshot(), but
75
+ * preserved layout components don't re-render prematurely with
76
+ * {old tree, new params}.
77
+ *
78
+ * After the React render commits, the router calls notifyParamsListeners()
79
+ * to trigger re-renders in preserved layouts that read useParams().
77
80
  *
78
81
  * On the client, the segment router calls this on each navigation.
79
82
  * During SSR, params are also available via getSsrData().params
80
83
  * (ALS-backed), but setCurrentParams is still called for the
81
84
  * module-level fallback path.
82
- *
83
- * After mutation, all useSyncExternalStore subscribers are notified
84
- * so that every mounted useParams() consumer re-renders in the same
85
- * React commit — even components in unchanged layouts.
86
85
  */
87
86
  export function setCurrentParams(params: Record<string, string | string[]>): void {
88
- currentParams = params;
89
- for (const listener of listeners) {
87
+ _setCurrentParams(params);
88
+ }
89
+
90
+ /**
91
+ * Notify all useSyncExternalStore subscribers that params have changed.
92
+ * Called by the router AFTER renderPayload() so that preserved layout
93
+ * components re-render only after the new tree is committed — producing
94
+ * an atomic {new tree, new params} update instead of a stale
95
+ * {old tree, new params} intermediate state.
96
+ */
97
+ export function notifyParamsListeners(): void {
98
+ for (const listener of paramsListeners) {
90
99
  listener();
91
100
  }
92
101
  }
@@ -112,24 +121,25 @@ export function setCurrentParams(params: Record<string, string | string[]>): voi
112
121
  export function useParams<R extends keyof Routes>(route: R): Routes[R]['params'];
113
122
  export function useParams(route?: string): Record<string, string | string[]>;
114
123
  export function useParams(_route?: string): Record<string, string | string[]> {
115
- // During SSR, read from the ALS-backed SSR data context.
116
- // This ensures correct params even for components inside Suspense
117
- // boundaries that resolve asynchronously across concurrent requests.
118
- const ssrData = getSsrData();
119
- if (ssrData) {
120
- return ssrData.params;
121
- }
122
-
123
- // useSyncExternalStore requires a React dispatcher (i.e., must be called
124
- // inside a component render). When called outside a component (e.g., in
125
- // tests or setup code), fall back to reading the snapshot directly.
126
- // This mirrors React's own behavior — hooks only work during rendering.
124
+ // useSyncExternalStore handles both client and SSR:
125
+ // - Client: calls getSnapshot() reads currentParams from state.ts
126
+ // - SSR: calls getServerSnapshot() reads from ALS-backed getSsrData()
127
+ //
128
+ // We must always call the hook (Rules of Hooks — no conditional hook calls).
129
+ // React picks the right snapshot function based on the environment.
130
+ //
131
+ // When called outside a React component (e.g., in test assertions),
132
+ // useSyncExternalStore throws because there's no dispatcher. In that case,
133
+ // fall back to reading the snapshot directly.
127
134
  try {
128
135
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
129
136
  } catch {
130
- // No React dispatcher available — return the snapshot directly.
137
+ // No React dispatcher available — return the best available snapshot.
131
138
  // This path is hit when useParams() is called outside a component,
132
139
  // e.g. in test assertions that verify the current params value.
133
- return getSnapshot();
140
+ // Use getServerSnapshot() because it checks the ALS-backed SSR context
141
+ // first (request-isolated), falling back to module-level currentParams
142
+ // only when no SSR context exists (client-side / tests).
143
+ return getServerSnapshot();
134
144
  }
135
145
  }
@@ -14,10 +14,14 @@
14
14
  *
15
15
  * During SSR, reads the request search params from the SSR ALS context
16
16
  * (populated by ssr-entry.ts) instead of window.location.
17
+ *
18
+ * All mutable state is delegated to client/state.ts for singleton guarantees.
19
+ * See design/18-build-system.md §"Singleton State Registry"
17
20
  */
18
21
 
19
22
  import { useSyncExternalStore } from 'react';
20
23
  import { getSsrData } from './ssr-data.js';
24
+ import { cachedSearch, cachedSearchParams, _setCachedSearch } from './state.js';
21
25
 
22
26
  function getSearch(): string {
23
27
  if (typeof window !== 'undefined') return window.location.search;
@@ -43,16 +47,16 @@ function subscribe(callback: () => void): () => void {
43
47
 
44
48
  // Cache the last search string and its parsed URLSearchParams to avoid
45
49
  // creating a new object on every render when the URL hasn't changed.
46
- let cachedSearch = '';
47
- let cachedParams = new URLSearchParams();
50
+ // State lives in client/state.ts for singleton guarantees.
48
51
 
49
52
  function getSearchParams(): URLSearchParams {
50
53
  const search = getSearch();
51
54
  if (search !== cachedSearch) {
52
- cachedSearch = search;
53
- cachedParams = new URLSearchParams(search);
55
+ const params = new URLSearchParams(search);
56
+ _setCachedSearch(search, params);
57
+ return params;
54
58
  }
55
- return cachedParams;
59
+ return cachedSearchParams;
56
60
  }
57
61
 
58
62
  function getServerSearchParams(): URLSearchParams {
@@ -92,8 +92,10 @@ export function timberShims(_ctx: PluginContext): Plugin {
92
92
  * instance as framework internals (which import via #/). This ensures
93
93
  * a single requestContextAls and _getRscFallback variable.
94
94
  *
95
- * @timber-js/app/client is NOT mapped here it resolves to dist/ via
96
- * package.json exports, where 'use client' is preserved on the entry.
95
+ * @timber-js/app/client is resolved to src/ in the SSR environment so
96
+ * client hooks share the same module instance as ssr-entry.ts internals.
97
+ * In RSC it resolves to dist/ (via package.json exports) where 'use client'
98
+ * is preserved on the entry for client boundary detection.
97
99
  */
98
100
  resolveId(id: string) {
99
101
  // Poison pill packages — resolve to virtual modules handled by load()
@@ -127,6 +129,28 @@ export function timberShims(_ctx: PluginContext): Plugin {
127
129
  return resolve(PKG_ROOT, 'src', 'server', 'index.ts');
128
130
  }
129
131
 
132
+ // @timber-js/app/client → src/ in the SSR environment so client hooks
133
+ // (useParams, usePathname, etc.) share the same module instance as
134
+ // ssr-entry.ts's internal imports (via #/client/...).
135
+ //
136
+ // Without this remap, @timber-js/app/client resolves to dist/ (via
137
+ // package.json exports), creating a module instance split: ssr-entry.ts
138
+ // registers the ALS-backed SSR data provider on the src/ instance of
139
+ // ssr-data.ts, but client component hooks read getSsrData() from the
140
+ // dist/ instance — which has no provider. Result: hooks like useParams()
141
+ // return empty defaults during SSR.
142
+ //
143
+ // This remap is SSR-only. The RSC environment still resolves to dist/
144
+ // where 'use client' is preserved on the entry (needed for client
145
+ // boundary detection). The client (browser) environment uses dist/
146
+ // for bundling.
147
+ if (cleanId === '@timber-js/app/client') {
148
+ const envName = (this as unknown as { environment?: { name?: string } }).environment?.name;
149
+ if (envName === 'ssr') {
150
+ return resolve(PKG_ROOT, 'src', 'client', 'index.ts');
151
+ }
152
+ }
153
+
130
154
  return null;
131
155
  },
132
156
 
@@ -19,7 +19,10 @@ import type {
19
19
  InterceptionMarker,
20
20
  } from './types.js';
21
21
  import { DEFAULT_PAGE_EXTENSIONS, INTERCEPTION_MARKERS } from './types.js';
22
- import { classifyMetadataRoute } from '#/server/metadata-routes.js';
22
+ import {
23
+ classifyMetadataRoute,
24
+ isDynamicMetadataExtension,
25
+ } from '#/server/metadata-routes.js';
23
26
 
24
27
  /**
25
28
  * Pattern matching encoded path delimiters that must be rejected during route discovery.
@@ -317,13 +320,26 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
317
320
  }
318
321
 
319
322
  // Metadata route files (sitemap.ts, robots.ts, icon.tsx, opengraph-image.tsx, etc.)
323
+ // Both static (.xml, .txt, .png, .ico, etc.) and dynamic (.ts, .tsx) files are recognized.
324
+ // When both exist for the same base name, dynamic takes precedence.
320
325
  // See design/16-metadata.md §"Metadata Routes"
321
326
  const metaInfo = classifyMetadataRoute(entry);
322
327
  if (metaInfo) {
323
328
  if (!node.metadataRoutes) {
324
329
  node.metadataRoutes = new Map();
325
330
  }
326
- node.metadataRoutes.set(name, { filePath: fullPath, extension: ext });
331
+ const existing = node.metadataRoutes.get(name);
332
+ if (existing) {
333
+ // Dynamic > static precedence: only overwrite if the new file is dynamic
334
+ // or the existing file is static (dynamic always wins).
335
+ const existingIsDynamic = isDynamicMetadataExtension(name, existing.extension);
336
+ const newIsDynamic = isDynamicMetadataExtension(name, ext);
337
+ if (newIsDynamic || !existingIsDynamic) {
338
+ node.metadataRoutes.set(name, { filePath: fullPath, extension: ext });
339
+ }
340
+ } else {
341
+ node.metadataRoutes.set(name, { filePath: fullPath, extension: ext });
342
+ }
327
343
  }
328
344
  }
329
345
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Browser Runtime Adapter — Re-exports from @vitejs/plugin-rsc/browser.
3
+ *
4
+ * This module insulates the rest of the framework from direct imports of
5
+ * @vitejs/plugin-rsc. The plugin is pre-1.0 and its API surface will change.
6
+ * By routing all browser-environment imports through this single file, a
7
+ * breaking upstream change only requires updating one place.
8
+ *
9
+ * Keep this as thin pass-through re-exports — the value is the single choke
10
+ * point, not abstraction.
11
+ */
12
+
13
+ export {
14
+ createFromReadableStream,
15
+ createFromFetch,
16
+ setServerCallback,
17
+ encodeReply,
18
+ } from '@vitejs/plugin-rsc/browser';
@@ -0,0 +1,19 @@
1
+ /**
2
+ * RSC Runtime Adapter — Re-exports from @vitejs/plugin-rsc/rsc.
3
+ *
4
+ * This module insulates the rest of the framework from direct imports of
5
+ * @vitejs/plugin-rsc. The plugin is pre-1.0 and its API surface will change.
6
+ * By routing all RSC-environment imports through this single file, a breaking
7
+ * upstream change only requires updating one place instead of every file that
8
+ * touches the RSC runtime.
9
+ *
10
+ * Keep this as thin pass-through re-exports — the value is the single choke
11
+ * point, not abstraction.
12
+ */
13
+
14
+ export {
15
+ renderToReadableStream,
16
+ loadServerAction,
17
+ decodeReply,
18
+ decodeAction,
19
+ } from '@vitejs/plugin-rsc/rsc';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * SSR Runtime Adapter — Re-exports from @vitejs/plugin-rsc/ssr.
3
+ *
4
+ * This module insulates the rest of the framework from direct imports of
5
+ * @vitejs/plugin-rsc. The plugin is pre-1.0 and its API surface will change.
6
+ * By routing all SSR-environment imports through this single file, a breaking
7
+ * upstream change only requires updating one place.
8
+ *
9
+ * Keep this as thin pass-through re-exports — the value is the single choke
10
+ * point, not abstraction.
11
+ */
12
+
13
+ export { createFromReadableStream } from '@vitejs/plugin-rsc/ssr';