@timber-js/app 0.1.21 → 0.1.23

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 +35 -25
  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 +3 -0
  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 +25 -20
  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 +10 -13
  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,6 +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 { flushSync } from 'react-dom';
8
9
  import { setCurrentParams, notifyParamsListeners } from './use-params.js';
9
10
 
10
11
  // ─── Types ───────────────────────────────────────────────────────
@@ -393,18 +394,18 @@ export function createRouter(deps: RouterDeps): RouterInstance {
393
394
  // header reflects the currently mounted segments.
394
395
  updateSegmentCache(result.segmentInfo);
395
396
 
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
+ // Update params, render the new tree, and notify params subscribers
398
+ // in a single synchronous flush. Without flushSync, renderPayload()
399
+ // (reactRoot.render) and notifyParamsListeners() are separate update
400
+ // mechanisms that React may commit in different frames — causing a
401
+ // flash where both the old and new active rows show simultaneously
402
+ // in preserved layouts (the new tree commits before the external
403
+ // store re-render deactivates the old row).
400
404
  updateParams(result.params);
401
-
402
- // Render the decoded RSC tree into the DOM.
403
- renderPayload(result.payload);
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();
405
+ flushSync(() => {
406
+ renderPayload(result.payload);
407
+ notifyParamsListeners();
408
+ });
408
409
 
409
410
  // Update document.title and <meta> tags with the new page's metadata
410
411
  applyHead(result.headElements);
@@ -464,12 +465,12 @@ export function createRouter(deps: RouterDeps): RouterInstance {
464
465
  // Update segment cache with fresh segment info from full render
465
466
  updateSegmentCache(result.segmentInfo);
466
467
 
467
- // Update params snapshot before rendering (see navigate() for rationale)
468
+ // Atomic update see navigate() for rationale on flushSync.
468
469
  updateParams(result.params);
469
-
470
- // Render the fresh RSC tree, then notify params subscribers
471
- renderPayload(result.payload);
472
- notifyParamsListeners();
470
+ flushSync(() => {
471
+ renderPayload(result.payload);
472
+ notifyParamsListeners();
473
+ });
473
474
  applyHead(result.headElements);
474
475
  } finally {
475
476
  setPending(false);
@@ -485,8 +486,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
485
486
  if (entry && entry.payload !== null) {
486
487
  // Replay cached payload — no server roundtrip
487
488
  updateParams(entry.params);
488
- renderPayload(entry.payload);
489
- notifyParamsListeners();
489
+ flushSync(() => {
490
+ renderPayload(entry.payload);
491
+ notifyParamsListeners();
492
+ });
490
493
  applyHead(entry.headElements);
491
494
  afterPaint(() => {
492
495
  deps.scrollTo(0, scrollY);
@@ -508,8 +511,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
508
511
  headElements: result.headElements,
509
512
  params: result.params,
510
513
  });
511
- renderPayload(result.payload);
512
- notifyParamsListeners();
514
+ flushSync(() => {
515
+ renderPayload(result.payload);
516
+ notifyParamsListeners();
517
+ });
513
518
  applyHead(result.headElements);
514
519
  afterPaint(() => {
515
520
  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
  /**
@@ -87,7 +84,7 @@ function getServerSnapshot(): Record<string, string | string[]> {
87
84
  * module-level fallback path.
88
85
  */
89
86
  export function setCurrentParams(params: Record<string, string | string[]>): void {
90
- currentParams = params;
87
+ _setCurrentParams(params);
91
88
  }
92
89
 
93
90
  /**
@@ -98,7 +95,7 @@ export function setCurrentParams(params: Record<string, string | string[]>): voi
98
95
  * {old tree, new params} intermediate state.
99
96
  */
100
97
  export function notifyParamsListeners(): void {
101
- for (const listener of listeners) {
98
+ for (const listener of paramsListeners) {
102
99
  listener();
103
100
  }
104
101
  }
@@ -125,7 +122,7 @@ export function useParams<R extends keyof Routes>(route: R): Routes[R]['params']
125
122
  export function useParams(route?: string): Record<string, string | string[]>;
126
123
  export function useParams(_route?: string): Record<string, string | string[]> {
127
124
  // useSyncExternalStore handles both client and SSR:
128
- // - Client: calls getSnapshot() → reads module-level currentParams
125
+ // - Client: calls getSnapshot() → reads currentParams from state.ts
129
126
  // - SSR: calls getServerSnapshot() → reads from ALS-backed getSsrData()
130
127
  //
131
128
  // We must always call the hook (Rules of Hooks — no conditional hook calls).
@@ -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';