@timber-js/app 0.2.0-alpha.134 → 0.2.0-alpha.135

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 (147) hide show
  1. package/dist/_chunks/{actions-B8Fhsosm.js → actions-CHFblsdT.js} +13 -9
  2. package/dist/_chunks/actions-CHFblsdT.js.map +1 -0
  3. package/dist/_chunks/{build-output-helper-K7oUCRqU.js → build-output-helper-CfBfOU9m.js} +1 -1
  4. package/dist/_chunks/{build-output-helper-K7oUCRqU.js.map → build-output-helper-CfBfOU9m.js.map} +1 -1
  5. package/dist/_chunks/{codec-registry-DqkeAydt.js → codec-registry-G9hD5LOq.js} +1 -1
  6. package/dist/_chunks/{codec-registry-DqkeAydt.js.map → codec-registry-G9hD5LOq.js.map} +1 -1
  7. package/dist/_chunks/{define-jeHDXZGq.js → define-DXWUJb-K.js} +3 -3
  8. package/dist/_chunks/{define-jeHDXZGq.js.map → define-DXWUJb-K.js.map} +1 -1
  9. package/dist/_chunks/{define-DMq-Yjp7.js → define-Dp-qu3ql.js} +3 -3
  10. package/dist/_chunks/{define-DMq-Yjp7.js.map → define-Dp-qu3ql.js.map} +1 -1
  11. package/dist/_chunks/fast-hash-DXZ7S2D5.js +36 -0
  12. package/dist/_chunks/fast-hash-DXZ7S2D5.js.map +1 -0
  13. package/dist/_chunks/{format-BjN9HLu9.js → format-CR6v1pWE.js} +1 -1
  14. package/dist/_chunks/{format-BjN9HLu9.js.map → format-CR6v1pWE.js.map} +1 -1
  15. package/dist/_chunks/{handler-store-bIAstrxQ.js → handler-store-aQMpqPe7.js} +1 -1
  16. package/dist/_chunks/{handler-store-bIAstrxQ.js.map → handler-store-aQMpqPe7.js.map} +1 -1
  17. package/dist/_chunks/{href-validation-BJmD5mZh.js → href-validation-2G-HZaQ8.js} +8 -8
  18. package/dist/_chunks/href-validation-2G-HZaQ8.js.map +1 -0
  19. package/dist/_chunks/{navigation-root-B5-RmsPi.js → navigation-root-BQqFQI-n.js} +3 -3
  20. package/dist/_chunks/{navigation-root-B5-RmsPi.js.map → navigation-root-BQqFQI-n.js.map} +1 -1
  21. package/dist/_chunks/{registry-BI9cgCt7.js → registry-Dp_hJL4s.js} +1 -1
  22. package/dist/_chunks/{registry-BI9cgCt7.js.map → registry-Dp_hJL4s.js.map} +1 -1
  23. package/dist/_chunks/{resolve-schema-DU2nbVOi.js → resolve-schema-D3CbcdCW.js} +2 -2
  24. package/dist/_chunks/{resolve-schema-DU2nbVOi.js.map → resolve-schema-D3CbcdCW.js.map} +1 -1
  25. package/dist/_chunks/{router-ref-B2-2kIIC.js → router-ref-B50j42zB.js} +2 -2
  26. package/dist/_chunks/{router-ref-B2-2kIIC.js.map → router-ref-B50j42zB.js.map} +1 -1
  27. package/dist/_chunks/{schema-bridge-o0SwFtXW.js → schema-bridge-Cuy8BaYu.js} +2 -2
  28. package/dist/_chunks/{schema-bridge-o0SwFtXW.js.map → schema-bridge-Cuy8BaYu.js.map} +1 -1
  29. package/dist/_chunks/{segment-context-Bg0pFbLT.js → segment-context-BnxoPSFQ.js} +1 -1
  30. package/dist/_chunks/{segment-context-Bg0pFbLT.js.map → segment-context-BnxoPSFQ.js.map} +1 -1
  31. package/dist/_chunks/{ssr-data-DVX8S7vj.js → ssr-data-DxXyoRrz.js} +1 -1
  32. package/dist/_chunks/{ssr-data-DVX8S7vj.js.map → ssr-data-DxXyoRrz.js.map} +1 -1
  33. package/dist/_chunks/{stale-reload-CCqb62Z3.js → stale-reload-DIIIVGh_.js} +1 -1
  34. package/dist/_chunks/{stale-reload-CCqb62Z3.js.map → stale-reload-DIIIVGh_.js.map} +1 -1
  35. package/dist/_chunks/{use-query-states-C9G6LP0F.js → use-query-states-D9SBlAfp.js} +2 -2
  36. package/dist/_chunks/{use-query-states-C9G6LP0F.js.map → use-query-states-D9SBlAfp.js.map} +1 -1
  37. package/dist/_chunks/{use-segment-params-CIeinIx2.js → use-segment-params-x2poKmk8.js} +2 -2
  38. package/dist/_chunks/use-segment-params-x2poKmk8.js.map +1 -0
  39. package/dist/_chunks/{validation-AfEFM5ze.js → validation-BoWM_qI1.js} +31 -2
  40. package/dist/_chunks/validation-BoWM_qI1.js.map +1 -0
  41. package/dist/_chunks/walkers-BbEu2_Pp.js.map +1 -1
  42. package/dist/adapters/cloudflare.js +1 -1
  43. package/dist/adapters/nitro.js +1 -1
  44. package/dist/cache/cache-api.d.ts +1 -1
  45. package/dist/cache/cache-api.d.ts.map +1 -1
  46. package/dist/cache/index.js +17 -39
  47. package/dist/cache/index.js.map +1 -1
  48. package/dist/cache/timber-cache.d.ts +1 -1
  49. package/dist/cache/timber-cache.d.ts.map +1 -1
  50. package/dist/client/error-boundary.js +2 -2
  51. package/dist/client/index.d.ts.map +1 -1
  52. package/dist/client/index.js +10 -10
  53. package/dist/client/index.js.map +1 -1
  54. package/dist/client/internal.js +8 -8
  55. package/dist/client/internal.js.map +1 -1
  56. package/dist/client/link.d.ts.map +1 -1
  57. package/dist/client/navigation-context.d.ts.map +1 -1
  58. package/dist/client/use-pathname.d.ts.map +1 -1
  59. package/dist/client/use-search-params.d.ts.map +1 -1
  60. package/dist/codec.js +1 -1
  61. package/dist/cookies/index.js +3 -3
  62. package/dist/cookies/validation.d.ts +11 -23
  63. package/dist/cookies/validation.d.ts.map +1 -1
  64. package/dist/dev-tools/error-page.d.ts.map +1 -1
  65. package/dist/fonts/virtual-modules.d.ts.map +1 -1
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +106 -3
  68. package/dist/index.js.map +1 -1
  69. package/dist/params/define-schema.d.ts.map +1 -1
  70. package/dist/params/index.js +2 -2
  71. package/dist/params/index.js.map +1 -1
  72. package/dist/plugins/cache.d.ts +31 -0
  73. package/dist/plugins/cache.d.ts.map +1 -0
  74. package/dist/routing/link-codegen.d.ts.map +1 -1
  75. package/dist/schema-bridge.d.ts.map +1 -1
  76. package/dist/search-params/index.js +3 -3
  77. package/dist/segment-params/index.js +1 -1
  78. package/dist/server/actions.d.ts.map +1 -1
  79. package/dist/server/body-limits.d.ts.map +1 -1
  80. package/dist/server/cookie-context.d.ts +2 -2
  81. package/dist/server/cookie-context.d.ts.map +1 -1
  82. package/dist/server/csrf.d.ts +3 -2
  83. package/dist/server/csrf.d.ts.map +1 -1
  84. package/dist/server/html-injector-core.d.ts.map +1 -1
  85. package/dist/server/html-injectors.d.ts.map +1 -1
  86. package/dist/server/index.js +17 -8
  87. package/dist/server/index.js.map +1 -1
  88. package/dist/server/internal.js +50 -13
  89. package/dist/server/internal.js.map +1 -1
  90. package/dist/server/pipeline.d.ts.map +1 -1
  91. package/dist/server/primitives.d.ts.map +1 -1
  92. package/dist/server/route-element-builder.d.ts.map +1 -1
  93. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  94. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  95. package/dist/server/rsc-entry/revalidate-renderer.d.ts +108 -0
  96. package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -0
  97. package/dist/server/rsc-entry/ssr-renderer.d.ts +15 -0
  98. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  99. package/dist/server/sitemap-handler.d.ts.map +1 -1
  100. package/dist/server/ssr-wrappers.d.ts.map +1 -1
  101. package/dist/server/tree-builder.d.ts.map +1 -1
  102. package/dist/shared/href-validation.d.ts.map +1 -1
  103. package/package.json +7 -6
  104. package/src/cache/cache-api.ts +3 -2
  105. package/src/cache/timber-cache.ts +27 -4
  106. package/src/cli.ts +0 -0
  107. package/src/client/index.ts +1 -0
  108. package/src/client/link.tsx +8 -5
  109. package/src/client/navigation-context.ts +2 -0
  110. package/src/client/use-pathname.ts +1 -0
  111. package/src/client/use-search-params.ts +1 -0
  112. package/src/client/use-segment-params.ts +1 -0
  113. package/src/config-validation.ts +1 -1
  114. package/src/cookies/validation.ts +58 -0
  115. package/src/dev-tools/error-page.ts +1 -0
  116. package/src/fonts/virtual-modules.ts +2 -0
  117. package/src/index.ts +5 -1
  118. package/src/params/define-schema.ts +1 -0
  119. package/src/plugins/cache.ts +180 -0
  120. package/src/routing/link-codegen.ts +5 -3
  121. package/src/schema-bridge.ts +3 -2
  122. package/src/server/action-client.ts +31 -13
  123. package/src/server/actions.ts +20 -3
  124. package/src/server/body-limits.ts +10 -2
  125. package/src/server/cookie-context.ts +7 -2
  126. package/src/server/cookie-parsing.ts +1 -1
  127. package/src/server/csrf.ts +39 -6
  128. package/src/server/html-injector-core.ts +6 -5
  129. package/src/server/html-injectors.ts +8 -5
  130. package/src/server/pipeline.ts +19 -10
  131. package/src/server/primitives.ts +11 -1
  132. package/src/server/route-element-builder.ts +2 -0
  133. package/src/server/rsc-entry/error-renderer.ts +4 -3
  134. package/src/server/rsc-entry/index.ts +16 -36
  135. package/src/server/rsc-entry/revalidate-renderer.ts +344 -0
  136. package/src/server/rsc-entry/ssr-renderer.ts +76 -0
  137. package/src/server/sitemap-handler.ts +24 -2
  138. package/src/server/slot-resolver.ts +8 -2
  139. package/src/server/ssr-wrappers.tsx +2 -0
  140. package/src/server/tree-builder.ts +2 -0
  141. package/src/server/types.ts +2 -2
  142. package/src/shared/href-validation.ts +12 -8
  143. package/LICENSE +0 -8
  144. package/dist/_chunks/actions-B8Fhsosm.js.map +0 -1
  145. package/dist/_chunks/href-validation-BJmD5mZh.js.map +0 -1
  146. package/dist/_chunks/use-segment-params-CIeinIx2.js.map +0 -1
  147. package/dist/_chunks/validation-AfEFM5ze.js.map +0 -1
@@ -0,0 +1,36 @@
1
+ //#region src/cache/fast-hash.ts
2
+ /**
3
+ * Fast non-cryptographic hash for cache keys.
4
+ *
5
+ * FNV-1a 64-bit produces a well-distributed hash with a collision
6
+ * probability of ~1 in 5 billion at 77k keys (birthday paradox).
7
+ * Not suitable for security, but ideal for cache key generation
8
+ * where we need speed over crypto strength.
9
+ *
10
+ * Uses BigInt for 64-bit arithmetic — supported in all modern runtimes
11
+ * including Cloudflare Workers. No node:crypto dependency.
12
+ *
13
+ * See TIM-370.
14
+ */
15
+ var FNV_OFFSET_BASIS = 14695981039346656037n;
16
+ var FNV_PRIME = 1099511628211n;
17
+ var MASK_64 = 18446744073709551615n;
18
+ /**
19
+ * Compute a 64-bit FNV-1a hash of a string, returned as a 16-char hex string.
20
+ *
21
+ * 64 bits gives ~5 billion keys before a 50% collision probability
22
+ * (birthday paradox), making accidental collisions effectively impossible
23
+ * for cache key use cases.
24
+ */
25
+ function fnv1aHash(input) {
26
+ let hash = FNV_OFFSET_BASIS;
27
+ for (let i = 0; i < input.length; i++) {
28
+ hash ^= BigInt(input.charCodeAt(i));
29
+ hash = hash * FNV_PRIME & MASK_64;
30
+ }
31
+ return hash.toString(16).padStart(16, "0");
32
+ }
33
+ //#endregion
34
+ export { fnv1aHash as t };
35
+
36
+ //# sourceMappingURL=fast-hash-DXZ7S2D5.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fast-hash-DXZ7S2D5.js","names":[],"sources":["../../src/cache/fast-hash.ts"],"sourcesContent":["/**\n * Fast non-cryptographic hash for cache keys.\n *\n * FNV-1a 64-bit produces a well-distributed hash with a collision\n * probability of ~1 in 5 billion at 77k keys (birthday paradox).\n * Not suitable for security, but ideal for cache key generation\n * where we need speed over crypto strength.\n *\n * Uses BigInt for 64-bit arithmetic — supported in all modern runtimes\n * including Cloudflare Workers. No node:crypto dependency.\n *\n * See TIM-370.\n */\n\n// FNV-1a constants for 64-bit hash\nconst FNV_OFFSET_BASIS = 0xcbf29ce484222325n;\nconst FNV_PRIME = 0x100000001b3n;\nconst MASK_64 = 0xffffffffffffffffn;\n\n/**\n * Compute a 64-bit FNV-1a hash of a string, returned as a 16-char hex string.\n *\n * 64 bits gives ~5 billion keys before a 50% collision probability\n * (birthday paradox), making accidental collisions effectively impossible\n * for cache key use cases.\n */\nexport function fnv1aHash(input: string): string {\n let hash = FNV_OFFSET_BASIS;\n for (let i = 0; i < input.length; i++) {\n hash ^= BigInt(input.charCodeAt(i));\n hash = (hash * FNV_PRIME) & MASK_64;\n }\n return hash.toString(16).padStart(16, '0');\n}\n"],"mappings":";;;;;;;;;;;;;;AAeA,IAAM,mBAAmB;AACzB,IAAM,YAAY;AAClB,IAAM,UAAU;;;;;;;;AAShB,SAAgB,UAAU,OAAuB;CAC/C,IAAI,OAAO;CACX,KAAK,IAAI,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;EACrC,QAAQ,OAAO,MAAM,WAAW,EAAE,CAAC;EACnC,OAAQ,OAAO,YAAa;;CAE9B,OAAO,KAAK,SAAS,GAAG,CAAC,SAAS,IAAI,IAAI"}
@@ -11,4 +11,4 @@ function formatSize(bytes) {
11
11
  //#endregion
12
12
  export { formatSize as t };
13
13
 
14
- //# sourceMappingURL=format-BjN9HLu9.js.map
14
+ //# sourceMappingURL=format-CR6v1pWE.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"format-BjN9HLu9.js","names":[],"sources":["../../src/utils/format.ts"],"sourcesContent":["/**\n * Shared formatting utilities.\n */\n\n/** Format a byte count as a human-readable string (e.g. \"1.50 kB\"). */\nexport function formatSize(bytes: number): string {\n if (bytes < 1024) return `${bytes} B`;\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} kB`;\n return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;\n}\n"],"mappings":";;;;;AAKA,SAAgB,WAAW,OAAuB;CAChD,IAAI,QAAQ,MAAM,OAAO,GAAG,MAAM;CAClC,IAAI,QAAQ,OAAO,MAAM,OAAO,IAAI,QAAQ,MAAM,QAAQ,EAAE,CAAC;CAC7D,OAAO,IAAI,SAAS,OAAO,OAAO,QAAQ,EAAE,CAAC"}
1
+ {"version":3,"file":"format-CR6v1pWE.js","names":[],"sources":["../../src/utils/format.ts"],"sourcesContent":["/**\n * Shared formatting utilities.\n */\n\n/** Format a byte count as a human-readable string (e.g. \"1.50 kB\"). */\nexport function formatSize(bytes: number): string {\n if (bytes < 1024) return `${bytes} B`;\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} kB`;\n return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;\n}\n"],"mappings":";;;;;AAKA,SAAgB,WAAW,OAAuB;CAChD,IAAI,QAAQ,MAAM,OAAO,GAAG,MAAM;CAClC,IAAI,QAAQ,OAAO,MAAM,OAAO,IAAI,QAAQ,MAAM,QAAQ,EAAE,CAAC;CAC7D,OAAO,IAAI,SAAS,OAAO,OAAO,QAAQ,EAAE,CAAC"}
@@ -51,4 +51,4 @@ function createDefaultHandler() {
51
51
  //#endregion
52
52
  export { setCacheHandler as n, getCacheHandler as t };
53
53
 
54
- //# sourceMappingURL=handler-store-bIAstrxQ.js.map
54
+ //# sourceMappingURL=handler-store-aQMpqPe7.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"handler-store-bIAstrxQ.js","names":[],"sources":["../../src/cache/handler-store.ts"],"sourcesContent":["/**\n * Module-level cache handler singleton.\n *\n * Lazily initialized to MemoryCacheHandler on first access. The framework\n * replaces this at boot from timber.config.ts via setCacheHandler().\n *\n * This module avoids importing from ./index to prevent circular dependencies.\n */\n\n// Inline the interface to avoid circular import with index.ts\ninterface CacheHandlerLike {\n get(key: string): Promise<{ value: unknown; stale: boolean } | null>;\n set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void>;\n invalidate(opts: { key?: string; tag?: string }): Promise<void>;\n}\n\nlet handler: CacheHandlerLike | null = null;\n\n/** Replace the active cache handler. Called by the framework at boot. */\nexport function setCacheHandler(h: CacheHandlerLike): void {\n handler = h;\n}\n\n/**\n * Get the active cache handler. Creates a default MemoryCacheHandler on\n * first access if none has been set via setCacheHandler().\n */\nexport function getCacheHandler(): CacheHandlerLike {\n if (!handler) {\n // Inline a minimal LRU cache to avoid circular dep with index.ts.\n // In production, the framework always calls setCacheHandler() at boot.\n handler = createDefaultHandler();\n }\n return handler;\n}\n\nfunction createDefaultHandler(): CacheHandlerLike {\n const store = new Map<string, { value: unknown; expiresAt: number; tags: string[] }>();\n const maxEntries = 1000;\n\n return {\n async get(key) {\n const entry = store.get(key);\n if (!entry) return null;\n store.delete(key);\n store.set(key, entry);\n const stale = Date.now() > entry.expiresAt;\n return { value: entry.value, stale };\n },\n async set(key, value, opts) {\n if (store.has(key)) store.delete(key);\n while (store.size >= maxEntries) {\n const oldest = store.keys().next().value;\n if (oldest !== undefined) store.delete(oldest);\n else break;\n }\n store.set(key, { value, expiresAt: Date.now() + opts.ttl * 1000, tags: opts.tags });\n },\n async invalidate(opts) {\n if (opts.key) store.delete(opts.key);\n if (opts.tag) {\n for (const [key, entry] of store) {\n if (entry.tags.includes(opts.tag)) store.delete(key);\n }\n }\n },\n };\n}\n"],"mappings":";AAgBA,IAAI,UAAmC;;AAGvC,SAAgB,gBAAgB,GAA2B;CACzD,UAAU;;;;;;AAOZ,SAAgB,kBAAoC;CAClD,IAAI,CAAC,SAGH,UAAU,sBAAsB;CAElC,OAAO;;AAGT,SAAS,uBAAyC;CAChD,MAAM,wBAAQ,IAAI,KAAoE;CACtF,MAAM,aAAa;CAEnB,OAAO;EACL,MAAM,IAAI,KAAK;GACb,MAAM,QAAQ,MAAM,IAAI,IAAI;GAC5B,IAAI,CAAC,OAAO,OAAO;GACnB,MAAM,OAAO,IAAI;GACjB,MAAM,IAAI,KAAK,MAAM;GACrB,MAAM,QAAQ,KAAK,KAAK,GAAG,MAAM;GACjC,OAAO;IAAE,OAAO,MAAM;IAAO;IAAO;;EAEtC,MAAM,IAAI,KAAK,OAAO,MAAM;GAC1B,IAAI,MAAM,IAAI,IAAI,EAAE,MAAM,OAAO,IAAI;GACrC,OAAO,MAAM,QAAQ,YAAY;IAC/B,MAAM,SAAS,MAAM,MAAM,CAAC,MAAM,CAAC;IACnC,IAAI,WAAW,KAAA,GAAW,MAAM,OAAO,OAAO;SACzC;;GAEP,MAAM,IAAI,KAAK;IAAE;IAAO,WAAW,KAAK,KAAK,GAAG,KAAK,MAAM;IAAM,MAAM,KAAK;IAAM,CAAC;;EAErF,MAAM,WAAW,MAAM;GACrB,IAAI,KAAK,KAAK,MAAM,OAAO,KAAK,IAAI;GACpC,IAAI,KAAK;SACF,MAAM,CAAC,KAAK,UAAU,OACzB,IAAI,MAAM,KAAK,SAAS,KAAK,IAAI,EAAE,MAAM,OAAO,IAAI;;;EAI3D"}
1
+ {"version":3,"file":"handler-store-aQMpqPe7.js","names":[],"sources":["../../src/cache/handler-store.ts"],"sourcesContent":["/**\n * Module-level cache handler singleton.\n *\n * Lazily initialized to MemoryCacheHandler on first access. The framework\n * replaces this at boot from timber.config.ts via setCacheHandler().\n *\n * This module avoids importing from ./index to prevent circular dependencies.\n */\n\n// Inline the interface to avoid circular import with index.ts\ninterface CacheHandlerLike {\n get(key: string): Promise<{ value: unknown; stale: boolean } | null>;\n set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void>;\n invalidate(opts: { key?: string; tag?: string }): Promise<void>;\n}\n\nlet handler: CacheHandlerLike | null = null;\n\n/** Replace the active cache handler. Called by the framework at boot. */\nexport function setCacheHandler(h: CacheHandlerLike): void {\n handler = h;\n}\n\n/**\n * Get the active cache handler. Creates a default MemoryCacheHandler on\n * first access if none has been set via setCacheHandler().\n */\nexport function getCacheHandler(): CacheHandlerLike {\n if (!handler) {\n // Inline a minimal LRU cache to avoid circular dep with index.ts.\n // In production, the framework always calls setCacheHandler() at boot.\n handler = createDefaultHandler();\n }\n return handler;\n}\n\nfunction createDefaultHandler(): CacheHandlerLike {\n const store = new Map<string, { value: unknown; expiresAt: number; tags: string[] }>();\n const maxEntries = 1000;\n\n return {\n async get(key) {\n const entry = store.get(key);\n if (!entry) return null;\n store.delete(key);\n store.set(key, entry);\n const stale = Date.now() > entry.expiresAt;\n return { value: entry.value, stale };\n },\n async set(key, value, opts) {\n if (store.has(key)) store.delete(key);\n while (store.size >= maxEntries) {\n const oldest = store.keys().next().value;\n if (oldest !== undefined) store.delete(oldest);\n else break;\n }\n store.set(key, { value, expiresAt: Date.now() + opts.ttl * 1000, tags: opts.tags });\n },\n async invalidate(opts) {\n if (opts.key) store.delete(opts.key);\n if (opts.tag) {\n for (const [key, entry] of store) {\n if (entry.tags.includes(opts.tag)) store.delete(key);\n }\n }\n },\n };\n}\n"],"mappings":";AAgBA,IAAI,UAAmC;;AAGvC,SAAgB,gBAAgB,GAA2B;CACzD,UAAU;;;;;;AAOZ,SAAgB,kBAAoC;CAClD,IAAI,CAAC,SAGH,UAAU,sBAAsB;CAElC,OAAO;;AAGT,SAAS,uBAAyC;CAChD,MAAM,wBAAQ,IAAI,KAAoE;CACtF,MAAM,aAAa;CAEnB,OAAO;EACL,MAAM,IAAI,KAAK;GACb,MAAM,QAAQ,MAAM,IAAI,IAAI;GAC5B,IAAI,CAAC,OAAO,OAAO;GACnB,MAAM,OAAO,IAAI;GACjB,MAAM,IAAI,KAAK,MAAM;GACrB,MAAM,QAAQ,KAAK,KAAK,GAAG,MAAM;GACjC,OAAO;IAAE,OAAO,MAAM;IAAO;IAAO;;EAEtC,MAAM,IAAI,KAAK,OAAO,MAAM;GAC1B,IAAI,MAAM,IAAI,IAAI,EAAE,MAAM,OAAO,IAAI;GACrC,OAAO,MAAM,QAAQ,YAAY;IAC/B,MAAM,SAAS,MAAM,MAAM,CAAC,MAAM,CAAC;IACnC,IAAI,WAAW,KAAA,GAAW,MAAM,OAAO,OAAO;SACzC;;GAEP,MAAM,IAAI,KAAK;IAAE;IAAO,WAAW,KAAK,KAAK,GAAG,KAAK,MAAM;IAAM,MAAM,KAAK;IAAM,CAAC;;EAErF,MAAM,WAAW,MAAM;GACrB,IAAI,KAAK,KAAK,MAAM,OAAO,KAAK,IAAI;GACpC,IAAI,KAAK;SACF,MAAM,CAAC,KAAK,UAAU,OACzB,IAAI,MAAM,KAAK,SAAS,KAAK,IAAI,EAAE,MAAM,OAAO,IAAI;;;EAI3D"}
@@ -48,12 +48,12 @@ function mergePreservedSearchParams(targetHref, currentSearch, preserve) {
48
48
  * and design/07-routing.md §"<Link> Scheme Validation".
49
49
  */
50
50
  /**
51
- * ASCII control characters that browsers strip during URL preprocessing.
52
- * A naive prefix regex like /^javascript:/i can be bypassed with
53
- * "java\tscript:alert(1)" because the browser removes the tab before
54
- * interpreting the scheme. We strip these before checking the scheme.
51
+ * All C0 control characters (U+0000–U+001F) that browsers strip during
52
+ * URL preprocessing. Browsers and `new URL()` strip the full C0 range,
53
+ * not just tab/LF/CR — so we must match that behavior to prevent
54
+ * bypass vectors like `\x00javascript:alert(1)`.
55
55
  */
56
- var CONTROL_CHARS_RE = /[\t\n\r]/g;
56
+ var CONTROL_CHARS_RE = /[\x00-\x1f]/g;
57
57
  var DANGEROUS_SCHEMES = new Set([
58
58
  "javascript:",
59
59
  "data:",
@@ -78,7 +78,7 @@ function extractScheme(href) {
78
78
  * Throws on rejection. Safe hrefs pass through silently.
79
79
  */
80
80
  function validateNavigationHref(href) {
81
- if (href.replace(CONTROL_CHARS_RE, "").trimStart().startsWith("//")) throw new Error(`Navigation received a protocol-relative URL: "${href}". Use an absolute URL with an explicit scheme or a relative path.`);
81
+ if (href.replace(CONTROL_CHARS_RE, "").trimStart().replaceAll("\\", "/").startsWith("//")) throw new Error(`Navigation received a protocol-relative or backslash-prefixed URL: "${href}". Use an absolute URL with an explicit scheme or a relative path.`);
82
82
  const scheme = extractScheme(href);
83
83
  if (scheme && DANGEROUS_SCHEMES.has(scheme)) throw new Error(`Navigation received a dangerous href: "${href}". javascript:, data:, and vbscript: URLs are not allowed.`);
84
84
  }
@@ -89,7 +89,7 @@ function validateNavigationHref(href) {
89
89
  * correctly identified as having a scheme (and thus not internal).
90
90
  */
91
91
  function isInternalHref(href) {
92
- const cleaned = href.replace(CONTROL_CHARS_RE, "").trimStart();
92
+ const cleaned = href.replace(CONTROL_CHARS_RE, "").trimStart().replaceAll("\\", "/");
93
93
  if (cleaned.startsWith("//")) return false;
94
94
  if (cleaned.startsWith("/") || cleaned.startsWith("#") || cleaned.startsWith("?")) return true;
95
95
  if (extractScheme(href)) return false;
@@ -121,4 +121,4 @@ function validateExternalRedirectUrl(url, allowList) {
121
121
  //#endregion
122
122
  export { mergePreservedSearchParams as i, validateExternalRedirectUrl as n, validateNavigationHref as r, isInternalHref as t };
123
123
 
124
- //# sourceMappingURL=href-validation-BJmD5mZh.js.map
124
+ //# sourceMappingURL=href-validation-2G-HZaQ8.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"href-validation-2G-HZaQ8.js","names":[],"sources":["../../src/shared/merge-search-params.ts","../../src/shared/href-validation.ts"],"sourcesContent":["/**\n * Shared utility for merging preserved search params into a target URL.\n *\n * Used by both <Link> (client) and redirect() (server). Extracted to a shared\n * module to avoid importing client code ('use client') from server modules.\n */\n\n/**\n * Merge preserved search params from the current URL into a target href.\n *\n * When `preserve` is `true`, all current search params are merged.\n * When `preserve` is a `string[]`, only the named params are merged.\n *\n * The target href's own search params take precedence — preserved params\n * are only added if the target doesn't already define them.\n *\n * @param targetHref - The resolved target href (may already contain query string)\n * @param currentSearch - The current URL's search string (e.g. \"?private=access&page=2\")\n * @param preserve - `true` to preserve all, or `string[]` to preserve specific params\n * @returns The target href with preserved search params merged in\n */\nexport function mergePreservedSearchParams(\n targetHref: string,\n currentSearch: string,\n preserve: true | string[]\n): string {\n const currentParams = new URLSearchParams(currentSearch);\n if (currentParams.size === 0) return targetHref;\n\n // Split hash fragment from target before processing query params.\n // Hash must come after query string: /path?query=value#hash\n const hashIdx = targetHref.indexOf('#');\n const hrefWithoutHash = hashIdx >= 0 ? targetHref.slice(0, hashIdx) : targetHref;\n const hash = hashIdx >= 0 ? targetHref.slice(hashIdx) : '';\n\n // Split target into path and existing query\n const qIdx = hrefWithoutHash.indexOf('?');\n const targetPath = qIdx >= 0 ? hrefWithoutHash.slice(0, qIdx) : hrefWithoutHash;\n const targetParams = new URLSearchParams(qIdx >= 0 ? hrefWithoutHash.slice(qIdx + 1) : '');\n\n // Collect params to preserve (that aren't already in the target)\n const merged = new URLSearchParams(targetParams);\n for (const [key, value] of currentParams) {\n // Only preserve if: (a) not already in target, and (b) included in preserve list\n if (!targetParams.has(key)) {\n if (preserve === true || preserve.includes(key)) {\n merged.append(key, value);\n }\n }\n }\n\n const qs = merged.toString();\n const pathWithQuery = qs ? `${targetPath}?${qs}` : targetPath;\n return pathWithQuery + hash;\n}\n","/**\n * Shared href/URL validation for all navigation sinks.\n *\n * Used by <Link>, useRouter().push/replace, and redirectExternal().\n * Single source of truth for dangerous-scheme detection — prevents\n * control-character smuggling that bypasses naive regex checks.\n *\n * See design/13-security.md §\"Link scheme injection\" (test #9)\n * and design/07-routing.md §\"<Link> Scheme Validation\".\n */\n\n/**\n * All C0 control characters (U+0000–U+001F) that browsers strip during\n * URL preprocessing. Browsers and `new URL()` strip the full C0 range,\n * not just tab/LF/CR — so we must match that behavior to prevent\n * bypass vectors like `\\x00javascript:alert(1)`.\n */\n// eslint-disable-next-line no-control-regex -- intentional: must match all C0 controls to prevent bypass\nconst CONTROL_CHARS_RE = /[\\x00-\\x1f]/g;\n\nconst DANGEROUS_SCHEMES = new Set(['javascript:', 'data:', 'vbscript:']);\n\n/**\n * Strip C0 control chars and leading whitespace, then extract the scheme.\n * Returns the lowercase scheme (e.g. \"javascript:\") or null if no scheme.\n */\nfunction extractScheme(href: string): string | null {\n const cleaned = href.replace(CONTROL_CHARS_RE, '').trimStart();\n const match = /^([a-z][a-z0-9+.-]*:)/i.exec(cleaned);\n return match ? match[1].toLowerCase() : null;\n}\n\n/**\n * Validate an href for navigation sinks (<Link>, useRouter, etc.).\n *\n * Rejects dangerous schemes (javascript:, data:, vbscript:) even when\n * control characters are embedded to bypass naive regex checks.\n * Also rejects protocol-relative URLs (//evil.com).\n *\n * Throws on rejection. Safe hrefs pass through silently.\n */\nexport function validateNavigationHref(href: string): void {\n // Normalize for validation: strip C0 controls, trim, then replace backslashes\n // with forward slashes. Browsers normalize \\ → / during URL resolution, so\n // \"/\\evil.com\" becomes \"//evil.com\" (protocol-relative open redirect).\n const cleaned = href.replace(CONTROL_CHARS_RE, '').trimStart().replaceAll('\\\\', '/');\n\n // Protocol-relative URLs can redirect to attacker-controlled origins\n if (cleaned.startsWith('//')) {\n throw new Error(\n `Navigation received a protocol-relative or backslash-prefixed URL: \"${href}\". ` +\n 'Use an absolute URL with an explicit scheme or a relative path.'\n );\n }\n\n const scheme = extractScheme(href);\n if (scheme && DANGEROUS_SCHEMES.has(scheme)) {\n throw new Error(\n `Navigation received a dangerous href: \"${href}\". ` +\n 'javascript:, data:, and vbscript: URLs are not allowed.'\n );\n }\n}\n\n/**\n * Check if an href is internal (same-origin navigation) or external.\n *\n * Strips control characters before checking so that \"java\\tscript:\" is\n * correctly identified as having a scheme (and thus not internal).\n */\nexport function isInternalHref(href: string): boolean {\n const cleaned = href.replace(CONTROL_CHARS_RE, '').trimStart().replaceAll('\\\\', '/');\n\n if (cleaned.startsWith('//')) return false;\n if (cleaned.startsWith('/') || cleaned.startsWith('#') || cleaned.startsWith('?')) return true;\n\n const scheme = extractScheme(href);\n if (scheme) return false;\n\n return true;\n}\n\nconst SAFE_REDIRECT_SCHEMES = new Set(['http:', 'https:']);\n\n/**\n * Validate and canonicalize an external redirect URL.\n *\n * Requires http: or https: scheme and checks origin against an allow-list.\n * Returns the parsed canonical URL string (from URL.href) to prevent\n * raw user input from reaching the Location header.\n *\n * @throws On invalid URL, dangerous scheme, or origin not in allow-list.\n */\nexport function validateExternalRedirectUrl(url: string, allowList: string[]): string {\n // Strip control chars before parsing to prevent scheme smuggling\n const cleaned = url.replace(CONTROL_CHARS_RE, '').trimStart();\n\n if (cleaned.startsWith('//')) {\n throw new Error(\n `redirectExternal() received a protocol-relative URL: \"${url}\". ` +\n 'Use an absolute URL with http: or https: scheme.'\n );\n }\n\n let parsed: URL;\n try {\n parsed = new URL(cleaned);\n } catch {\n throw new Error(`redirectExternal() received an invalid URL: \"${url}\"`);\n }\n\n if (!SAFE_REDIRECT_SCHEMES.has(parsed.protocol)) {\n throw new Error(\n `redirectExternal() received a non-http(s) URL: \"${url}\". ` +\n 'Only http: and https: schemes are allowed for external redirects.'\n );\n }\n\n if (!allowList.includes(parsed.origin)) {\n throw new Error(\n `redirectExternal() target \"${parsed.origin}\" is not in the allow-list. ` +\n `Allowed: [${allowList.join(', ')}]`\n );\n }\n\n return parsed.href;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,2BACd,YACA,eACA,UACQ;CACR,MAAM,gBAAgB,IAAI,gBAAgB,cAAc;CACxD,IAAI,cAAc,SAAS,GAAG,OAAO;CAIrC,MAAM,UAAU,WAAW,QAAQ,IAAI;CACvC,MAAM,kBAAkB,WAAW,IAAI,WAAW,MAAM,GAAG,QAAQ,GAAG;CACtE,MAAM,OAAO,WAAW,IAAI,WAAW,MAAM,QAAQ,GAAG;CAGxD,MAAM,OAAO,gBAAgB,QAAQ,IAAI;CACzC,MAAM,aAAa,QAAQ,IAAI,gBAAgB,MAAM,GAAG,KAAK,GAAG;CAChE,MAAM,eAAe,IAAI,gBAAgB,QAAQ,IAAI,gBAAgB,MAAM,OAAO,EAAE,GAAG,GAAG;CAG1F,MAAM,SAAS,IAAI,gBAAgB,aAAa;CAChD,KAAK,MAAM,CAAC,KAAK,UAAU,eAEzB,IAAI,CAAC,aAAa,IAAI,IAAI;MACpB,aAAa,QAAQ,SAAS,SAAS,IAAI,EAC7C,OAAO,OAAO,KAAK,MAAM;;CAK/B,MAAM,KAAK,OAAO,UAAU;CAE5B,QADsB,KAAK,GAAG,WAAW,GAAG,OAAO,cAC5B;;;;;;;;;;;;;;;;;;;;ACnCzB,IAAM,mBAAmB;AAEzB,IAAM,oBAAoB,IAAI,IAAI;CAAC;CAAe;CAAS;CAAY,CAAC;;;;;AAMxE,SAAS,cAAc,MAA6B;CAClD,MAAM,UAAU,KAAK,QAAQ,kBAAkB,GAAG,CAAC,WAAW;CAC9D,MAAM,QAAQ,yBAAyB,KAAK,QAAQ;CACpD,OAAO,QAAQ,MAAM,GAAG,aAAa,GAAG;;;;;;;;;;;AAY1C,SAAgB,uBAAuB,MAAoB;CAOzD,IAHgB,KAAK,QAAQ,kBAAkB,GAAG,CAAC,WAAW,CAAC,WAAW,MAAM,IAG5E,CAAQ,WAAW,KAAK,EAC1B,MAAM,IAAI,MACR,uEAAuE,KAAK,oEAE7E;CAGH,MAAM,SAAS,cAAc,KAAK;CAClC,IAAI,UAAU,kBAAkB,IAAI,OAAO,EACzC,MAAM,IAAI,MACR,0CAA0C,KAAK,4DAEhD;;;;;;;;AAUL,SAAgB,eAAe,MAAuB;CACpD,MAAM,UAAU,KAAK,QAAQ,kBAAkB,GAAG,CAAC,WAAW,CAAC,WAAW,MAAM,IAAI;CAEpF,IAAI,QAAQ,WAAW,KAAK,EAAE,OAAO;CACrC,IAAI,QAAQ,WAAW,IAAI,IAAI,QAAQ,WAAW,IAAI,IAAI,QAAQ,WAAW,IAAI,EAAE,OAAO;CAG1F,IADe,cAAc,KACzB,EAAQ,OAAO;CAEnB,OAAO;;AAGT,IAAM,wBAAwB,IAAI,IAAI,CAAC,SAAS,SAAS,CAAC;;;;;;;;;;AAW1D,SAAgB,4BAA4B,KAAa,WAA6B;CAEpF,MAAM,UAAU,IAAI,QAAQ,kBAAkB,GAAG,CAAC,WAAW;CAE7D,IAAI,QAAQ,WAAW,KAAK,EAC1B,MAAM,IAAI,MACR,yDAAyD,IAAI,qDAE9D;CAGH,IAAI;CACJ,IAAI;EACF,SAAS,IAAI,IAAI,QAAQ;SACnB;EACN,MAAM,IAAI,MAAM,gDAAgD,IAAI,GAAG;;CAGzE,IAAI,CAAC,sBAAsB,IAAI,OAAO,SAAS,EAC7C,MAAM,IAAI,MACR,mDAAmD,IAAI,sEAExD;CAGH,IAAI,CAAC,UAAU,SAAS,OAAO,OAAO,EACpC,MAAM,IAAI,MACR,8BAA8B,OAAO,OAAO,wCAC7B,UAAU,KAAK,KAAK,CAAC,GACrC;CAGH,OAAO,OAAO"}
@@ -1,5 +1,5 @@
1
- import "./use-segment-params-CIeinIx2.js";
2
- import { n as getRouterOrNull } from "./router-ref-B2-2kIIC.js";
1
+ import "./use-segment-params-x2poKmk8.js";
2
+ import { n as getRouterOrNull } from "./router-ref-B50j42zB.js";
3
3
  import { useSyncExternalStore } from "react";
4
4
  //#region src/client/use-pending-navigation.ts
5
5
  function subscribe(onStoreChange) {
@@ -100,4 +100,4 @@ function setHardNavigating(value) {
100
100
  //#endregion
101
101
  export { usePendingNavigation as n, setHardNavigating as t };
102
102
 
103
- //# sourceMappingURL=navigation-root-B5-RmsPi.js.map
103
+ //# sourceMappingURL=navigation-root-BQqFQI-n.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"navigation-root-B5-RmsPi.js","names":[],"sources":["../../src/client/use-pending-navigation.ts","../../src/client/top-loader.tsx","../../src/client/navigation-root.tsx"],"sourcesContent":["import { useSyncExternalStore } from 'react';\nimport { getRouterOrNull } from './router-ref.js';\n\nfunction subscribe(onStoreChange: () => void): () => void {\n const router = getRouterOrNull();\n if (!router) return () => {};\n return router.onPendingChange(onStoreChange);\n}\n\nfunction getSnapshot(): boolean {\n const router = getRouterOrNull();\n return router ? router.isPending() : false;\n}\n\nconst getServerSnapshot = getSnapshot;\n\n/**\n * Returns true while an RSC navigation is in flight.\n *\n * Reads from the router's external pending store via useSyncExternalStore.\n * Only components that call this hook re-render when pending state\n * changes — no full-tree re-render.\n *\n * ```tsx\n * 'use client'\n * import { usePendingNavigation } from '@timber-js/app/client'\n *\n * export function NavBar() {\n * const isPending = usePendingNavigation()\n * return (\n * <nav className={isPending ? 'opacity-50' : ''}>\n * <Link href=\"/dashboard\">Dashboard</Link>\n * </nav>\n * )\n * }\n * ```\n */\nexport function usePendingNavigation(): boolean {\n return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);\n}\n","/**\n * TopLoader — Built-in progress bar for client navigations.\n *\n * Shows an animated progress bar at the top of the viewport while an RSC\n * navigation is in flight. Injected automatically by the framework into\n * NavigationRoot — users never render this component directly.\n *\n * Configuration is via timber.config.ts `topLoader` key. Enabled by default.\n * Users who want a fully custom progress indicator disable the built-in one\n * (`topLoader: { enabled: false }`) and use `usePendingNavigation()` directly.\n *\n * Animation approach: pure CSS @keyframes. The bar crawls from 0% to ~90%\n * width over ~30s using ease-out timing. When navigation completes, the bar\n * snaps to 100% and fades out over 200ms. No JS animation loops (RAF, setInterval).\n *\n * Phase transitions are derived synchronously during render (React's\n * getDerivedStateFromProps pattern) — no useEffect needed for state tracking.\n * The finishing → hidden cleanup uses onTransitionEnd from the CSS transition.\n *\n * When delay > 0, CSS animation-delay + a visibility keyframe ensure the bar\n * stays invisible during the delay period. If navigation finishes before the\n * delay, the bar was never visible so the finish transition is also invisible.\n *\n * See design/19-client-navigation.md §\"usePendingNavigation()\"\n * See LOCAL-336 for design decisions.\n */\n\n'use client';\n\nimport { useState, createElement } from 'react';\nimport { usePendingNavigation } from './use-pending-navigation.js';\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport interface TopLoaderConfig {\n /** Whether the top-loader is enabled. Default: true. */\n enabled?: boolean;\n /** Bar color. Default: '#2299DD'. */\n color?: string;\n /** Bar height in pixels. Default: 3. */\n height?: number;\n /** Show subtle glow/shadow effect. Default: false. */\n shadow?: boolean;\n /** Delay in ms before showing the bar. Default: 0. */\n delay?: number;\n /** CSS z-index. Default: 1600. */\n zIndex?: number;\n}\n\n// ─── Defaults ────────────────────────────────────────────────────\n\nconst DEFAULT_COLOR = '#2299DD';\nconst DEFAULT_HEIGHT = 3;\nconst DEFAULT_SHADOW = false;\nconst DEFAULT_DELAY = 0;\nconst DEFAULT_Z_INDEX = 1600;\n\n// ─── Keyframes ───────────────────────────────────────────────────\n\n// Unique keyframes name to avoid collisions with user styles.\nconst CRAWL_KEYFRAMES = '__timber_top_loader_crawl';\nconst APPEAR_KEYFRAMES = '__timber_top_loader_appear';\nconst FINISH_KEYFRAMES = '__timber_top_loader_finish';\n\n// Track whether the @keyframes rules have been injected into the document.\nlet keyframesInjected = false;\n\n/**\n * Inject the @keyframes rules into the document head once.\n * Called during render (idempotent). Uses a <style> tag so the\n * animations are available for inline-styled elements.\n */\nfunction ensureKeyframes(): void {\n if (keyframesInjected) return;\n if (typeof document === 'undefined') return;\n\n const style = document.createElement('style');\n style.textContent = `\n@keyframes ${CRAWL_KEYFRAMES} {\n 0% { width: 0%; }\n 100% { width: 90%; }\n}\n@keyframes ${APPEAR_KEYFRAMES} {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n@keyframes ${FINISH_KEYFRAMES} {\n 0% { width: 90%; opacity: 1; }\n 50% { width: 100%; opacity: 1; }\n 100% { width: 100%; opacity: 0; }\n}\n`;\n document.head.appendChild(style);\n keyframesInjected = true;\n}\n\n// ─── Component ───────────────────────────────────────────────────\n\n/**\n * Internal top-loader component. Injected by NavigationRoot.\n *\n * Reads pending navigation state from PendingNavigationContext.\n * Phase transitions are derived synchronously during render:\n *\n * hidden → crawling: when isPending becomes true\n * crawling → finishing: when isPending becomes false\n * finishing → hidden: when CSS transition ends (onTransitionEnd)\n * finishing → crawling: when isPending becomes true again\n *\n * No useEffect — all state changes are either derived during render\n * (getDerivedStateFromProps pattern) or triggered by DOM events.\n */\nexport function TopLoader({ config }: { config?: TopLoaderConfig }): React.ReactElement | null {\n // Read pending state from the router's external store via\n // useSyncExternalStore (inside usePendingNavigation). Only this\n // component re-renders when pending changes — no full-tree re-render.\n const isPending = usePendingNavigation();\n\n const color = config?.color ?? DEFAULT_COLOR;\n const height = config?.height ?? DEFAULT_HEIGHT;\n const shadow = config?.shadow ?? DEFAULT_SHADOW;\n const delay = config?.delay ?? DEFAULT_DELAY;\n const zIndex = config?.zIndex ?? DEFAULT_Z_INDEX;\n\n const [phase, setPhase] = useState<'hidden' | 'crawling' | 'finishing'>('hidden');\n\n // ─── Synchronous phase derivation (getDerivedStateFromProps) ──\n // React allows setState during render if the value changes — it\n // immediately re-renders with the updated state before committing.\n\n if (isPending && (phase === 'hidden' || phase === 'finishing')) {\n setPhase('crawling');\n }\n if (!isPending && phase === 'crawling') {\n setPhase('finishing');\n }\n\n // Inject keyframes on first visible render (idempotent)\n if (phase !== 'hidden') {\n ensureKeyframes();\n }\n\n if (phase === 'hidden') return null;\n\n // ─── Styles ──────────────────────────────────────────────────\n\n const containerStyle: React.CSSProperties = {\n position: 'fixed',\n top: 0,\n left: 0,\n width: '100%',\n height: `${height}px`,\n zIndex,\n pointerEvents: 'none',\n };\n\n const barStyle: React.CSSProperties = {\n height: '100%',\n backgroundColor: color,\n ...(phase === 'crawling'\n ? {\n // Crawl from 0% to 90% over 30s. When delay > 0, both the crawl\n // and a visibility animation are delayed — the bar stays at width 0%\n // and opacity 0 during the delay, then appears and starts crawling.\n // With delay 0, the appear animation is instant (0s duration, no delay).\n animation: [\n `${CRAWL_KEYFRAMES} 30s ease-out ${delay}ms forwards`,\n `${APPEAR_KEYFRAMES} 0s ${delay}ms both`,\n ].join(', '),\n }\n : {\n // Finishing: fill to 100% then fade out via a keyframe animation.\n // We use a keyframe instead of a CSS transition because the\n // animation-to-transition handoff is unreliable — the browser\n // may not capture the animated width as the transition's \"from\"\n // value when both the animation removal and transition are\n // applied in the same render frame.\n animation: `${FINISH_KEYFRAMES} 400ms ease forwards`,\n }),\n ...(shadow\n ? {\n boxShadow: `0 0 10px ${color}, 0 0 5px ${color}`,\n }\n : {}),\n };\n\n // Clean up the finishing phase when the finish animation completes.\n const handleAnimationEnd =\n phase === 'finishing'\n ? (e: React.AnimationEvent) => {\n if (e.animationName === FINISH_KEYFRAMES) {\n setPhase('hidden');\n }\n }\n : undefined;\n\n return createElement(\n 'div',\n {\n 'style': containerStyle,\n 'aria-hidden': 'true',\n 'data-timber-top-loader': '',\n },\n createElement('div', { style: barStyle, onAnimationEnd: handleAnimationEnd })\n );\n}\n","/**\n * NavigationRoot — Wrapper component for transition-based rendering.\n *\n * Solves the \"new boundary has no old content\" problem for client-side\n * navigation. When React renders a completely new Suspense boundary via\n * root.render(), it shows the fallback immediately — root.render() is\n * always an urgent update regardless of startTransition.\n *\n * NavigationRoot holds the current element in React state. Navigation\n * updates call startTransition(() => setState(newElement)), which IS\n * a transition update. React keeps the old committed tree visible while\n * any new Suspense boundaries in the transition resolve.\n *\n * Also manages `pendingUrl` as React state with an urgent/transition split:\n * - Navigation START: `setPendingUrl(url)` is an urgent update — React\n * commits it before the next paint, showing the spinner immediately.\n * - Navigation END: `setPendingUrl(null)` is inside `startTransition`\n * alongside `setElement(newTree)` — both commit atomically, so the\n * spinner disappears in the same frame as the new content appears.\n *\n * Hard navigation guard: When a hard navigation is triggered (500 error,\n * version skew), the component throws an unresolved thenable AFTER all\n * hooks to suspend forever — preventing React from rendering children\n * during page teardown. The throw must come after hooks to satisfy\n * React's rules (same hook count every render) while still preventing\n * child renders that could hit hook count mismatches in components\n * whose positions shift during teardown. This pattern is borrowed from\n * Next.js (app-router.tsx pushRef.mpaNavigation — also after hooks).\n *\n * See design/05-streaming.md §\"deferSuspenseFor\"\n * See design/19-client-navigation.md §\"NavigationContext\"\n */\n\nimport { createElement, Fragment, startTransition, useState, type ReactNode } from 'react';\nimport { PendingNavigationProvider } from './navigation-context.js';\nimport { TopLoader, type TopLoaderConfig } from './top-loader.js';\n\n// ─── Navigation Transition Counter ──────────────────────────────\n// Monotonically increasing counter that increments each time\n// navigateTransition() is called. Used to detect stale transitions:\n// if a newer transition started while the current one's perform()\n// was in flight, the current transition is stale and should reject.\n//\n// Separate from the link-pending navId (which only increments on\n// link clicks). This counter covers all navigation types: link clicks,\n// programmatic navigate(), refresh(), and handlePopState().\n//\n// Uses globalThis for singleton guarantee across chunks — same pattern\n// as NavigationContext and the link pending store.\n\nconst NAV_TRANSITION_KEY = Symbol.for('__timber_nav_transition_counter');\n\nfunction getTransitionCounter(): { id: number } {\n const g = globalThis as Record<symbol, unknown>;\n if (!g[NAV_TRANSITION_KEY]) {\n g[NAV_TRANSITION_KEY] = { id: 0 };\n }\n return g[NAV_TRANSITION_KEY] as { id: number };\n}\n\n// ─── Hard Navigation Guard ──────────────────────────────────────\n\n/**\n * Module-level flag indicating a hard (MPA) navigation is in progress.\n *\n * When true:\n * - NavigationRoot throws an unresolved thenable to suspend forever,\n * preventing React from rendering children during page teardown\n * (avoids \"Rendered more hooks\" crashes).\n * - The Navigation API handler skips interception, letting the browser\n * perform a full page load (prevents infinite loops where\n * window.location.href → navigate event → router.navigate → 500 →\n * window.location.href → ...).\n *\n * Uses globalThis for singleton guarantee across chunks (same pattern\n * as NavigationContext). See design/19-client-navigation.md §\"Singleton\n * Guarantee via globalThis\".\n */\nconst HARD_NAV_KEY = Symbol.for('__timber_hard_navigating');\n\nfunction getHardNavStore(): { value: boolean } {\n const g = globalThis as Record<symbol, unknown>;\n if (!g[HARD_NAV_KEY]) {\n g[HARD_NAV_KEY] = { value: false };\n }\n return g[HARD_NAV_KEY] as { value: boolean };\n}\n\n/**\n * Set the hard-navigating flag. Call this BEFORE setting\n * window.location.href or window.location.reload() to prevent:\n * 1. React from rendering children during page teardown\n * 2. Navigation API from intercepting the hard navigation\n */\nexport function setHardNavigating(value: boolean): void {\n getHardNavStore().value = value;\n}\n\n/**\n * Check if a hard navigation is in progress.\n * Used by NavigationRoot (throw unresolvedThenable) and by the\n * Navigation API handler (skip interception).\n */\nexport function isHardNavigating(): boolean {\n return getHardNavStore().value;\n}\n\n/**\n * A thenable that never resolves. When thrown during React render,\n * it causes the component to suspend forever — React keeps the\n * old committed tree visible and never attempts to render children.\n *\n * This is the same pattern Next.js uses in app-router.tsx for MPA\n * navigations (pushRef.mpaNavigation → throw unresolvedThenable).\n */\n// for React's Suspense mechanism. Same pattern as Next.js's unresolvedThenable.\n// eslint-disable-next-line unicorn/no-thenable -- Intentionally a never-resolving thenable\nconst unresolvedThenable = { then() {} } as PromiseLike<never>;\n\n// ─── Module-level functions ──────────────────────────────────────\n\n/**\n * Module-level reference to the state setter wrapped in startTransition.\n * Used for non-navigation renders (applyRevalidation, popstate replay).\n */\nlet _transitionRender: ((element: ReactNode) => void) | null = null;\n\n/**\n * Module-level reference to the navigation transition function.\n * Wraps a full navigation (fetch + render) in a single startTransition\n * with the pending URL.\n */\nlet _navigateTransition:\n | ((\n pendingUrl: string,\n perform: () => Promise<{ element: ReactNode; decodePromise: Promise<void> | null }>\n ) => Promise<void>)\n | null = null;\n\n// ─── Component ───────────────────────────────────────────────────\n\n/**\n * Root wrapper component that enables transition-based rendering.\n *\n * Renders PendingNavigationProvider around children for the pending URL\n * context. The DOM tree matches the server-rendered HTML during hydration\n * (the provider renders no extra DOM elements).\n *\n * Usage in browser-entry.ts:\n * const rootEl = createElement(NavigationRoot, { initial: wrapped });\n * reactRoot = hydrateRoot(document, rootEl);\n *\n * Subsequent navigations:\n * navigateTransition(url, async () => { fetch; return wrappedElement; });\n *\n * Non-navigation renders:\n * transitionRender(newWrappedElement);\n */\nexport function NavigationRoot({\n initial,\n topLoaderConfig,\n}: {\n initial: ReactNode;\n topLoaderConfig?: TopLoaderConfig;\n}): ReactNode {\n const [element, setElement] = useState<ReactNode>(initial);\n const [pendingUrl, setPendingUrl] = useState<string | null>(null);\n\n // NOTE: We use standalone `startTransition` (imported from 'react'),\n // NOT `useTransition`. The `useTransition` hook's `startTransition`\n // is tied to a single fiber and tracks one async callback at a time.\n // When two navigations overlap (click slow-page, then click dashboard),\n // calling useTransition's startTransition twice with concurrent async\n // callbacks corrupts React's internal hook tracking — causing\n // \"Rendered more hooks than during the previous render.\"\n //\n // Standalone `startTransition` creates independent transition lanes\n // for each call, so concurrent navigations don't interfere. We don't\n // need useTransition's `isPending` — we track pending state via our\n // own `pendingUrl` useState.\n //\n // This matches the Next.js pattern (TIM-625): \"No useTransition in\n // the router at all — only standalone startTransition.\"\n\n // Non-navigation render (revalidation, popstate cached replay).\n _transitionRender = (newElement: ReactNode) => {\n startTransition(() => {\n setElement(newElement);\n });\n };\n\n // Full navigation transition.\n // setPendingUrl(url) is an URGENT update — React commits it before the next\n // paint, so the pending spinner appears immediately when navigation starts.\n // Inside startTransition: the async fetch + setElement + setPendingUrl(null)\n // are deferred. When the transition commits, the new tree and pendingUrl=null\n // both apply in the same React commit — making the pending→active transition\n // atomic (no frame where pending is false but the old tree is still visible).\n _navigateTransition = (\n url: string,\n perform: () => Promise<{ element: ReactNode; decodePromise: Promise<void> | null }>\n ) => {\n // Increment the transition counter SYNCHRONOUSLY (before startTransition\n // schedules the async work). Each call gets a unique transId; the counter\n // is the same globalThis singleton, so a newer call always has a higher id.\n const counter = getTransitionCounter();\n const transId = ++counter.id;\n\n // NO urgent setPendingUrl here. Per-link pending state is driven by\n // Link's own useTransition — only the clicked Link re-renders for\n // isPending. setPendingUrl is set INSIDE startTransition so the\n // TopLoader activates as a transition update (no full-tree re-render).\n\n return new Promise<void>((resolve, reject) => {\n startTransition(async () => {\n setPendingUrl(url);\n try {\n const { element, decodePromise } = await perform();\n if (counter.id !== transId) {\n decodePromise?.catch(() => {});\n reject(new DOMException('Navigation superseded', 'AbortError'));\n return;\n }\n setElement(element);\n if (decodePromise) await decodePromise;\n if (counter.id === transId) {\n setPendingUrl(null);\n resolve();\n } else {\n reject(new DOMException('Navigation superseded', 'AbortError'));\n }\n } catch (err) {\n if (counter.id === transId) {\n setPendingUrl(null);\n }\n reject(err);\n }\n });\n });\n };\n\n // ─── Hard navigation guard ─────────────────────────────────\n // When a hard navigation is in progress (500 error, version skew),\n // suspend forever to prevent React from rendering children during\n // page teardown. This avoids \"Rendered more hooks\" crashes in\n // CHILD components whose hook counts may shift during teardown.\n //\n // CRITICAL: This throw MUST come AFTER all hooks (the two\n // useState calls above). React requires the same hooks to run on\n // every render. If we threw before hooks, React would see 0 hooks\n // on the re-render vs 2 hooks on the initial render — triggering\n // the exact \"Rendered more hooks\" error we're trying to prevent.\n //\n // By placing it after hooks but before the return, all hooks\n // satisfy React's rules, but the thrown thenable prevents any\n // children from rendering. Same pattern as Next.js app-router.tsx\n // (pushRef.mpaNavigation — also placed after all hooks).\n if (isHardNavigating()) {\n throw unresolvedThenable;\n }\n\n // Inject TopLoader alongside the element tree inside PendingNavigationProvider.\n // The TopLoader reads pendingUrl from context to show/hide the progress bar.\n // It is rendered only when not explicitly disabled via config.\n const showTopLoader = topLoaderConfig?.enabled !== false;\n const children = showTopLoader\n ? createElement(Fragment, null, createElement(TopLoader, { config: topLoaderConfig }), element)\n : element;\n return createElement(PendingNavigationProvider, { value: pendingUrl }, children);\n}\n\n// ─── Public API ──────────────────────────────────────────────────\n\n/**\n * Trigger a transition render for non-navigation updates.\n * React keeps the old committed tree visible while any new Suspense\n * boundaries in the update resolve.\n *\n * Used for: applyRevalidation, popstate replay with cached payload.\n */\nexport function transitionRender(element: ReactNode): void {\n if (_transitionRender) {\n _transitionRender(element);\n }\n}\n\n/**\n * Run a full navigation inside a React transition with optimistic pending URL.\n *\n * The `perform` callback runs inside `startTransition` — it should fetch the\n * RSC payload, update router state, and return the wrapped React element.\n * The pending URL shows immediately (urgent update) and reverts\n * to null when the transition commits (atomic with the new tree).\n *\n * Returns a Promise that resolves when the async work completes (note: the\n * React transition may not have committed yet, but all state updates are done).\n *\n * Used for: navigate(), refresh(), popstate with fetch.\n */\nexport function navigateTransition(\n pendingUrl: string,\n perform: () => Promise<{ element: ReactNode; decodePromise: Promise<void> | null }>\n): Promise<void> {\n if (_navigateTransition) {\n return _navigateTransition(pendingUrl, perform);\n }\n // Fallback: no NavigationRoot mounted (shouldn't happen in production)\n return perform().then(() => {});\n}\n\n/**\n * Check if the NavigationRoot is mounted and ready for renders.\n * Used by browser-entry.ts to guard against renders before hydration.\n */\nexport function isNavigationRootReady(): boolean {\n return _transitionRender !== null;\n}\n\n/**\n * Install one-shot deferred callbacks for the no-RSC bootstrap path (TIM-600).\n *\n * When there's no RSC payload, we can't create a React root immediately —\n * `createRoot(document).render(...)` would blank the SSR HTML. Instead,\n * this sets up `_transitionRender` and `_navigateTransition` so that the\n * first client navigation triggers root creation via `createAndMount`.\n *\n * After `createAndMount` runs, NavigationRoot renders and overwrites these\n * callbacks with its real `startTransition`-based implementations.\n */\nexport function installDeferredNavigation(createAndMount: (initial: ReactNode) => void): void {\n let mounted = false;\n const mountOnce = (element: ReactNode) => {\n if (mounted) return;\n mounted = true;\n createAndMount(element);\n };\n _transitionRender = (element: ReactNode) => {\n mountOnce(element);\n };\n _navigateTransition = async (\n _pendingUrl: string,\n perform: () => Promise<{ element: ReactNode; decodePromise: Promise<void> | null }>\n ) => {\n const { element } = await perform();\n mountOnce(element);\n };\n}\n"],"mappings":";;;;AAGA,SAAS,UAAU,eAAuC;CACxD,MAAM,SAAS,iBAAiB;CAChC,IAAI,CAAC,QAAQ,aAAa;CAC1B,OAAO,OAAO,gBAAgB,cAAc;;AAG9C,SAAS,cAAuB;CAC9B,MAAM,SAAS,iBAAiB;CAChC,OAAO,SAAS,OAAO,WAAW,GAAG;;AAGvC,IAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;AAuB1B,SAAgB,uBAAgC;CAC9C,OAAO,qBAAqB,WAAW,aAAa,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AEwCxE,IAAM,eAAe,OAAO,IAAI,2BAA2B;AAE3D,SAAS,kBAAsC;CAC7C,MAAM,IAAI;CACV,IAAI,CAAC,EAAE,eACL,EAAE,gBAAgB,EAAE,OAAO,OAAO;CAEpC,OAAO,EAAE;;;;;;;;AASX,SAAgB,kBAAkB,OAAsB;CACtD,iBAAiB,CAAC,QAAQ"}
1
+ {"version":3,"file":"navigation-root-BQqFQI-n.js","names":[],"sources":["../../src/client/use-pending-navigation.ts","../../src/client/top-loader.tsx","../../src/client/navigation-root.tsx"],"sourcesContent":["import { useSyncExternalStore } from 'react';\nimport { getRouterOrNull } from './router-ref.js';\n\nfunction subscribe(onStoreChange: () => void): () => void {\n const router = getRouterOrNull();\n if (!router) return () => {};\n return router.onPendingChange(onStoreChange);\n}\n\nfunction getSnapshot(): boolean {\n const router = getRouterOrNull();\n return router ? router.isPending() : false;\n}\n\nconst getServerSnapshot = getSnapshot;\n\n/**\n * Returns true while an RSC navigation is in flight.\n *\n * Reads from the router's external pending store via useSyncExternalStore.\n * Only components that call this hook re-render when pending state\n * changes — no full-tree re-render.\n *\n * ```tsx\n * 'use client'\n * import { usePendingNavigation } from '@timber-js/app/client'\n *\n * export function NavBar() {\n * const isPending = usePendingNavigation()\n * return (\n * <nav className={isPending ? 'opacity-50' : ''}>\n * <Link href=\"/dashboard\">Dashboard</Link>\n * </nav>\n * )\n * }\n * ```\n */\nexport function usePendingNavigation(): boolean {\n return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);\n}\n","/**\n * TopLoader — Built-in progress bar for client navigations.\n *\n * Shows an animated progress bar at the top of the viewport while an RSC\n * navigation is in flight. Injected automatically by the framework into\n * NavigationRoot — users never render this component directly.\n *\n * Configuration is via timber.config.ts `topLoader` key. Enabled by default.\n * Users who want a fully custom progress indicator disable the built-in one\n * (`topLoader: { enabled: false }`) and use `usePendingNavigation()` directly.\n *\n * Animation approach: pure CSS @keyframes. The bar crawls from 0% to ~90%\n * width over ~30s using ease-out timing. When navigation completes, the bar\n * snaps to 100% and fades out over 200ms. No JS animation loops (RAF, setInterval).\n *\n * Phase transitions are derived synchronously during render (React's\n * getDerivedStateFromProps pattern) — no useEffect needed for state tracking.\n * The finishing → hidden cleanup uses onTransitionEnd from the CSS transition.\n *\n * When delay > 0, CSS animation-delay + a visibility keyframe ensure the bar\n * stays invisible during the delay period. If navigation finishes before the\n * delay, the bar was never visible so the finish transition is also invisible.\n *\n * See design/19-client-navigation.md §\"usePendingNavigation()\"\n * See LOCAL-336 for design decisions.\n */\n\n'use client';\n\nimport { useState, createElement } from 'react';\nimport { usePendingNavigation } from './use-pending-navigation.js';\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport interface TopLoaderConfig {\n /** Whether the top-loader is enabled. Default: true. */\n enabled?: boolean;\n /** Bar color. Default: '#2299DD'. */\n color?: string;\n /** Bar height in pixels. Default: 3. */\n height?: number;\n /** Show subtle glow/shadow effect. Default: false. */\n shadow?: boolean;\n /** Delay in ms before showing the bar. Default: 0. */\n delay?: number;\n /** CSS z-index. Default: 1600. */\n zIndex?: number;\n}\n\n// ─── Defaults ────────────────────────────────────────────────────\n\nconst DEFAULT_COLOR = '#2299DD';\nconst DEFAULT_HEIGHT = 3;\nconst DEFAULT_SHADOW = false;\nconst DEFAULT_DELAY = 0;\nconst DEFAULT_Z_INDEX = 1600;\n\n// ─── Keyframes ───────────────────────────────────────────────────\n\n// Unique keyframes name to avoid collisions with user styles.\nconst CRAWL_KEYFRAMES = '__timber_top_loader_crawl';\nconst APPEAR_KEYFRAMES = '__timber_top_loader_appear';\nconst FINISH_KEYFRAMES = '__timber_top_loader_finish';\n\n// Track whether the @keyframes rules have been injected into the document.\nlet keyframesInjected = false;\n\n/**\n * Inject the @keyframes rules into the document head once.\n * Called during render (idempotent). Uses a <style> tag so the\n * animations are available for inline-styled elements.\n */\nfunction ensureKeyframes(): void {\n if (keyframesInjected) return;\n if (typeof document === 'undefined') return;\n\n const style = document.createElement('style');\n style.textContent = `\n@keyframes ${CRAWL_KEYFRAMES} {\n 0% { width: 0%; }\n 100% { width: 90%; }\n}\n@keyframes ${APPEAR_KEYFRAMES} {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n@keyframes ${FINISH_KEYFRAMES} {\n 0% { width: 90%; opacity: 1; }\n 50% { width: 100%; opacity: 1; }\n 100% { width: 100%; opacity: 0; }\n}\n`;\n document.head.appendChild(style);\n keyframesInjected = true;\n}\n\n// ─── Component ───────────────────────────────────────────────────\n\n/**\n * Internal top-loader component. Injected by NavigationRoot.\n *\n * Reads pending navigation state from PendingNavigationContext.\n * Phase transitions are derived synchronously during render:\n *\n * hidden → crawling: when isPending becomes true\n * crawling → finishing: when isPending becomes false\n * finishing → hidden: when CSS transition ends (onTransitionEnd)\n * finishing → crawling: when isPending becomes true again\n *\n * No useEffect — all state changes are either derived during render\n * (getDerivedStateFromProps pattern) or triggered by DOM events.\n */\nexport function TopLoader({ config }: { config?: TopLoaderConfig }): React.ReactElement | null {\n // Read pending state from the router's external store via\n // useSyncExternalStore (inside usePendingNavigation). Only this\n // component re-renders when pending changes — no full-tree re-render.\n const isPending = usePendingNavigation();\n\n const color = config?.color ?? DEFAULT_COLOR;\n const height = config?.height ?? DEFAULT_HEIGHT;\n const shadow = config?.shadow ?? DEFAULT_SHADOW;\n const delay = config?.delay ?? DEFAULT_DELAY;\n const zIndex = config?.zIndex ?? DEFAULT_Z_INDEX;\n\n const [phase, setPhase] = useState<'hidden' | 'crawling' | 'finishing'>('hidden');\n\n // ─── Synchronous phase derivation (getDerivedStateFromProps) ──\n // React allows setState during render if the value changes — it\n // immediately re-renders with the updated state before committing.\n\n if (isPending && (phase === 'hidden' || phase === 'finishing')) {\n setPhase('crawling');\n }\n if (!isPending && phase === 'crawling') {\n setPhase('finishing');\n }\n\n // Inject keyframes on first visible render (idempotent)\n if (phase !== 'hidden') {\n ensureKeyframes();\n }\n\n if (phase === 'hidden') return null;\n\n // ─── Styles ──────────────────────────────────────────────────\n\n const containerStyle: React.CSSProperties = {\n position: 'fixed',\n top: 0,\n left: 0,\n width: '100%',\n height: `${height}px`,\n zIndex,\n pointerEvents: 'none',\n };\n\n const barStyle: React.CSSProperties = {\n height: '100%',\n backgroundColor: color,\n ...(phase === 'crawling'\n ? {\n // Crawl from 0% to 90% over 30s. When delay > 0, both the crawl\n // and a visibility animation are delayed — the bar stays at width 0%\n // and opacity 0 during the delay, then appears and starts crawling.\n // With delay 0, the appear animation is instant (0s duration, no delay).\n animation: [\n `${CRAWL_KEYFRAMES} 30s ease-out ${delay}ms forwards`,\n `${APPEAR_KEYFRAMES} 0s ${delay}ms both`,\n ].join(', '),\n }\n : {\n // Finishing: fill to 100% then fade out via a keyframe animation.\n // We use a keyframe instead of a CSS transition because the\n // animation-to-transition handoff is unreliable — the browser\n // may not capture the animated width as the transition's \"from\"\n // value when both the animation removal and transition are\n // applied in the same render frame.\n animation: `${FINISH_KEYFRAMES} 400ms ease forwards`,\n }),\n ...(shadow\n ? {\n boxShadow: `0 0 10px ${color}, 0 0 5px ${color}`,\n }\n : {}),\n };\n\n // Clean up the finishing phase when the finish animation completes.\n const handleAnimationEnd =\n phase === 'finishing'\n ? (e: React.AnimationEvent) => {\n if (e.animationName === FINISH_KEYFRAMES) {\n setPhase('hidden');\n }\n }\n : undefined;\n\n return createElement(\n 'div',\n {\n 'style': containerStyle,\n 'aria-hidden': 'true',\n 'data-timber-top-loader': '',\n },\n createElement('div', { style: barStyle, onAnimationEnd: handleAnimationEnd })\n );\n}\n","/**\n * NavigationRoot — Wrapper component for transition-based rendering.\n *\n * Solves the \"new boundary has no old content\" problem for client-side\n * navigation. When React renders a completely new Suspense boundary via\n * root.render(), it shows the fallback immediately — root.render() is\n * always an urgent update regardless of startTransition.\n *\n * NavigationRoot holds the current element in React state. Navigation\n * updates call startTransition(() => setState(newElement)), which IS\n * a transition update. React keeps the old committed tree visible while\n * any new Suspense boundaries in the transition resolve.\n *\n * Also manages `pendingUrl` as React state with an urgent/transition split:\n * - Navigation START: `setPendingUrl(url)` is an urgent update — React\n * commits it before the next paint, showing the spinner immediately.\n * - Navigation END: `setPendingUrl(null)` is inside `startTransition`\n * alongside `setElement(newTree)` — both commit atomically, so the\n * spinner disappears in the same frame as the new content appears.\n *\n * Hard navigation guard: When a hard navigation is triggered (500 error,\n * version skew), the component throws an unresolved thenable AFTER all\n * hooks to suspend forever — preventing React from rendering children\n * during page teardown. The throw must come after hooks to satisfy\n * React's rules (same hook count every render) while still preventing\n * child renders that could hit hook count mismatches in components\n * whose positions shift during teardown. This pattern is borrowed from\n * Next.js (app-router.tsx pushRef.mpaNavigation — also after hooks).\n *\n * See design/05-streaming.md §\"deferSuspenseFor\"\n * See design/19-client-navigation.md §\"NavigationContext\"\n */\n\nimport { createElement, Fragment, startTransition, useState, type ReactNode } from 'react';\nimport { PendingNavigationProvider } from './navigation-context.js';\nimport { TopLoader, type TopLoaderConfig } from './top-loader.js';\n\n// ─── Navigation Transition Counter ──────────────────────────────\n// Monotonically increasing counter that increments each time\n// navigateTransition() is called. Used to detect stale transitions:\n// if a newer transition started while the current one's perform()\n// was in flight, the current transition is stale and should reject.\n//\n// Separate from the link-pending navId (which only increments on\n// link clicks). This counter covers all navigation types: link clicks,\n// programmatic navigate(), refresh(), and handlePopState().\n//\n// Uses globalThis for singleton guarantee across chunks — same pattern\n// as NavigationContext and the link pending store.\n\nconst NAV_TRANSITION_KEY = Symbol.for('__timber_nav_transition_counter');\n\nfunction getTransitionCounter(): { id: number } {\n const g = globalThis as Record<symbol, unknown>;\n if (!g[NAV_TRANSITION_KEY]) {\n g[NAV_TRANSITION_KEY] = { id: 0 };\n }\n return g[NAV_TRANSITION_KEY] as { id: number };\n}\n\n// ─── Hard Navigation Guard ──────────────────────────────────────\n\n/**\n * Module-level flag indicating a hard (MPA) navigation is in progress.\n *\n * When true:\n * - NavigationRoot throws an unresolved thenable to suspend forever,\n * preventing React from rendering children during page teardown\n * (avoids \"Rendered more hooks\" crashes).\n * - The Navigation API handler skips interception, letting the browser\n * perform a full page load (prevents infinite loops where\n * window.location.href → navigate event → router.navigate → 500 →\n * window.location.href → ...).\n *\n * Uses globalThis for singleton guarantee across chunks (same pattern\n * as NavigationContext). See design/19-client-navigation.md §\"Singleton\n * Guarantee via globalThis\".\n */\nconst HARD_NAV_KEY = Symbol.for('__timber_hard_navigating');\n\nfunction getHardNavStore(): { value: boolean } {\n const g = globalThis as Record<symbol, unknown>;\n if (!g[HARD_NAV_KEY]) {\n g[HARD_NAV_KEY] = { value: false };\n }\n return g[HARD_NAV_KEY] as { value: boolean };\n}\n\n/**\n * Set the hard-navigating flag. Call this BEFORE setting\n * window.location.href or window.location.reload() to prevent:\n * 1. React from rendering children during page teardown\n * 2. Navigation API from intercepting the hard navigation\n */\nexport function setHardNavigating(value: boolean): void {\n getHardNavStore().value = value;\n}\n\n/**\n * Check if a hard navigation is in progress.\n * Used by NavigationRoot (throw unresolvedThenable) and by the\n * Navigation API handler (skip interception).\n */\nexport function isHardNavigating(): boolean {\n return getHardNavStore().value;\n}\n\n/**\n * A thenable that never resolves. When thrown during React render,\n * it causes the component to suspend forever — React keeps the\n * old committed tree visible and never attempts to render children.\n *\n * This is the same pattern Next.js uses in app-router.tsx for MPA\n * navigations (pushRef.mpaNavigation → throw unresolvedThenable).\n */\n// for React's Suspense mechanism. Same pattern as Next.js's unresolvedThenable.\n// eslint-disable-next-line unicorn/no-thenable -- Intentionally a never-resolving thenable\nconst unresolvedThenable = { then() {} } as PromiseLike<never>;\n\n// ─── Module-level functions ──────────────────────────────────────\n\n/**\n * Module-level reference to the state setter wrapped in startTransition.\n * Used for non-navigation renders (applyRevalidation, popstate replay).\n */\nlet _transitionRender: ((element: ReactNode) => void) | null = null;\n\n/**\n * Module-level reference to the navigation transition function.\n * Wraps a full navigation (fetch + render) in a single startTransition\n * with the pending URL.\n */\nlet _navigateTransition:\n | ((\n pendingUrl: string,\n perform: () => Promise<{ element: ReactNode; decodePromise: Promise<void> | null }>\n ) => Promise<void>)\n | null = null;\n\n// ─── Component ───────────────────────────────────────────────────\n\n/**\n * Root wrapper component that enables transition-based rendering.\n *\n * Renders PendingNavigationProvider around children for the pending URL\n * context. The DOM tree matches the server-rendered HTML during hydration\n * (the provider renders no extra DOM elements).\n *\n * Usage in browser-entry.ts:\n * const rootEl = createElement(NavigationRoot, { initial: wrapped });\n * reactRoot = hydrateRoot(document, rootEl);\n *\n * Subsequent navigations:\n * navigateTransition(url, async () => { fetch; return wrappedElement; });\n *\n * Non-navigation renders:\n * transitionRender(newWrappedElement);\n */\nexport function NavigationRoot({\n initial,\n topLoaderConfig,\n}: {\n initial: ReactNode;\n topLoaderConfig?: TopLoaderConfig;\n}): ReactNode {\n const [element, setElement] = useState<ReactNode>(initial);\n const [pendingUrl, setPendingUrl] = useState<string | null>(null);\n\n // NOTE: We use standalone `startTransition` (imported from 'react'),\n // NOT `useTransition`. The `useTransition` hook's `startTransition`\n // is tied to a single fiber and tracks one async callback at a time.\n // When two navigations overlap (click slow-page, then click dashboard),\n // calling useTransition's startTransition twice with concurrent async\n // callbacks corrupts React's internal hook tracking — causing\n // \"Rendered more hooks than during the previous render.\"\n //\n // Standalone `startTransition` creates independent transition lanes\n // for each call, so concurrent navigations don't interfere. We don't\n // need useTransition's `isPending` — we track pending state via our\n // own `pendingUrl` useState.\n //\n // This matches the Next.js pattern (TIM-625): \"No useTransition in\n // the router at all — only standalone startTransition.\"\n\n // Non-navigation render (revalidation, popstate cached replay).\n _transitionRender = (newElement: ReactNode) => {\n startTransition(() => {\n setElement(newElement);\n });\n };\n\n // Full navigation transition.\n // setPendingUrl(url) is an URGENT update — React commits it before the next\n // paint, so the pending spinner appears immediately when navigation starts.\n // Inside startTransition: the async fetch + setElement + setPendingUrl(null)\n // are deferred. When the transition commits, the new tree and pendingUrl=null\n // both apply in the same React commit — making the pending→active transition\n // atomic (no frame where pending is false but the old tree is still visible).\n _navigateTransition = (\n url: string,\n perform: () => Promise<{ element: ReactNode; decodePromise: Promise<void> | null }>\n ) => {\n // Increment the transition counter SYNCHRONOUSLY (before startTransition\n // schedules the async work). Each call gets a unique transId; the counter\n // is the same globalThis singleton, so a newer call always has a higher id.\n const counter = getTransitionCounter();\n const transId = ++counter.id;\n\n // NO urgent setPendingUrl here. Per-link pending state is driven by\n // Link's own useTransition — only the clicked Link re-renders for\n // isPending. setPendingUrl is set INSIDE startTransition so the\n // TopLoader activates as a transition update (no full-tree re-render).\n\n return new Promise<void>((resolve, reject) => {\n startTransition(async () => {\n setPendingUrl(url);\n try {\n const { element, decodePromise } = await perform();\n if (counter.id !== transId) {\n decodePromise?.catch(() => {});\n reject(new DOMException('Navigation superseded', 'AbortError'));\n return;\n }\n setElement(element);\n if (decodePromise) await decodePromise;\n if (counter.id === transId) {\n setPendingUrl(null);\n resolve();\n } else {\n reject(new DOMException('Navigation superseded', 'AbortError'));\n }\n } catch (err) {\n if (counter.id === transId) {\n setPendingUrl(null);\n }\n reject(err);\n }\n });\n });\n };\n\n // ─── Hard navigation guard ─────────────────────────────────\n // When a hard navigation is in progress (500 error, version skew),\n // suspend forever to prevent React from rendering children during\n // page teardown. This avoids \"Rendered more hooks\" crashes in\n // CHILD components whose hook counts may shift during teardown.\n //\n // CRITICAL: This throw MUST come AFTER all hooks (the two\n // useState calls above). React requires the same hooks to run on\n // every render. If we threw before hooks, React would see 0 hooks\n // on the re-render vs 2 hooks on the initial render — triggering\n // the exact \"Rendered more hooks\" error we're trying to prevent.\n //\n // By placing it after hooks but before the return, all hooks\n // satisfy React's rules, but the thrown thenable prevents any\n // children from rendering. Same pattern as Next.js app-router.tsx\n // (pushRef.mpaNavigation — also placed after all hooks).\n if (isHardNavigating()) {\n throw unresolvedThenable;\n }\n\n // Inject TopLoader alongside the element tree inside PendingNavigationProvider.\n // The TopLoader reads pendingUrl from context to show/hide the progress bar.\n // It is rendered only when not explicitly disabled via config.\n const showTopLoader = topLoaderConfig?.enabled !== false;\n const children = showTopLoader\n ? createElement(Fragment, null, createElement(TopLoader, { config: topLoaderConfig }), element)\n : element;\n return createElement(PendingNavigationProvider, { value: pendingUrl }, children);\n}\n\n// ─── Public API ──────────────────────────────────────────────────\n\n/**\n * Trigger a transition render for non-navigation updates.\n * React keeps the old committed tree visible while any new Suspense\n * boundaries in the update resolve.\n *\n * Used for: applyRevalidation, popstate replay with cached payload.\n */\nexport function transitionRender(element: ReactNode): void {\n if (_transitionRender) {\n _transitionRender(element);\n }\n}\n\n/**\n * Run a full navigation inside a React transition with optimistic pending URL.\n *\n * The `perform` callback runs inside `startTransition` — it should fetch the\n * RSC payload, update router state, and return the wrapped React element.\n * The pending URL shows immediately (urgent update) and reverts\n * to null when the transition commits (atomic with the new tree).\n *\n * Returns a Promise that resolves when the async work completes (note: the\n * React transition may not have committed yet, but all state updates are done).\n *\n * Used for: navigate(), refresh(), popstate with fetch.\n */\nexport function navigateTransition(\n pendingUrl: string,\n perform: () => Promise<{ element: ReactNode; decodePromise: Promise<void> | null }>\n): Promise<void> {\n if (_navigateTransition) {\n return _navigateTransition(pendingUrl, perform);\n }\n // Fallback: no NavigationRoot mounted (shouldn't happen in production)\n return perform().then(() => {});\n}\n\n/**\n * Check if the NavigationRoot is mounted and ready for renders.\n * Used by browser-entry.ts to guard against renders before hydration.\n */\nexport function isNavigationRootReady(): boolean {\n return _transitionRender !== null;\n}\n\n/**\n * Install one-shot deferred callbacks for the no-RSC bootstrap path (TIM-600).\n *\n * When there's no RSC payload, we can't create a React root immediately —\n * `createRoot(document).render(...)` would blank the SSR HTML. Instead,\n * this sets up `_transitionRender` and `_navigateTransition` so that the\n * first client navigation triggers root creation via `createAndMount`.\n *\n * After `createAndMount` runs, NavigationRoot renders and overwrites these\n * callbacks with its real `startTransition`-based implementations.\n */\nexport function installDeferredNavigation(createAndMount: (initial: ReactNode) => void): void {\n let mounted = false;\n const mountOnce = (element: ReactNode) => {\n if (mounted) return;\n mounted = true;\n createAndMount(element);\n };\n _transitionRender = (element: ReactNode) => {\n mountOnce(element);\n };\n _navigateTransition = async (\n _pendingUrl: string,\n perform: () => Promise<{ element: ReactNode; decodePromise: Promise<void> | null }>\n ) => {\n const { element } = await perform();\n mountOnce(element);\n };\n}\n"],"mappings":";;;;AAGA,SAAS,UAAU,eAAuC;CACxD,MAAM,SAAS,iBAAiB;CAChC,IAAI,CAAC,QAAQ,aAAa;CAC1B,OAAO,OAAO,gBAAgB,cAAc;;AAG9C,SAAS,cAAuB;CAC9B,MAAM,SAAS,iBAAiB;CAChC,OAAO,SAAS,OAAO,WAAW,GAAG;;AAGvC,IAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;AAuB1B,SAAgB,uBAAgC;CAC9C,OAAO,qBAAqB,WAAW,aAAa,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AEwCxE,IAAM,eAAe,OAAO,IAAI,2BAA2B;AAE3D,SAAS,kBAAsC;CAC7C,MAAM,IAAI;CACV,IAAI,CAAC,EAAE,eACL,EAAE,gBAAgB,EAAE,OAAO,OAAO;CAEpC,OAAO,EAAE;;;;;;;;AASX,SAAgB,kBAAkB,OAAsB;CACtD,iBAAiB,CAAC,QAAQ"}
@@ -17,4 +17,4 @@ function getSearchParamsDefinition(route) {
17
17
  //#endregion
18
18
  export { registerSearchParams as n, getSearchParamsDefinition as t };
19
19
 
20
- //# sourceMappingURL=registry-BI9cgCt7.js.map
20
+ //# sourceMappingURL=registry-Dp_hJL4s.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"registry-BI9cgCt7.js","names":[],"sources":["../../src/search-params/registry.ts"],"sourcesContent":["/**\n * Runtime registry for route-scoped search params definitions.\n *\n * When a route's modules load, the framework registers its search-params\n * definition here. useQueryStates('/route') resolves codecs from this map.\n *\n * Design doc: design/23-search-params.md §\"Runtime: Registration at Route Load\"\n */\n\nimport type { SearchParamsDefinition } from './define.js';\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst registry = new Map<string, SearchParamsDefinition<any>>();\n\n/**\n * Register a route's search params definition.\n * Called by the generated route manifest loader when a route's modules load.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function registerSearchParams(route: string, definition: SearchParamsDefinition<any>): void {\n registry.set(route, definition);\n}\n\n/**\n * Look up a route's search params definition.\n * Returns undefined if the route hasn't been loaded yet.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function getSearchParamsDefinition(route: string): SearchParamsDefinition<any> | undefined {\n return registry.get(route);\n}\n"],"mappings":";AAYA,IAAM,2BAAW,IAAI,KAA0C;;;;;AAO/D,SAAgB,qBAAqB,OAAe,YAA+C;CACjG,SAAS,IAAI,OAAO,WAAW;;;;;;AAQjC,SAAgB,0BAA0B,OAAwD;CAChG,OAAO,SAAS,IAAI,MAAM"}
1
+ {"version":3,"file":"registry-Dp_hJL4s.js","names":[],"sources":["../../src/search-params/registry.ts"],"sourcesContent":["/**\n * Runtime registry for route-scoped search params definitions.\n *\n * When a route's modules load, the framework registers its search-params\n * definition here. useQueryStates('/route') resolves codecs from this map.\n *\n * Design doc: design/23-search-params.md §\"Runtime: Registration at Route Load\"\n */\n\nimport type { SearchParamsDefinition } from './define.js';\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst registry = new Map<string, SearchParamsDefinition<any>>();\n\n/**\n * Register a route's search params definition.\n * Called by the generated route manifest loader when a route's modules load.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function registerSearchParams(route: string, definition: SearchParamsDefinition<any>): void {\n registry.set(route, definition);\n}\n\n/**\n * Look up a route's search params definition.\n * Returns undefined if the route hasn't been loaded yet.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function getSearchParamsDefinition(route: string): SearchParamsDefinition<any> | undefined {\n return registry.get(route);\n}\n"],"mappings":";AAYA,IAAM,2BAAW,IAAI,KAA0C;;;;;AAO/D,SAAgB,qBAAqB,OAAe,YAA+C;CACjG,SAAS,IAAI,OAAO,WAAW;;;;;;AAQjC,SAAgB,0BAA0B,OAAwD;CAChG,OAAO,SAAS,IAAI,MAAM"}
@@ -1,4 +1,4 @@
1
- import { a as validateSync, i as isStandardSchema, r as isCodec } from "./schema-bridge-o0SwFtXW.js";
1
+ import { a as validateSync, i as isStandardSchema, r as isCodec } from "./schema-bridge-Cuy8BaYu.js";
2
2
  //#region src/params/resolve-schema.ts
3
3
  /**
4
4
  * Wrap a Standard Schema into a Codec for route params.
@@ -74,4 +74,4 @@ function resolveSchemaCodecs(segmentParams) {
74
74
  //#endregion
75
75
  export { toBracketKey as n, resolveSchemaCodecs as t };
76
76
 
77
- //# sourceMappingURL=resolve-schema-DU2nbVOi.js.map
77
+ //# sourceMappingURL=resolve-schema-D3CbcdCW.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"resolve-schema-DU2nbVOi.js","names":[],"sources":["../../src/params/resolve-schema.ts"],"sourcesContent":["/**\n * Schema resolution — resolves the global schema's segmentParams into\n * a runtime-ready codec map keyed by bracket name (e.g. '[id]').\n *\n * Bracket keys are preserved so that `[id]`, `[...id]`, and `[[...id]]`\n * remain distinct entries. The coercion layer reconstructs bracket keys\n * from matched segment metadata to look up the correct codec.\n *\n * Design doc: design/41-global-params.md §Pipeline Integration\n */\n\nimport type { Codec } from '../codec.js';\nimport { isCodec, isStandardSchema, validateSync } from '../schema-bridge.js';\nimport type { StandardSchemaV1 } from '../schema-bridge.js';\n\n/**\n * Wrap a Standard Schema into a Codec for route params.\n * Parse throws on failure (invalid param → 404). Serialize returns string.\n */\nfunction fromParamSchema<T>(fieldName: string, schema: StandardSchemaV1<T>): Codec<T> {\n return {\n parse(value: string | string[] | undefined): T {\n const result = validateSync(schema, value);\n if (!result.issues) {\n return result.value;\n }\n const messages = result.issues.map((i) => i.message).join(', ');\n throw new Error(`[timber] Param '${fieldName}' coercion failed: ${messages}`);\n },\n serialize(value: T): string | null {\n if (value === null || value === undefined) return null;\n if (Array.isArray(value)) return value.join('/');\n return String(value);\n },\n };\n}\n\n/**\n * Resolve a schema entry to a Codec. Accepts Codec<T> or StandardSchemaV1<T>.\n */\nfunction resolveCodec(fieldName: string, value: unknown): Codec<unknown> {\n if (isCodec(value)) return value;\n if (isStandardSchema(value)) return fromParamSchema(fieldName, value);\n throw new Error(`[timber] Schema field '${fieldName}' is not a valid codec or Standard Schema.`);\n}\n\n/**\n * Reconstruct the bracket key for a segment from its type and param name.\n * Used by the coercion layer to look up codecs in the bracket-keyed map.\n *\n * 'dynamic' + 'id' → '[id]'\n * 'catch-all' + 'slug' → '[...slug]'\n * 'optional-catch-all' + 'slug' → '[[...slug]]'\n */\nexport function toBracketKey(segmentType: string, paramName: string): string {\n switch (segmentType) {\n case 'catch-all':\n return `[...${paramName}]`;\n case 'optional-catch-all':\n return `[[...${paramName}]]`;\n default:\n return `[${paramName}]`;\n }\n}\n\nconst CATCH_ALL_RE = /^\\[\\.\\.\\..+\\]$/;\nconst OPTIONAL_CATCH_ALL_RE = /^\\[\\[\\.\\.\\..+\\]\\]$/;\n\nfunction isCatchAllKey(key: string): boolean {\n return CATCH_ALL_RE.test(key) || OPTIONAL_CATCH_ALL_RE.test(key);\n}\n\nfunction isOptionalCatchAllKey(key: string): boolean {\n return OPTIONAL_CATCH_ALL_RE.test(key);\n}\n\n/**\n * Resolve the global schema's segmentParams into a map of bracket-keyed\n * param name → resolved Codec.\n *\n * Keys are preserved exactly as written in `app/schema.ts` so that\n * `[id]`, `[...id]`, and `[[...id]]` remain distinct entries.\n * The coercion layer in `param-coercion.ts` reconstructs bracket keys\n * from matched segment metadata to look up the correct codec.\n *\n * Input: { '[id]': z.coerce.number(), '[...slug]': codec.string }\n * Output: { '[id]': Codec<number>, '[...slug]': Codec<string> }\n */\nexport function resolveSchemaCodecs(\n segmentParams: Record<string, unknown>\n): Record<string, Codec<unknown>> {\n const resolved: Record<string, Codec<unknown>> = Object.create(null);\n\n for (const [bracketName, value] of Object.entries(segmentParams)) {\n if (isCatchAllKey(bracketName) && isCodec(value) && !('__catchAll' in value)) {\n throw new Error(\n `[timber] Schema error: '${bracketName}' is a catch-all segment and requires an array codec ` +\n `(e.g. codec.catchAll(codec.string)). Got a scalar codec.`\n );\n }\n if (\n isOptionalCatchAllKey(bracketName) &&\n isCodec(value) &&\n '__catchAll' in value &&\n !('__optional' in value)\n ) {\n throw new Error(\n `[timber] Schema error: '${bracketName}' is an optional catch-all segment and requires codec.optionalCatchAll() ` +\n `(e.g. codec.optionalCatchAll(codec.string)). Got a non-optional catch-all codec.`\n );\n }\n resolved[bracketName] = resolveCodec(bracketName, value);\n }\n\n return resolved;\n}\n"],"mappings":";;;;;;AAmBA,SAAS,gBAAmB,WAAmB,QAAuC;CACpF,OAAO;EACL,MAAM,OAAyC;GAC7C,MAAM,SAAS,aAAa,QAAQ,MAAM;GAC1C,IAAI,CAAC,OAAO,QACV,OAAO,OAAO;GAEhB,MAAM,WAAW,OAAO,OAAO,KAAK,MAAM,EAAE,QAAQ,CAAC,KAAK,KAAK;GAC/D,MAAM,IAAI,MAAM,mBAAmB,UAAU,qBAAqB,WAAW;;EAE/E,UAAU,OAAyB;GACjC,IAAI,UAAU,QAAQ,UAAU,KAAA,GAAW,OAAO;GAClD,IAAI,MAAM,QAAQ,MAAM,EAAE,OAAO,MAAM,KAAK,IAAI;GAChD,OAAO,OAAO,MAAM;;EAEvB;;;;;AAMH,SAAS,aAAa,WAAmB,OAAgC;CACvE,IAAI,QAAQ,MAAM,EAAE,OAAO;CAC3B,IAAI,iBAAiB,MAAM,EAAE,OAAO,gBAAgB,WAAW,MAAM;CACrE,MAAM,IAAI,MAAM,0BAA0B,UAAU,4CAA4C;;;;;;;;;;AAWlG,SAAgB,aAAa,aAAqB,WAA2B;CAC3E,QAAQ,aAAR;EACE,KAAK,aACH,OAAO,OAAO,UAAU;EAC1B,KAAK,sBACH,OAAO,QAAQ,UAAU;EAC3B,SACE,OAAO,IAAI,UAAU;;;AAI3B,IAAM,eAAe;AACrB,IAAM,wBAAwB;AAE9B,SAAS,cAAc,KAAsB;CAC3C,OAAO,aAAa,KAAK,IAAI,IAAI,sBAAsB,KAAK,IAAI;;AAGlE,SAAS,sBAAsB,KAAsB;CACnD,OAAO,sBAAsB,KAAK,IAAI;;;;;;;;;;;;;;AAexC,SAAgB,oBACd,eACgC;CAChC,MAAM,WAA2C,OAAO,OAAO,KAAK;CAEpE,KAAK,MAAM,CAAC,aAAa,UAAU,OAAO,QAAQ,cAAc,EAAE;EAChE,IAAI,cAAc,YAAY,IAAI,QAAQ,MAAM,IAAI,EAAE,gBAAgB,QACpE,MAAM,IAAI,MACR,2BAA2B,YAAY,+GAExC;EAEH,IACE,sBAAsB,YAAY,IAClC,QAAQ,MAAM,IACd,gBAAgB,SAChB,EAAE,gBAAgB,QAElB,MAAM,IAAI,MACR,2BAA2B,YAAY,2JAExC;EAEH,SAAS,eAAe,aAAa,aAAa,MAAM;;CAG1D,OAAO"}
1
+ {"version":3,"file":"resolve-schema-D3CbcdCW.js","names":[],"sources":["../../src/params/resolve-schema.ts"],"sourcesContent":["/**\n * Schema resolution — resolves the global schema's segmentParams into\n * a runtime-ready codec map keyed by bracket name (e.g. '[id]').\n *\n * Bracket keys are preserved so that `[id]`, `[...id]`, and `[[...id]]`\n * remain distinct entries. The coercion layer reconstructs bracket keys\n * from matched segment metadata to look up the correct codec.\n *\n * Design doc: design/41-global-params.md §Pipeline Integration\n */\n\nimport type { Codec } from '../codec.js';\nimport { isCodec, isStandardSchema, validateSync } from '../schema-bridge.js';\nimport type { StandardSchemaV1 } from '../schema-bridge.js';\n\n/**\n * Wrap a Standard Schema into a Codec for route params.\n * Parse throws on failure (invalid param → 404). Serialize returns string.\n */\nfunction fromParamSchema<T>(fieldName: string, schema: StandardSchemaV1<T>): Codec<T> {\n return {\n parse(value: string | string[] | undefined): T {\n const result = validateSync(schema, value);\n if (!result.issues) {\n return result.value;\n }\n const messages = result.issues.map((i) => i.message).join(', ');\n throw new Error(`[timber] Param '${fieldName}' coercion failed: ${messages}`);\n },\n serialize(value: T): string | null {\n if (value === null || value === undefined) return null;\n if (Array.isArray(value)) return value.join('/');\n return String(value);\n },\n };\n}\n\n/**\n * Resolve a schema entry to a Codec. Accepts Codec<T> or StandardSchemaV1<T>.\n */\nfunction resolveCodec(fieldName: string, value: unknown): Codec<unknown> {\n if (isCodec(value)) return value;\n if (isStandardSchema(value)) return fromParamSchema(fieldName, value);\n throw new Error(`[timber] Schema field '${fieldName}' is not a valid codec or Standard Schema.`);\n}\n\n/**\n * Reconstruct the bracket key for a segment from its type and param name.\n * Used by the coercion layer to look up codecs in the bracket-keyed map.\n *\n * 'dynamic' + 'id' → '[id]'\n * 'catch-all' + 'slug' → '[...slug]'\n * 'optional-catch-all' + 'slug' → '[[...slug]]'\n */\nexport function toBracketKey(segmentType: string, paramName: string): string {\n switch (segmentType) {\n case 'catch-all':\n return `[...${paramName}]`;\n case 'optional-catch-all':\n return `[[...${paramName}]]`;\n default:\n return `[${paramName}]`;\n }\n}\n\nconst CATCH_ALL_RE = /^\\[\\.\\.\\..+\\]$/;\nconst OPTIONAL_CATCH_ALL_RE = /^\\[\\[\\.\\.\\..+\\]\\]$/;\n\nfunction isCatchAllKey(key: string): boolean {\n return CATCH_ALL_RE.test(key) || OPTIONAL_CATCH_ALL_RE.test(key);\n}\n\nfunction isOptionalCatchAllKey(key: string): boolean {\n return OPTIONAL_CATCH_ALL_RE.test(key);\n}\n\n/**\n * Resolve the global schema's segmentParams into a map of bracket-keyed\n * param name → resolved Codec.\n *\n * Keys are preserved exactly as written in `app/schema.ts` so that\n * `[id]`, `[...id]`, and `[[...id]]` remain distinct entries.\n * The coercion layer in `param-coercion.ts` reconstructs bracket keys\n * from matched segment metadata to look up the correct codec.\n *\n * Input: { '[id]': z.coerce.number(), '[...slug]': codec.string }\n * Output: { '[id]': Codec<number>, '[...slug]': Codec<string> }\n */\nexport function resolveSchemaCodecs(\n segmentParams: Record<string, unknown>\n): Record<string, Codec<unknown>> {\n const resolved: Record<string, Codec<unknown>> = Object.create(null);\n\n for (const [bracketName, value] of Object.entries(segmentParams)) {\n if (isCatchAllKey(bracketName) && isCodec(value) && !('__catchAll' in value)) {\n throw new Error(\n `[timber] Schema error: '${bracketName}' is a catch-all segment and requires an array codec ` +\n `(e.g. codec.catchAll(codec.string)). Got a scalar codec.`\n );\n }\n if (\n isOptionalCatchAllKey(bracketName) &&\n isCodec(value) &&\n '__catchAll' in value &&\n !('__optional' in value)\n ) {\n throw new Error(\n `[timber] Schema error: '${bracketName}' is an optional catch-all segment and requires codec.optionalCatchAll() ` +\n `(e.g. codec.optionalCatchAll(codec.string)). Got a non-optional catch-all codec.`\n );\n }\n resolved[bracketName] = resolveCodec(bracketName, value);\n }\n\n return resolved;\n}\n"],"mappings":";;;;;;AAmBA,SAAS,gBAAmB,WAAmB,QAAuC;CACpF,OAAO;EACL,MAAM,OAAyC;GAC7C,MAAM,SAAS,aAAa,QAAQ,MAAM;GAC1C,IAAI,CAAC,OAAO,QACV,OAAO,OAAO;GAEhB,MAAM,WAAW,OAAO,OAAO,KAAK,MAAM,EAAE,QAAQ,CAAC,KAAK,KAAK;GAC/D,MAAM,IAAI,MAAM,mBAAmB,UAAU,qBAAqB,WAAW;;EAE/E,UAAU,OAAyB;GACjC,IAAI,UAAU,QAAQ,UAAU,KAAA,GAAW,OAAO;GAClD,IAAI,MAAM,QAAQ,MAAM,EAAE,OAAO,MAAM,KAAK,IAAI;GAChD,OAAO,OAAO,MAAM;;EAEvB;;;;;AAMH,SAAS,aAAa,WAAmB,OAAgC;CACvE,IAAI,QAAQ,MAAM,EAAE,OAAO;CAC3B,IAAI,iBAAiB,MAAM,EAAE,OAAO,gBAAgB,WAAW,MAAM;CACrE,MAAM,IAAI,MAAM,0BAA0B,UAAU,4CAA4C;;;;;;;;;;AAWlG,SAAgB,aAAa,aAAqB,WAA2B;CAC3E,QAAQ,aAAR;EACE,KAAK,aACH,OAAO,OAAO,UAAU;EAC1B,KAAK,sBACH,OAAO,QAAQ,UAAU;EAC3B,SACE,OAAO,IAAI,UAAU;;;AAI3B,IAAM,eAAe;AACrB,IAAM,wBAAwB;AAE9B,SAAS,cAAc,KAAsB;CAC3C,OAAO,aAAa,KAAK,IAAI,IAAI,sBAAsB,KAAK,IAAI;;AAGlE,SAAS,sBAAsB,KAAsB;CACnD,OAAO,sBAAsB,KAAK,IAAI;;;;;;;;;;;;;;AAexC,SAAgB,oBACd,eACgC;CAChC,MAAM,WAA2C,OAAO,OAAO,KAAK;CAEpE,KAAK,MAAM,CAAC,aAAa,UAAU,OAAO,QAAQ,cAAc,EAAE;EAChE,IAAI,cAAc,YAAY,IAAI,QAAQ,MAAM,IAAI,EAAE,gBAAgB,QACpE,MAAM,IAAI,MACR,2BAA2B,YAAY,+GAExC;EAEH,IACE,sBAAsB,YAAY,IAClC,QAAQ,MAAM,IACd,gBAAgB,SAChB,EAAE,gBAAgB,QAElB,MAAM,IAAI,MACR,2BAA2B,YAAY,2JAExC;EAEH,SAAS,eAAe,aAAa,aAAa,MAAM;;CAG1D,OAAO"}
@@ -1,4 +1,4 @@
1
- import { o as _setGlobalRouter, u as globalRouter } from "./ssr-data-DVX8S7vj.js";
1
+ import { o as _setGlobalRouter, u as globalRouter } from "./ssr-data-DxXyoRrz.js";
2
2
  //#region src/client/router-ref.ts
3
3
  /**
4
4
  * Set the global router instance. Called once during bootstrap.
@@ -25,4 +25,4 @@ function getRouterOrNull() {
25
25
  //#endregion
26
26
  export { getRouterOrNull as n, setGlobalRouter as r, getRouter as t };
27
27
 
28
- //# sourceMappingURL=router-ref-B2-2kIIC.js.map
28
+ //# sourceMappingURL=router-ref-B50j42zB.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"router-ref-B2-2kIIC.js","names":[],"sources":["../../src/client/router-ref.ts"],"sourcesContent":["// Global router reference — shared between browser-entry and client hooks.\n//\n// Delegates to client/state.ts for the actual module-level variable.\n// This ensures singleton semantics regardless of import path — all\n// callers converge on the same state.ts instance via the barrel.\n//\n// See design/18-build-system.md §\"Module Singleton Strategy\"\n\nimport type { RouterInstance } from './router.js';\nimport { globalRouter, _setGlobalRouter } from './state.js';\n\n/**\n * Set the global router instance. Called once during bootstrap.\n */\nexport function setGlobalRouter(router: RouterInstance): void {\n _setGlobalRouter(router);\n}\n\n/**\n * Get the global router instance. Throws if called before bootstrap.\n * Used by client-side hooks (usePendingNavigation, etc.)\n */\nexport function getRouter(): RouterInstance {\n if (!globalRouter) {\n throw new Error('[timber] Router not initialized. getRouter() was called before bootstrap().');\n }\n return globalRouter;\n}\n\n/**\n * Get the global router instance or null if not yet initialized.\n * Used by useRouter() methods to avoid silent failures — callers\n * can log a meaningful warning instead of silently no-oping.\n */\nexport function getRouterOrNull(): RouterInstance | null {\n return globalRouter;\n}\n\n/**\n * Reset the global router to null. Used only in tests to isolate\n * module-level state between test cases.\n * @internal\n */\nexport function resetGlobalRouter(): void {\n _setGlobalRouter(null);\n}\n"],"mappings":";;;;;AAcA,SAAgB,gBAAgB,QAA8B;CAC5D,iBAAiB,OAAO;;;;;;AAO1B,SAAgB,YAA4B;CAC1C,IAAI,CAAC,cACH,MAAM,IAAI,MAAM,8EAA8E;CAEhG,OAAO;;;;;;;AAQT,SAAgB,kBAAyC;CACvD,OAAO"}
1
+ {"version":3,"file":"router-ref-B50j42zB.js","names":[],"sources":["../../src/client/router-ref.ts"],"sourcesContent":["// Global router reference — shared between browser-entry and client hooks.\n//\n// Delegates to client/state.ts for the actual module-level variable.\n// This ensures singleton semantics regardless of import path — all\n// callers converge on the same state.ts instance via the barrel.\n//\n// See design/18-build-system.md §\"Module Singleton Strategy\"\n\nimport type { RouterInstance } from './router.js';\nimport { globalRouter, _setGlobalRouter } from './state.js';\n\n/**\n * Set the global router instance. Called once during bootstrap.\n */\nexport function setGlobalRouter(router: RouterInstance): void {\n _setGlobalRouter(router);\n}\n\n/**\n * Get the global router instance. Throws if called before bootstrap.\n * Used by client-side hooks (usePendingNavigation, etc.)\n */\nexport function getRouter(): RouterInstance {\n if (!globalRouter) {\n throw new Error('[timber] Router not initialized. getRouter() was called before bootstrap().');\n }\n return globalRouter;\n}\n\n/**\n * Get the global router instance or null if not yet initialized.\n * Used by useRouter() methods to avoid silent failures — callers\n * can log a meaningful warning instead of silently no-oping.\n */\nexport function getRouterOrNull(): RouterInstance | null {\n return globalRouter;\n}\n\n/**\n * Reset the global router to null. Used only in tests to isolate\n * module-level state between test cases.\n * @internal\n */\nexport function resetGlobalRouter(): void {\n _setGlobalRouter(null);\n}\n"],"mappings":";;;;;AAcA,SAAgB,gBAAgB,QAA8B;CAC5D,iBAAiB,OAAO;;;;;;AAO1B,SAAgB,YAA4B;CAC1C,IAAI,CAAC,cACH,MAAM,IAAI,MAAM,8EAA8E;CAEhG,OAAO;;;;;;;AAQT,SAAgB,kBAAyC;CACvD,OAAO"}
@@ -40,7 +40,7 @@ function isCodec(value) {
40
40
  function fromSchema(schema) {
41
41
  return {
42
42
  parse(value) {
43
- const result = validateSync(schema, Array.isArray(value) ? value[value.length - 1] : value);
43
+ const result = validateSync(schema, Array.isArray(value) ? value[0] : value);
44
44
  if (!result.issues) return result.value;
45
45
  const defaultResult = validateSync(schema, void 0);
46
46
  if (!defaultResult.issues) return defaultResult.value;
@@ -83,4 +83,4 @@ function fromArraySchema(schema) {
83
83
  //#endregion
84
84
  export { validateSync as a, isStandardSchema as i, fromSchema as n, isCodec as r, fromArraySchema as t };
85
85
 
86
- //# sourceMappingURL=schema-bridge-o0SwFtXW.js.map
86
+ //# sourceMappingURL=schema-bridge-Cuy8BaYu.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"schema-bridge-o0SwFtXW.js","names":[],"sources":["../../src/schema-bridge.ts"],"sourcesContent":["/**\n * Standard Schema bridge — shared helpers for bridging Standard Schema-compatible\n * validation libraries (Zod, Valibot, ArkType) to the Codec<T> protocol.\n *\n * This module is the single source of truth for:\n * - StandardSchemaV1 interface (subset of the Standard Schema spec)\n * - validateSync() helper\n * - fromSchema() — bridge from Standard Schema to Codec<T>\n * - fromArraySchema() — bridge for array-valued codecs\n *\n * These are re-exported from @timber-js/app/search-params, @timber-js/app/segment-params,\n * and @timber-js/app/cookies for convenience. The canonical import is\n * @timber-js/app/codec.\n *\n * Design doc: design/23a-search-params-triage.md §\"Unify Codec<T> type\"\n */\n\nimport type { Codec } from './codec.js';\n\n// ---------------------------------------------------------------------------\n// Standard Schema interface (subset)\n//\n// Standard Schema (https://github.com/standard-schema/standard-schema) defines\n// a minimal interface that Zod ≥3.24, Valibot ≥1.0, and ArkType all implement.\n// We depend only on `~standard.validate` to avoid coupling to any specific lib.\n// ---------------------------------------------------------------------------\n\n/** Minimal Standard Schema interface for auto-detection. */\nexport interface StandardSchemaV1<Output = unknown> {\n '~standard': {\n validate(value: unknown): StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;\n };\n}\n\nexport type StandardSchemaResult<Output> =\n | { value: Output; issues?: undefined }\n | { value?: undefined; issues: ReadonlyArray<{ message: string }> };\n\n// ---------------------------------------------------------------------------\n// Sync validate helper\n// ---------------------------------------------------------------------------\n\n/**\n * Run a Standard Schema's `~standard.validate()` synchronously.\n *\n * Zod v4's signature includes `Promise` in the return union to satisfy the\n * Standard Schema spec, but in practice Zod always validates synchronously\n * for the schema types we use. We assert the result is sync and throw if\n * it isn't — codec parsing must be synchronous.\n */\nexport function validateSync<Output>(\n schema: StandardSchemaV1<Output>,\n value: unknown\n): StandardSchemaResult<Output> {\n const result = schema['~standard'].validate(value);\n if (result instanceof Promise) {\n throw new Error(\n '[timber] fromSchema: schema returned a Promise — only sync schemas are supported.'\n );\n }\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// Type guards\n// ---------------------------------------------------------------------------\n\n/** Check if a value is a Standard Schema object. */\nexport function isStandardSchema(value: unknown): value is StandardSchemaV1 {\n return (\n typeof value === 'object' &&\n value !== null &&\n '~standard' in value &&\n typeof (value as StandardSchemaV1)['~standard']?.validate === 'function'\n );\n}\n\n/** Check if a value is a Codec (has parse + serialize methods). */\nexport function isCodec(value: unknown): value is Codec<unknown> {\n return (\n typeof value === 'object' &&\n value !== null &&\n typeof (value as Codec<unknown>).parse === 'function' &&\n typeof (value as Codec<unknown>).serialize === 'function'\n );\n}\n\n// ---------------------------------------------------------------------------\n// fromSchema — bridge from Standard Schema to Codec<T>\n// ---------------------------------------------------------------------------\n\n/**\n * Bridge a Standard Schema-compatible schema (Zod, Valibot, ArkType) to a\n * Codec<T>.\n *\n * Parse: coerces the raw string through the schema. On validation failure,\n * parses `undefined` to get the schema's default value (the schema should have\n * a `.default()` call). If that also fails, returns `undefined`.\n *\n * Serialize: uses `String()` for primitives, `null` for null/undefined.\n *\n * ```ts\n * import { fromSchema } from '@timber-js/app/codec'\n * import { z } from 'zod/v4'\n *\n * const pageCodec = fromSchema(z.coerce.number().int().min(1).default(1))\n * ```\n */\nexport function fromSchema<T>(schema: StandardSchemaV1<T>): Codec<T> {\n return {\n parse(value: string | string[] | undefined): T {\n // For array inputs, take the last value (consistent with URLSearchParams.get())\n const input = Array.isArray(value) ? value[value.length - 1] : value;\n\n // Try parsing the raw value\n const result = validateSync(schema, input);\n if (!result.issues) {\n return result.value;\n }\n\n // On failure, try parsing undefined to get the default\n const defaultResult = validateSync(schema, undefined);\n if (!defaultResult.issues) {\n return defaultResult.value;\n }\n\n // No default available — return undefined (codec design choice)\n return undefined as T;\n },\n\n serialize(value: T): string | null {\n if (value === null || value === undefined) {\n return null;\n }\n return String(value);\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// fromArraySchema — bridge for array-valued codecs\n// ---------------------------------------------------------------------------\n\n/**\n * Bridge a Standard Schema for array values. Handles both single strings\n * and repeated query keys (`?tag=a&tag=b`).\n *\n * ```ts\n * import { fromArraySchema } from '@timber-js/app/codec'\n * import { z } from 'zod/v4'\n *\n * const tagsCodec = fromArraySchema(z.array(z.string()).default([]))\n * ```\n */\nexport function fromArraySchema<T>(schema: StandardSchemaV1<T>): Codec<T> {\n return {\n parse(value: string | string[] | undefined): T {\n // Coerce single string to array for array schemas\n let input: unknown = value;\n if (typeof value === 'string') {\n input = [value];\n } else if (value === undefined) {\n input = undefined;\n }\n\n const result = validateSync(schema, input);\n if (!result.issues) {\n return result.value;\n }\n\n // On failure, try undefined for default\n const defaultResult = validateSync(schema, undefined);\n if (!defaultResult.issues) {\n return defaultResult.value;\n }\n\n return undefined as T;\n },\n\n serialize(value: T): string | null {\n if (value === null || value === undefined) {\n return null;\n }\n if (Array.isArray(value)) {\n return value.length === 0 ? null : value.join(',');\n }\n return String(value);\n },\n };\n}\n"],"mappings":";;;;;;;;;AAkDA,SAAgB,aACd,QACA,OAC8B;CAC9B,MAAM,SAAS,OAAO,aAAa,SAAS,MAAM;CAClD,IAAI,kBAAkB,SACpB,MAAM,IAAI,MACR,oFACD;CAEH,OAAO;;;AAQT,SAAgB,iBAAiB,OAA2C;CAC1E,OACE,OAAO,UAAU,YACjB,UAAU,QACV,eAAe,SACf,OAAQ,MAA2B,cAAc,aAAa;;;AAKlE,SAAgB,QAAQ,OAAyC;CAC/D,OACE,OAAO,UAAU,YACjB,UAAU,QACV,OAAQ,MAAyB,UAAU,cAC3C,OAAQ,MAAyB,cAAc;;;;;;;;;;;;;;;;;;;AAyBnD,SAAgB,WAAc,QAAuC;CACnE,OAAO;EACL,MAAM,OAAyC;GAK7C,MAAM,SAAS,aAAa,QAHd,MAAM,QAAQ,MAAM,GAAG,MAAM,MAAM,SAAS,KAAK,MAGrB;GAC1C,IAAI,CAAC,OAAO,QACV,OAAO,OAAO;GAIhB,MAAM,gBAAgB,aAAa,QAAQ,KAAA,EAAU;GACrD,IAAI,CAAC,cAAc,QACjB,OAAO,cAAc;;EAOzB,UAAU,OAAyB;GACjC,IAAI,UAAU,QAAQ,UAAU,KAAA,GAC9B,OAAO;GAET,OAAO,OAAO,MAAM;;EAEvB;;;;;;;;;;;;;AAkBH,SAAgB,gBAAmB,QAAuC;CACxE,OAAO;EACL,MAAM,OAAyC;GAE7C,IAAI,QAAiB;GACrB,IAAI,OAAO,UAAU,UACnB,QAAQ,CAAC,MAAM;QACV,IAAI,UAAU,KAAA,GACnB,QAAQ,KAAA;GAGV,MAAM,SAAS,aAAa,QAAQ,MAAM;GAC1C,IAAI,CAAC,OAAO,QACV,OAAO,OAAO;GAIhB,MAAM,gBAAgB,aAAa,QAAQ,KAAA,EAAU;GACrD,IAAI,CAAC,cAAc,QACjB,OAAO,cAAc;;EAMzB,UAAU,OAAyB;GACjC,IAAI,UAAU,QAAQ,UAAU,KAAA,GAC9B,OAAO;GAET,IAAI,MAAM,QAAQ,MAAM,EACtB,OAAO,MAAM,WAAW,IAAI,OAAO,MAAM,KAAK,IAAI;GAEpD,OAAO,OAAO,MAAM;;EAEvB"}
1
+ {"version":3,"file":"schema-bridge-Cuy8BaYu.js","names":[],"sources":["../../src/schema-bridge.ts"],"sourcesContent":["/**\n * Standard Schema bridge — shared helpers for bridging Standard Schema-compatible\n * validation libraries (Zod, Valibot, ArkType) to the Codec<T> protocol.\n *\n * This module is the single source of truth for:\n * - StandardSchemaV1 interface (subset of the Standard Schema spec)\n * - validateSync() helper\n * - fromSchema() — bridge from Standard Schema to Codec<T>\n * - fromArraySchema() — bridge for array-valued codecs\n *\n * These are re-exported from @timber-js/app/search-params, @timber-js/app/segment-params,\n * and @timber-js/app/cookies for convenience. The canonical import is\n * @timber-js/app/codec.\n *\n * Design doc: design/23a-search-params-triage.md §\"Unify Codec<T> type\"\n */\n\nimport type { Codec } from './codec.js';\n\n// ---------------------------------------------------------------------------\n// Standard Schema interface (subset)\n//\n// Standard Schema (https://github.com/standard-schema/standard-schema) defines\n// a minimal interface that Zod ≥3.24, Valibot ≥1.0, and ArkType all implement.\n// We depend only on `~standard.validate` to avoid coupling to any specific lib.\n// ---------------------------------------------------------------------------\n\n/** Minimal Standard Schema interface for auto-detection. */\nexport interface StandardSchemaV1<Output = unknown> {\n '~standard': {\n validate(value: unknown): StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;\n };\n}\n\nexport type StandardSchemaResult<Output> =\n | { value: Output; issues?: undefined }\n | { value?: undefined; issues: ReadonlyArray<{ message: string }> };\n\n// ---------------------------------------------------------------------------\n// Sync validate helper\n// ---------------------------------------------------------------------------\n\n/**\n * Run a Standard Schema's `~standard.validate()` synchronously.\n *\n * Zod v4's signature includes `Promise` in the return union to satisfy the\n * Standard Schema spec, but in practice Zod always validates synchronously\n * for the schema types we use. We assert the result is sync and throw if\n * it isn't — codec parsing must be synchronous.\n */\nexport function validateSync<Output>(\n schema: StandardSchemaV1<Output>,\n value: unknown\n): StandardSchemaResult<Output> {\n const result = schema['~standard'].validate(value);\n if (result instanceof Promise) {\n throw new Error(\n '[timber] fromSchema: schema returned a Promise — only sync schemas are supported.'\n );\n }\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// Type guards\n// ---------------------------------------------------------------------------\n\n/** Check if a value is a Standard Schema object. */\nexport function isStandardSchema(value: unknown): value is StandardSchemaV1 {\n return (\n typeof value === 'object' &&\n value !== null &&\n '~standard' in value &&\n typeof (value as StandardSchemaV1)['~standard']?.validate === 'function'\n );\n}\n\n/** Check if a value is a Codec (has parse + serialize methods). */\nexport function isCodec(value: unknown): value is Codec<unknown> {\n return (\n typeof value === 'object' &&\n value !== null &&\n typeof (value as Codec<unknown>).parse === 'function' &&\n typeof (value as Codec<unknown>).serialize === 'function'\n );\n}\n\n// ---------------------------------------------------------------------------\n// fromSchema — bridge from Standard Schema to Codec<T>\n// ---------------------------------------------------------------------------\n\n/**\n * Bridge a Standard Schema-compatible schema (Zod, Valibot, ArkType) to a\n * Codec<T>.\n *\n * Parse: coerces the raw string through the schema. On validation failure,\n * parses `undefined` to get the schema's default value (the schema should have\n * a `.default()` call). If that also fails, returns `undefined`.\n *\n * Serialize: uses `String()` for primitives, `null` for null/undefined.\n *\n * ```ts\n * import { fromSchema } from '@timber-js/app/codec'\n * import { z } from 'zod/v4'\n *\n * const pageCodec = fromSchema(z.coerce.number().int().min(1).default(1))\n * ```\n */\nexport function fromSchema<T>(schema: StandardSchemaV1<T>): Codec<T> {\n return {\n parse(value: string | string[] | undefined): T {\n // For array inputs (duplicate query keys), use the first value.\n // Browsers and URLSearchParams.get() return the first occurrence.\n const input = Array.isArray(value) ? value[0] : value;\n\n // Try parsing the raw value\n const result = validateSync(schema, input);\n if (!result.issues) {\n return result.value;\n }\n\n // On failure, try parsing undefined to get the default\n const defaultResult = validateSync(schema, undefined);\n if (!defaultResult.issues) {\n return defaultResult.value;\n }\n\n // No default available — return undefined (codec design choice)\n return undefined as T;\n },\n\n serialize(value: T): string | null {\n if (value === null || value === undefined) {\n return null;\n }\n return String(value);\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// fromArraySchema — bridge for array-valued codecs\n// ---------------------------------------------------------------------------\n\n/**\n * Bridge a Standard Schema for array values. Handles both single strings\n * and repeated query keys (`?tag=a&tag=b`).\n *\n * ```ts\n * import { fromArraySchema } from '@timber-js/app/codec'\n * import { z } from 'zod/v4'\n *\n * const tagsCodec = fromArraySchema(z.array(z.string()).default([]))\n * ```\n */\nexport function fromArraySchema<T>(schema: StandardSchemaV1<T>): Codec<T> {\n return {\n parse(value: string | string[] | undefined): T {\n // Coerce single string to array for array schemas\n let input: unknown = value;\n if (typeof value === 'string') {\n input = [value];\n } else if (value === undefined) {\n input = undefined;\n }\n\n const result = validateSync(schema, input);\n if (!result.issues) {\n return result.value;\n }\n\n // On failure, try undefined for default\n const defaultResult = validateSync(schema, undefined);\n if (!defaultResult.issues) {\n return defaultResult.value;\n }\n\n return undefined as T;\n },\n\n serialize(value: T): string | null {\n if (value === null || value === undefined) {\n return null;\n }\n if (Array.isArray(value)) {\n return value.length === 0 ? null : value.join(',');\n }\n return String(value);\n },\n };\n}\n"],"mappings":";;;;;;;;;AAkDA,SAAgB,aACd,QACA,OAC8B;CAC9B,MAAM,SAAS,OAAO,aAAa,SAAS,MAAM;CAClD,IAAI,kBAAkB,SACpB,MAAM,IAAI,MACR,oFACD;CAEH,OAAO;;;AAQT,SAAgB,iBAAiB,OAA2C;CAC1E,OACE,OAAO,UAAU,YACjB,UAAU,QACV,eAAe,SACf,OAAQ,MAA2B,cAAc,aAAa;;;AAKlE,SAAgB,QAAQ,OAAyC;CAC/D,OACE,OAAO,UAAU,YACjB,UAAU,QACV,OAAQ,MAAyB,UAAU,cAC3C,OAAQ,MAAyB,cAAc;;;;;;;;;;;;;;;;;;;AAyBnD,SAAgB,WAAc,QAAuC;CACnE,OAAO;EACL,MAAM,OAAyC;GAM7C,MAAM,SAAS,aAAa,QAHd,MAAM,QAAQ,MAAM,GAAG,MAAM,KAAK,MAGN;GAC1C,IAAI,CAAC,OAAO,QACV,OAAO,OAAO;GAIhB,MAAM,gBAAgB,aAAa,QAAQ,KAAA,EAAU;GACrD,IAAI,CAAC,cAAc,QACjB,OAAO,cAAc;;EAOzB,UAAU,OAAyB;GACjC,IAAI,UAAU,QAAQ,UAAU,KAAA,GAC9B,OAAO;GAET,OAAO,OAAO,MAAM;;EAEvB;;;;;;;;;;;;;AAkBH,SAAgB,gBAAmB,QAAuC;CACxE,OAAO;EACL,MAAM,OAAyC;GAE7C,IAAI,QAAiB;GACrB,IAAI,OAAO,UAAU,UACnB,QAAQ,CAAC,MAAM;QACV,IAAI,UAAU,KAAA,GACnB,QAAQ,KAAA;GAGV,MAAM,SAAS,aAAa,QAAQ,MAAM;GAC1C,IAAI,CAAC,OAAO,QACV,OAAO,OAAO;GAIhB,MAAM,gBAAgB,aAAa,QAAQ,KAAA,EAAU;GACrD,IAAI,CAAC,cAAc,QACjB,OAAO,cAAc;;EAMzB,UAAU,OAAyB;GACjC,IAAI,UAAU,QAAQ,UAAU,KAAA,GAC9B,OAAO;GAET,IAAI,MAAM,QAAQ,MAAM,EACtB,OAAO,MAAM,WAAW,IAAI,OAAO,MAAM,KAAK,IAAI;GAEpD,OAAO,OAAO,MAAM;;EAEvB"}
@@ -31,4 +31,4 @@ function SegmentProvider({ segments, segmentId: _segmentId, parallelRouteKeys, c
31
31
  //#endregion
32
32
  export { useSegmentContext as n, SegmentProvider as t };
33
33
 
34
- //# sourceMappingURL=segment-context-Bg0pFbLT.js.map
34
+ //# sourceMappingURL=segment-context-BnxoPSFQ.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"segment-context-Bg0pFbLT.js","names":[],"sources":["../../src/client/segment-context.ts"],"sourcesContent":["/**\n * Segment Context — provides layout segment position for useSelectedLayoutSegment hooks.\n *\n * Each layout in the segment tree is wrapped with a SegmentProvider that stores\n * the URL segments from root to the current layout level. The hooks read this\n * context to determine which child segments are active below the calling layout.\n *\n * The context value is intentionally minimal: just the segment path array and\n * parallel route keys. No internal cache details are exposed.\n *\n * Design docs: design/19-client-navigation.md, design/14-ecosystem.md\n */\n\n'use client';\n\nimport { createContext, useContext, createElement, useMemo } from 'react';\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport interface SegmentContextValue {\n /** URL segments from root to this layout (e.g. ['', 'dashboard', 'settings']) */\n segments: string[];\n /** Parallel route slot keys available at this layout level (e.g. ['sidebar', 'modal']) */\n parallelRouteKeys: string[];\n}\n\n// ─── Context ─────────────────────────────────────────────────────\n\nconst SegmentContext = createContext<SegmentContextValue | null>(null);\n\n/** Read the segment context. Returns null if no provider is above this component. */\nexport function useSegmentContext(): SegmentContextValue | null {\n return useContext(SegmentContext);\n}\n\n// ─── Provider ────────────────────────────────────────────────────\n\ninterface SegmentProviderProps {\n segments: string[];\n /**\n * Unique identifier for this segment, used by the client-side segment\n * merger for element caching. For route groups this includes the group\n * name (e.g., \"/(marketing)\") since groups share their parent's urlPath.\n * Falls back to the reconstructed path from `segments` if not provided.\n */\n segmentId?: string;\n parallelRouteKeys: string[];\n children: React.ReactNode;\n}\n\n/**\n * Wraps each layout to provide segment position context.\n * Injected by rsc-entry.ts during element tree construction.\n */\nexport function SegmentProvider({\n segments,\n segmentId: _segmentId,\n parallelRouteKeys,\n children,\n}: SegmentProviderProps) {\n const value = useMemo(\n () => ({ segments, parallelRouteKeys }),\n // segments and parallelRouteKeys are static per layout — they don't change\n // across navigations. The layout's position in the tree is fixed.\n // Intentionally using derived keys — segments/parallelRouteKeys are static per layout\n [segments.join('/'), parallelRouteKeys.join(',')]\n );\n return createElement(SegmentContext.Provider, { value }, children);\n}\n"],"mappings":";;;;;;;;;;;;;;AA4BA,IAAM,iBAAiB,cAA0C,KAAK;;AAGtE,SAAgB,oBAAgD;CAC9D,OAAO,WAAW,eAAe;;;;;;AAsBnC,SAAgB,gBAAgB,EAC9B,UACA,WAAW,YACX,mBACA,YACuB;CACvB,MAAM,QAAQ,eACL;EAAE;EAAU;EAAmB,GAItC,CAAC,SAAS,KAAK,IAAI,EAAE,kBAAkB,KAAK,IAAI,CAAC,CAClD;CACD,OAAO,cAAc,eAAe,UAAU,EAAE,OAAO,EAAE,SAAS"}
1
+ {"version":3,"file":"segment-context-BnxoPSFQ.js","names":[],"sources":["../../src/client/segment-context.ts"],"sourcesContent":["/**\n * Segment Context — provides layout segment position for useSelectedLayoutSegment hooks.\n *\n * Each layout in the segment tree is wrapped with a SegmentProvider that stores\n * the URL segments from root to the current layout level. The hooks read this\n * context to determine which child segments are active below the calling layout.\n *\n * The context value is intentionally minimal: just the segment path array and\n * parallel route keys. No internal cache details are exposed.\n *\n * Design docs: design/19-client-navigation.md, design/14-ecosystem.md\n */\n\n'use client';\n\nimport { createContext, useContext, createElement, useMemo } from 'react';\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport interface SegmentContextValue {\n /** URL segments from root to this layout (e.g. ['', 'dashboard', 'settings']) */\n segments: string[];\n /** Parallel route slot keys available at this layout level (e.g. ['sidebar', 'modal']) */\n parallelRouteKeys: string[];\n}\n\n// ─── Context ─────────────────────────────────────────────────────\n\nconst SegmentContext = createContext<SegmentContextValue | null>(null);\n\n/** Read the segment context. Returns null if no provider is above this component. */\nexport function useSegmentContext(): SegmentContextValue | null {\n return useContext(SegmentContext);\n}\n\n// ─── Provider ────────────────────────────────────────────────────\n\ninterface SegmentProviderProps {\n segments: string[];\n /**\n * Unique identifier for this segment, used by the client-side segment\n * merger for element caching. For route groups this includes the group\n * name (e.g., \"/(marketing)\") since groups share their parent's urlPath.\n * Falls back to the reconstructed path from `segments` if not provided.\n */\n segmentId?: string;\n parallelRouteKeys: string[];\n children: React.ReactNode;\n}\n\n/**\n * Wraps each layout to provide segment position context.\n * Injected by rsc-entry.ts during element tree construction.\n */\nexport function SegmentProvider({\n segments,\n segmentId: _segmentId,\n parallelRouteKeys,\n children,\n}: SegmentProviderProps) {\n const value = useMemo(\n () => ({ segments, parallelRouteKeys }),\n // segments and parallelRouteKeys are static per layout — they don't change\n // across navigations. The layout's position in the tree is fixed.\n // Intentionally using derived keys — segments/parallelRouteKeys are static per layout\n [segments.join('/'), parallelRouteKeys.join(',')]\n );\n return createElement(SegmentContext.Provider, { value }, children);\n}\n"],"mappings":";;;;;;;;;;;;;;AA4BA,IAAM,iBAAiB,cAA0C,KAAK;;AAGtE,SAAgB,oBAAgD;CAC9D,OAAO,WAAW,eAAe;;;;;;AAsBnC,SAAgB,gBAAgB,EAC9B,UACA,WAAW,YACX,mBACA,YACuB;CACvB,MAAM,QAAQ,eACL;EAAE;EAAU;EAAmB,GAItC,CAAC,SAAS,KAAK,IAAI,EAAE,kBAAkB,KAAK,IAAI,CAAC,CAClD;CACD,OAAO,cAAc,eAAe,UAAU,EAAE,OAAO,EAAE,SAAS"}
@@ -85,4 +85,4 @@ function getSsrData() {
85
85
  //#endregion
86
86
  export { _setCurrentParams as a, cachedSearchParams as c, _setCachedSearch as i, currentParams as l, getSsrData as n, _setGlobalRouter as o, setSsrData as r, cachedSearch as s, clearSsrData as t, globalRouter as u };
87
87
 
88
- //# sourceMappingURL=ssr-data-DVX8S7vj.js.map
88
+ //# sourceMappingURL=ssr-data-DxXyoRrz.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"ssr-data-DVX8S7vj.js","names":[],"sources":["../../src/client/state.ts","../../src/client/ssr-data.ts"],"sourcesContent":["/**\n * Centralized client singleton state registry.\n *\n * ALL mutable module-level state that must have singleton semantics across\n * the client bundle lives here. Individual modules (router-ref.ts, ssr-data.ts,\n * use-segment-params.ts, use-search-params.ts, unload-guard.ts) import from this file\n * and re-export thin wrapper functions.\n *\n * Why: In Vite dev, a module is instantiated separately if reached via different\n * import paths (e.g., relative `./foo.js` vs barrel `@timber-js/app/client`).\n * By centralizing all mutable state in a single module that is always reached\n * through the same dependency chain (barrel → wrapper → state.ts), we guarantee\n * a single instance of every piece of shared state.\n *\n * DO NOT import this file from outside client/. Server code must never depend\n * on client state. The barrel (client/index.ts) is the public entry point.\n *\n * See design/18-build-system.md §\"Module Singleton Strategy\" and\n * §\"Singleton State Registry\".\n */\n\nimport type { RouterInstance } from './router.js';\nimport type { SsrData } from './ssr-data.js';\n\n// ─── Router (from router-ref.ts) ──────────────────────────────────────────\n\n/** The global router singleton — set once during bootstrap. */\nexport let globalRouter: RouterInstance | null = null;\n\nexport function _setGlobalRouter(router: RouterInstance | null): void {\n globalRouter = router;\n}\n\n// ─── SSR Data Provider (from ssr-data.ts) ──────────────────────────────────\n\n/**\n * ALS-backed SSR data provider. When registered, getSsrData() reads from\n * this function (ALS store) instead of module-level currentSsrData.\n */\nexport let ssrDataProvider: (() => SsrData | undefined) | undefined;\n\nexport function _setSsrDataProvider(provider: (() => SsrData | undefined) | undefined): void {\n ssrDataProvider = provider;\n}\n\n/** Fallback SSR data for tests and environments without ALS. */\nexport let currentSsrData: SsrData | undefined;\n\nexport function _setCurrentSsrData(data: SsrData | undefined): void {\n currentSsrData = data;\n}\n\n// ─── Route Params (from use-segment-params.ts) ──────────────────────────────────────\n\n/** Current route params snapshot — replaced (not mutated) on each navigation. */\nexport let currentParams: Record<string, string | string[]> = {};\n\nexport function _setCurrentParams(params: Record<string, string | string[]>): void {\n currentParams = params;\n}\n\n/** Listeners notified when currentParams changes. */\nexport const paramsListeners = new Set<() => void>();\n\n// ─── Search Params Cache (from use-search-params.ts) ────────────────────────\n\n/** Cached search string — avoids reparsing when URL hasn't changed. */\nexport let cachedSearch = '';\nexport let cachedSearchParams = new URLSearchParams();\n\nexport function _setCachedSearch(search: string, params: URLSearchParams): void {\n cachedSearch = search;\n cachedSearchParams = params;\n}\n\n// ─── Unload Guard (from unload-guard.ts) ─────────────────────────────────────\n\n/** Whether the page is currently being unloaded. */\nexport let unloading = false;\n\nexport function _setUnloading(value: boolean): void {\n unloading = value;\n}\n","/**\n * SSR Data — per-request state for client hooks during server-side rendering.\n *\n * RSC and SSR are separate Vite module graphs (see design/18-build-system.md),\n * so the RSC environment's request-context ALS is not visible to SSR modules.\n * This module provides getter/setter functions that ssr-entry.ts uses to\n * populate per-request data for React's render.\n *\n * Request isolation: On the server, ssr-entry.ts registers an ALS-backed\n * provider via registerSsrDataProvider(). getSsrData() reads from the ALS\n * store, ensuring correct per-request data even when Suspense boundaries\n * resolve asynchronously across concurrent requests. The module-level\n * setSsrData/clearSsrData functions are kept as a fallback for tests\n * and environments without ALS.\n *\n * IMPORTANT: This module must NOT import node:async_hooks or any Node.js-only\n * APIs, as it's imported by 'use client' hooks that are bundled for the browser.\n * The ALS instance lives in ssr-entry.ts (server-only); this module only holds\n * a reference to the provider function.\n *\n * All mutable state is delegated to client/state.ts for singleton guarantees.\n * See design/18-build-system.md §\"Singleton State Registry\"\n */\n\nimport {\n ssrDataProvider,\n currentSsrData,\n _setSsrDataProvider,\n _setCurrentSsrData,\n} from './state.js';\n\n// ─── Types ────────────────────────────────────────────────────────\n\nexport interface SsrData {\n /** The request's URL pathname (e.g. '/dashboard/settings') */\n pathname: string;\n /** The request's search params as a plain record */\n searchParams: Record<string, string>;\n /** The request's cookies as name→value pairs */\n cookies: Map<string, string>;\n /** The request's route params (e.g. { id: '123' }) */\n params: Record<string, string | string[]>;\n /**\n * Mutable reference to NavContext for error boundary → pipeline communication.\n *\n * When TimberErrorBoundary catches a DenySignal during SSR, it:\n * 1. Sets `statusCode` to the deny status (e.g., 403) — so the HTTP\n * Response has the correct status code without a re-render.\n * 2. Sets `_denyHandledByBoundary = true` — so the pipeline skips\n * the redundant renderDenyPage() re-render.\n *\n * This runs synchronously during Fizz rendering, BEFORE onShellReady,\n * so the status code is committed before any bytes are sent.\n *\n * See TIM-664, design/04-authorization.md §\"React.cache Scope in Deny/Error Re-renders\"\n */\n _navContext?: { statusCode?: number; _denyHandledByBoundary?: boolean };\n}\n\n// ─── ALS-Backed Provider ─────────────────────────────────────────\n//\n// Server-side code (ssr-entry.ts) registers a provider that reads\n// from AsyncLocalStorage. This avoids importing node:async_hooks\n// in this browser-bundled module.\n//\n// Module singleton guarantee: In Vite's SSR environment, both\n// ssr-entry.ts (via #/client/ssr-data.js) and client component hooks\n// (via @timber-js/app/client) must resolve to the SAME module instance\n// of this file. The timber-shims plugin ensures this by remapping\n// @timber-js/app/client → src/client/index.ts in the SSR environment.\n// Without this remap, @timber-js/app/client resolves to dist/ (via\n// package.json exports), creating a split where registerSsrDataProvider\n// writes to one instance but getSsrData reads from another.\n// See timber-shims plugin resolveId for details.\n\n/**\n * Register an ALS-backed SSR data provider. Called once at module load\n * by ssr-entry.ts to wire up per-request data via AsyncLocalStorage.\n *\n * When registered, getSsrData() reads from the provider (ALS store)\n * instead of module-level state, ensuring correct isolation for\n * concurrent requests with streaming Suspense.\n */\nexport function registerSsrDataProvider(provider: () => SsrData | undefined): void {\n _setSsrDataProvider(provider);\n}\n\n// ─── Module-Level Fallback ────────────────────────────────────────\n//\n// Used by tests and as a fallback when no ALS provider is registered.\n\n/**\n * Set the SSR data for the current request via module-level state.\n *\n * In production, ssr-entry.ts uses ALS (runWithSsrData) instead.\n * This function is retained for tests and as a fallback.\n */\nexport function setSsrData(data: SsrData): void {\n _setCurrentSsrData(data);\n}\n\n/**\n * Clear the SSR data after rendering completes.\n *\n * In production, ALS scope handles cleanup automatically.\n * This function is retained for tests and as a fallback.\n */\nexport function clearSsrData(): void {\n _setCurrentSsrData(undefined);\n}\n\n/**\n * Read the current request's SSR data. Returns undefined when called\n * outside an SSR render (i.e. on the client after hydration).\n *\n * Prefers the ALS-backed provider when registered (server-side),\n * falling back to module-level state (tests, legacy).\n *\n * Used by client hooks' server snapshot functions.\n */\nexport function getSsrData(): SsrData | undefined {\n if (ssrDataProvider) {\n return ssrDataProvider();\n }\n return currentSsrData;\n}\n"],"mappings":";;AA2BA,IAAW,eAAsC;AAEjD,SAAgB,iBAAiB,QAAqC;CACpE,eAAe;;;;;;AASjB,IAAW;;AAOX,IAAW;AAEX,SAAgB,mBAAmB,MAAiC;CAClE,iBAAiB;;;AAMnB,IAAW,gBAAmD,EAAE;AAEhE,SAAgB,kBAAkB,QAAiD;CACjF,gBAAgB;;;AASlB,IAAW,eAAe;AAC1B,IAAW,qBAAqB,IAAI,iBAAiB;AAErD,SAAgB,iBAAiB,QAAgB,QAA+B;CAC9E,eAAe;CACf,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACyBvB,SAAgB,WAAW,MAAqB;CAC9C,mBAAmB,KAAK;;;;;;;;AAS1B,SAAgB,eAAqB;CACnC,mBAAmB,KAAA,EAAU;;;;;;;;;;;AAY/B,SAAgB,aAAkC;CAChD,IAAI,iBACF,OAAO,iBAAiB;CAE1B,OAAO"}
1
+ {"version":3,"file":"ssr-data-DxXyoRrz.js","names":[],"sources":["../../src/client/state.ts","../../src/client/ssr-data.ts"],"sourcesContent":["/**\n * Centralized client singleton state registry.\n *\n * ALL mutable module-level state that must have singleton semantics across\n * the client bundle lives here. Individual modules (router-ref.ts, ssr-data.ts,\n * use-segment-params.ts, use-search-params.ts, unload-guard.ts) import from this file\n * and re-export thin wrapper functions.\n *\n * Why: In Vite dev, a module is instantiated separately if reached via different\n * import paths (e.g., relative `./foo.js` vs barrel `@timber-js/app/client`).\n * By centralizing all mutable state in a single module that is always reached\n * through the same dependency chain (barrel → wrapper → state.ts), we guarantee\n * a single instance of every piece of shared state.\n *\n * DO NOT import this file from outside client/. Server code must never depend\n * on client state. The barrel (client/index.ts) is the public entry point.\n *\n * See design/18-build-system.md §\"Module Singleton Strategy\" and\n * §\"Singleton State Registry\".\n */\n\nimport type { RouterInstance } from './router.js';\nimport type { SsrData } from './ssr-data.js';\n\n// ─── Router (from router-ref.ts) ──────────────────────────────────────────\n\n/** The global router singleton — set once during bootstrap. */\nexport let globalRouter: RouterInstance | null = null;\n\nexport function _setGlobalRouter(router: RouterInstance | null): void {\n globalRouter = router;\n}\n\n// ─── SSR Data Provider (from ssr-data.ts) ──────────────────────────────────\n\n/**\n * ALS-backed SSR data provider. When registered, getSsrData() reads from\n * this function (ALS store) instead of module-level currentSsrData.\n */\nexport let ssrDataProvider: (() => SsrData | undefined) | undefined;\n\nexport function _setSsrDataProvider(provider: (() => SsrData | undefined) | undefined): void {\n ssrDataProvider = provider;\n}\n\n/** Fallback SSR data for tests and environments without ALS. */\nexport let currentSsrData: SsrData | undefined;\n\nexport function _setCurrentSsrData(data: SsrData | undefined): void {\n currentSsrData = data;\n}\n\n// ─── Route Params (from use-segment-params.ts) ──────────────────────────────────────\n\n/** Current route params snapshot — replaced (not mutated) on each navigation. */\nexport let currentParams: Record<string, string | string[]> = {};\n\nexport function _setCurrentParams(params: Record<string, string | string[]>): void {\n currentParams = params;\n}\n\n/** Listeners notified when currentParams changes. */\nexport const paramsListeners = new Set<() => void>();\n\n// ─── Search Params Cache (from use-search-params.ts) ────────────────────────\n\n/** Cached search string — avoids reparsing when URL hasn't changed. */\nexport let cachedSearch = '';\nexport let cachedSearchParams = new URLSearchParams();\n\nexport function _setCachedSearch(search: string, params: URLSearchParams): void {\n cachedSearch = search;\n cachedSearchParams = params;\n}\n\n// ─── Unload Guard (from unload-guard.ts) ─────────────────────────────────────\n\n/** Whether the page is currently being unloaded. */\nexport let unloading = false;\n\nexport function _setUnloading(value: boolean): void {\n unloading = value;\n}\n","/**\n * SSR Data — per-request state for client hooks during server-side rendering.\n *\n * RSC and SSR are separate Vite module graphs (see design/18-build-system.md),\n * so the RSC environment's request-context ALS is not visible to SSR modules.\n * This module provides getter/setter functions that ssr-entry.ts uses to\n * populate per-request data for React's render.\n *\n * Request isolation: On the server, ssr-entry.ts registers an ALS-backed\n * provider via registerSsrDataProvider(). getSsrData() reads from the ALS\n * store, ensuring correct per-request data even when Suspense boundaries\n * resolve asynchronously across concurrent requests. The module-level\n * setSsrData/clearSsrData functions are kept as a fallback for tests\n * and environments without ALS.\n *\n * IMPORTANT: This module must NOT import node:async_hooks or any Node.js-only\n * APIs, as it's imported by 'use client' hooks that are bundled for the browser.\n * The ALS instance lives in ssr-entry.ts (server-only); this module only holds\n * a reference to the provider function.\n *\n * All mutable state is delegated to client/state.ts for singleton guarantees.\n * See design/18-build-system.md §\"Singleton State Registry\"\n */\n\nimport {\n ssrDataProvider,\n currentSsrData,\n _setSsrDataProvider,\n _setCurrentSsrData,\n} from './state.js';\n\n// ─── Types ────────────────────────────────────────────────────────\n\nexport interface SsrData {\n /** The request's URL pathname (e.g. '/dashboard/settings') */\n pathname: string;\n /** The request's search params as a plain record */\n searchParams: Record<string, string>;\n /** The request's cookies as name→value pairs */\n cookies: Map<string, string>;\n /** The request's route params (e.g. { id: '123' }) */\n params: Record<string, string | string[]>;\n /**\n * Mutable reference to NavContext for error boundary → pipeline communication.\n *\n * When TimberErrorBoundary catches a DenySignal during SSR, it:\n * 1. Sets `statusCode` to the deny status (e.g., 403) — so the HTTP\n * Response has the correct status code without a re-render.\n * 2. Sets `_denyHandledByBoundary = true` — so the pipeline skips\n * the redundant renderDenyPage() re-render.\n *\n * This runs synchronously during Fizz rendering, BEFORE onShellReady,\n * so the status code is committed before any bytes are sent.\n *\n * See TIM-664, design/04-authorization.md §\"React.cache Scope in Deny/Error Re-renders\"\n */\n _navContext?: { statusCode?: number; _denyHandledByBoundary?: boolean };\n}\n\n// ─── ALS-Backed Provider ─────────────────────────────────────────\n//\n// Server-side code (ssr-entry.ts) registers a provider that reads\n// from AsyncLocalStorage. This avoids importing node:async_hooks\n// in this browser-bundled module.\n//\n// Module singleton guarantee: In Vite's SSR environment, both\n// ssr-entry.ts (via #/client/ssr-data.js) and client component hooks\n// (via @timber-js/app/client) must resolve to the SAME module instance\n// of this file. The timber-shims plugin ensures this by remapping\n// @timber-js/app/client → src/client/index.ts in the SSR environment.\n// Without this remap, @timber-js/app/client resolves to dist/ (via\n// package.json exports), creating a split where registerSsrDataProvider\n// writes to one instance but getSsrData reads from another.\n// See timber-shims plugin resolveId for details.\n\n/**\n * Register an ALS-backed SSR data provider. Called once at module load\n * by ssr-entry.ts to wire up per-request data via AsyncLocalStorage.\n *\n * When registered, getSsrData() reads from the provider (ALS store)\n * instead of module-level state, ensuring correct isolation for\n * concurrent requests with streaming Suspense.\n */\nexport function registerSsrDataProvider(provider: () => SsrData | undefined): void {\n _setSsrDataProvider(provider);\n}\n\n// ─── Module-Level Fallback ────────────────────────────────────────\n//\n// Used by tests and as a fallback when no ALS provider is registered.\n\n/**\n * Set the SSR data for the current request via module-level state.\n *\n * In production, ssr-entry.ts uses ALS (runWithSsrData) instead.\n * This function is retained for tests and as a fallback.\n */\nexport function setSsrData(data: SsrData): void {\n _setCurrentSsrData(data);\n}\n\n/**\n * Clear the SSR data after rendering completes.\n *\n * In production, ALS scope handles cleanup automatically.\n * This function is retained for tests and as a fallback.\n */\nexport function clearSsrData(): void {\n _setCurrentSsrData(undefined);\n}\n\n/**\n * Read the current request's SSR data. Returns undefined when called\n * outside an SSR render (i.e. on the client after hydration).\n *\n * Prefers the ALS-backed provider when registered (server-side),\n * falling back to module-level state (tests, legacy).\n *\n * Used by client hooks' server snapshot functions.\n */\nexport function getSsrData(): SsrData | undefined {\n if (ssrDataProvider) {\n return ssrDataProvider();\n }\n return currentSsrData;\n}\n"],"mappings":";;AA2BA,IAAW,eAAsC;AAEjD,SAAgB,iBAAiB,QAAqC;CACpE,eAAe;;;;;;AASjB,IAAW;;AAOX,IAAW;AAEX,SAAgB,mBAAmB,MAAiC;CAClE,iBAAiB;;;AAMnB,IAAW,gBAAmD,EAAE;AAEhE,SAAgB,kBAAkB,QAAiD;CACjF,gBAAgB;;;AASlB,IAAW,eAAe;AAC1B,IAAW,qBAAqB,IAAI,iBAAiB;AAErD,SAAgB,iBAAiB,QAAgB,QAA+B;CAC9E,eAAe;CACf,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACyBvB,SAAgB,WAAW,MAAqB;CAC9C,mBAAmB,KAAK;;;;;;;;AAS1B,SAAgB,eAAqB;CACnC,mBAAmB,KAAA,EAAU;;;;;;;;;;;AAY/B,SAAgB,aAAkC;CAChD,IAAI,iBACF,OAAO,iBAAiB;CAE1B,OAAO"}
@@ -81,4 +81,4 @@ function triggerStaleReload() {
81
81
  //#endregion
82
82
  export { stale_reload_exports as n, triggerStaleReload as r, isStaleClientReference as t };
83
83
 
84
- //# sourceMappingURL=stale-reload-CCqb62Z3.js.map
84
+ //# sourceMappingURL=stale-reload-DIIIVGh_.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"stale-reload-CCqb62Z3.js","names":[],"sources":["../../src/client/stale-reload.ts"],"sourcesContent":["/**\n * Stale Client Reference Reload\n *\n * When a new deployment ships updated bundles, clients running stale\n * JavaScript may encounter \"Could not find the module\" errors during\n * RSC Flight stream decoding. This happens because the RSC payload\n * references module IDs from the new bundle that don't exist in the\n * old client bundle.\n *\n * This module detects these specific errors and triggers a full page\n * reload so the browser fetches the new bundle. A sessionStorage flag\n * guards against infinite reload loops.\n *\n * See: LOCAL-332\n */\n\nconst RELOAD_FLAG_KEY = '__timber_stale_reload';\n\n/**\n * In-memory fallback counter for environments where sessionStorage is\n * unavailable (private browsing, storage full, extension interference).\n * Incremented each time triggerStaleReload() falls into the catch path.\n * If the counter exceeds 0 on a subsequent call, the reload is suppressed\n * to prevent an infinite loop. Resets naturally on page load (module\n * re-evaluates) and can be manually reset via clearStaleReloadFlag().\n */\nlet memoryReloadCount = 0;\n\n/**\n * Check if an error is a stale client reference error from React's\n * Flight client. These errors have the message pattern:\n * \"Could not find the module \\\"<id>\\\"\"\n *\n * This is thrown by react-server-dom-webpack's client when the RSC\n * payload references a module ID that doesn't exist in the client's\n * module map — typically because the server has been redeployed with\n * new bundle hashes while the client is still running old JavaScript.\n */\nexport function isStaleClientReference(error: unknown): boolean {\n if (!(error instanceof Error)) return false;\n const msg = error.message;\n return msg.includes('Could not find the module') || msg.includes('client reference not found');\n}\n\n/**\n * Check if an error is a chunk load failure from a dynamic import.\n *\n * After a deployment, old chunk filenames no longer exist. When the client\n * tries to dynamically import a chunk that's been replaced, the browser\n * throws one of these errors:\n *\n * - Chromium: \"Failed to fetch dynamically imported module: <url>\"\n * - Firefox: \"error loading dynamically imported module: <url>\"\n * - Safari: \"Importing a module script failed.\"\n * - Vite/Rollup: \"Unable to preload CSS for <url>\"\n *\n * See TIM-446\n */\nexport function isChunkLoadError(error: unknown): boolean {\n if (!(error instanceof Error)) return false;\n const msg = error.message.toLowerCase();\n return (\n msg.includes('failed to fetch dynamically imported module') ||\n msg.includes('error loading dynamically imported module') ||\n msg.includes('importing a module script failed') ||\n msg.includes('unable to preload css') ||\n // Webpack-style chunk load errors (unlikely in Vite but defensive)\n msg.includes('loading chunk') ||\n msg.includes('loading css chunk')\n );\n}\n\n/**\n * Trigger a full page reload to pick up new bundles.\n *\n * Sets a sessionStorage flag before reloading. If the flag is already\n * set (meaning we already reloaded once for this reason), we don't\n * reload again — this prevents infinite reload loops if the error\n * persists after reload (e.g., a genuine bug rather than a stale bundle).\n *\n * @returns true if a reload was triggered, false if suppressed (loop guard)\n */\nexport function triggerStaleReload(): boolean {\n try {\n // Check if we already reloaded — prevent infinite loop\n if (sessionStorage.getItem(RELOAD_FLAG_KEY)) {\n console.warn(\n '[timber] Stale client reference detected again after reload. ' +\n 'Not reloading to prevent infinite loop. ' +\n 'This may indicate a deployment issue — try a hard refresh.'\n );\n return false;\n }\n\n // Set the flag before reloading\n sessionStorage.setItem(RELOAD_FLAG_KEY, '1');\n\n console.warn(\n '[timber] Stale client reference detected — the server has been ' +\n 'redeployed with new bundles. Reloading to pick up the new version.'\n );\n\n window.location.reload();\n return true;\n } catch {\n // sessionStorage unavailable (private browsing, storage full, etc.)\n // Use document.cookie as a reload-persistent fallback loop guard.\n // Module-level memoryReloadCount resets on every reload, so it can't\n // detect cross-reload loops. Cookies persist across reloads and are\n // available even when sessionStorage is blocked (TIM-576).\n const cookieFlag = document.cookie.includes(RELOAD_FLAG_KEY + '=1');\n if (cookieFlag || memoryReloadCount > 0) {\n console.warn(\n '[timber] Stale client reference detected again after reload. ' +\n 'Not reloading to prevent infinite loop. ' +\n 'This may indicate a deployment issue — try a hard refresh.'\n );\n return false;\n }\n\n memoryReloadCount++;\n // Set a short-lived cookie (60s) as the persistent loop guard.\n // It auto-expires so it won't block future legitimate reloads.\n try {\n document.cookie = `${RELOAD_FLAG_KEY}=1; max-age=60; path=/; SameSite=Lax`;\n } catch {\n // Cookie API unavailable — proceed anyway, memoryReloadCount guards same-page loops\n }\n console.warn(\n '[timber] Stale client reference detected — the server has been ' +\n 'redeployed with new bundles. Reloading to pick up the new version.'\n );\n window.location.reload();\n return true;\n }\n}\n\n/**\n * Clear the stale reload flag. Called on successful bootstrap to reset\n * the loop guard — if the page loaded successfully, the next stale\n * reference error should trigger a fresh reload attempt.\n */\nexport function clearStaleReloadFlag(): void {\n memoryReloadCount = 0;\n try {\n sessionStorage.removeItem(RELOAD_FLAG_KEY);\n } catch {\n // sessionStorage unavailable — nothing to clear\n }\n // Also clear the cookie fallback\n try {\n document.cookie = `${RELOAD_FLAG_KEY}=; max-age=0; path=/; SameSite=Lax`;\n } catch {\n // Cookie API unavailable\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAgBA,IAAM,kBAAkB;;;;;;;;;AAUxB,IAAI,oBAAoB;;;;;;;;;;;AAYxB,SAAgB,uBAAuB,OAAyB;CAC9D,IAAI,EAAE,iBAAiB,QAAQ,OAAO;CACtC,MAAM,MAAM,MAAM;CAClB,OAAO,IAAI,SAAS,4BAA4B,IAAI,IAAI,SAAS,6BAA6B;;;;;;;;;;;;AAyChG,SAAgB,qBAA8B;CAC5C,IAAI;EAEF,IAAI,eAAe,QAAQ,gBAAgB,EAAE;GAC3C,QAAQ,KACN,kKAGD;GACD,OAAO;;EAIT,eAAe,QAAQ,iBAAiB,IAAI;EAE5C,QAAQ,KACN,oIAED;EAED,OAAO,SAAS,QAAQ;EACxB,OAAO;SACD;EAON,IADmB,SAAS,OAAO,SAAS,kBAAkB,KAC1D,IAAc,oBAAoB,GAAG;GACvC,QAAQ,KACN,kKAGD;GACD,OAAO;;EAGT;EAGA,IAAI;GACF,SAAS,SAAS,GAAG,gBAAgB;UAC/B;EAGR,QAAQ,KACN,oIAED;EACD,OAAO,SAAS,QAAQ;EACxB,OAAO"}
1
+ {"version":3,"file":"stale-reload-DIIIVGh_.js","names":[],"sources":["../../src/client/stale-reload.ts"],"sourcesContent":["/**\n * Stale Client Reference Reload\n *\n * When a new deployment ships updated bundles, clients running stale\n * JavaScript may encounter \"Could not find the module\" errors during\n * RSC Flight stream decoding. This happens because the RSC payload\n * references module IDs from the new bundle that don't exist in the\n * old client bundle.\n *\n * This module detects these specific errors and triggers a full page\n * reload so the browser fetches the new bundle. A sessionStorage flag\n * guards against infinite reload loops.\n *\n * See: LOCAL-332\n */\n\nconst RELOAD_FLAG_KEY = '__timber_stale_reload';\n\n/**\n * In-memory fallback counter for environments where sessionStorage is\n * unavailable (private browsing, storage full, extension interference).\n * Incremented each time triggerStaleReload() falls into the catch path.\n * If the counter exceeds 0 on a subsequent call, the reload is suppressed\n * to prevent an infinite loop. Resets naturally on page load (module\n * re-evaluates) and can be manually reset via clearStaleReloadFlag().\n */\nlet memoryReloadCount = 0;\n\n/**\n * Check if an error is a stale client reference error from React's\n * Flight client. These errors have the message pattern:\n * \"Could not find the module \\\"<id>\\\"\"\n *\n * This is thrown by react-server-dom-webpack's client when the RSC\n * payload references a module ID that doesn't exist in the client's\n * module map — typically because the server has been redeployed with\n * new bundle hashes while the client is still running old JavaScript.\n */\nexport function isStaleClientReference(error: unknown): boolean {\n if (!(error instanceof Error)) return false;\n const msg = error.message;\n return msg.includes('Could not find the module') || msg.includes('client reference not found');\n}\n\n/**\n * Check if an error is a chunk load failure from a dynamic import.\n *\n * After a deployment, old chunk filenames no longer exist. When the client\n * tries to dynamically import a chunk that's been replaced, the browser\n * throws one of these errors:\n *\n * - Chromium: \"Failed to fetch dynamically imported module: <url>\"\n * - Firefox: \"error loading dynamically imported module: <url>\"\n * - Safari: \"Importing a module script failed.\"\n * - Vite/Rollup: \"Unable to preload CSS for <url>\"\n *\n * See TIM-446\n */\nexport function isChunkLoadError(error: unknown): boolean {\n if (!(error instanceof Error)) return false;\n const msg = error.message.toLowerCase();\n return (\n msg.includes('failed to fetch dynamically imported module') ||\n msg.includes('error loading dynamically imported module') ||\n msg.includes('importing a module script failed') ||\n msg.includes('unable to preload css') ||\n // Webpack-style chunk load errors (unlikely in Vite but defensive)\n msg.includes('loading chunk') ||\n msg.includes('loading css chunk')\n );\n}\n\n/**\n * Trigger a full page reload to pick up new bundles.\n *\n * Sets a sessionStorage flag before reloading. If the flag is already\n * set (meaning we already reloaded once for this reason), we don't\n * reload again — this prevents infinite reload loops if the error\n * persists after reload (e.g., a genuine bug rather than a stale bundle).\n *\n * @returns true if a reload was triggered, false if suppressed (loop guard)\n */\nexport function triggerStaleReload(): boolean {\n try {\n // Check if we already reloaded — prevent infinite loop\n if (sessionStorage.getItem(RELOAD_FLAG_KEY)) {\n console.warn(\n '[timber] Stale client reference detected again after reload. ' +\n 'Not reloading to prevent infinite loop. ' +\n 'This may indicate a deployment issue — try a hard refresh.'\n );\n return false;\n }\n\n // Set the flag before reloading\n sessionStorage.setItem(RELOAD_FLAG_KEY, '1');\n\n console.warn(\n '[timber] Stale client reference detected — the server has been ' +\n 'redeployed with new bundles. Reloading to pick up the new version.'\n );\n\n window.location.reload();\n return true;\n } catch {\n // sessionStorage unavailable (private browsing, storage full, etc.)\n // Use document.cookie as a reload-persistent fallback loop guard.\n // Module-level memoryReloadCount resets on every reload, so it can't\n // detect cross-reload loops. Cookies persist across reloads and are\n // available even when sessionStorage is blocked (TIM-576).\n const cookieFlag = document.cookie.includes(RELOAD_FLAG_KEY + '=1');\n if (cookieFlag || memoryReloadCount > 0) {\n console.warn(\n '[timber] Stale client reference detected again after reload. ' +\n 'Not reloading to prevent infinite loop. ' +\n 'This may indicate a deployment issue — try a hard refresh.'\n );\n return false;\n }\n\n memoryReloadCount++;\n // Set a short-lived cookie (60s) as the persistent loop guard.\n // It auto-expires so it won't block future legitimate reloads.\n try {\n document.cookie = `${RELOAD_FLAG_KEY}=1; max-age=60; path=/; SameSite=Lax`;\n } catch {\n // Cookie API unavailable — proceed anyway, memoryReloadCount guards same-page loops\n }\n console.warn(\n '[timber] Stale client reference detected — the server has been ' +\n 'redeployed with new bundles. Reloading to pick up the new version.'\n );\n window.location.reload();\n return true;\n }\n}\n\n/**\n * Clear the stale reload flag. Called on successful bootstrap to reset\n * the loop guard — if the page loaded successfully, the next stale\n * reference error should trigger a fresh reload attempt.\n */\nexport function clearStaleReloadFlag(): void {\n memoryReloadCount = 0;\n try {\n sessionStorage.removeItem(RELOAD_FLAG_KEY);\n } catch {\n // sessionStorage unavailable — nothing to clear\n }\n // Also clear the cookie fallback\n try {\n document.cookie = `${RELOAD_FLAG_KEY}=; max-age=0; path=/; SameSite=Lax`;\n } catch {\n // Cookie API unavailable\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAgBA,IAAM,kBAAkB;;;;;;;;;AAUxB,IAAI,oBAAoB;;;;;;;;;;;AAYxB,SAAgB,uBAAuB,OAAyB;CAC9D,IAAI,EAAE,iBAAiB,QAAQ,OAAO;CACtC,MAAM,MAAM,MAAM;CAClB,OAAO,IAAI,SAAS,4BAA4B,IAAI,IAAI,SAAS,6BAA6B;;;;;;;;;;;;AAyChG,SAAgB,qBAA8B;CAC5C,IAAI;EAEF,IAAI,eAAe,QAAQ,gBAAgB,EAAE;GAC3C,QAAQ,KACN,kKAGD;GACD,OAAO;;EAIT,eAAe,QAAQ,iBAAiB,IAAI;EAE5C,QAAQ,KACN,oIAED;EAED,OAAO,SAAS,QAAQ;EACxB,OAAO;SACD;EAON,IADmB,SAAS,OAAO,SAAS,kBAAkB,KAC1D,IAAc,oBAAoB,GAAG;GACvC,QAAQ,KACN,kKAGD;GACD,OAAO;;EAGT;EAGA,IAAI;GACF,SAAS,SAAS,GAAG,gBAAgB;UAC/B;EAGR,QAAQ,KACN,oIAED;EACD,OAAO,SAAS,QAAQ;EACxB,OAAO"}
@@ -1,4 +1,4 @@
1
- import { t as getSearchParamsDefinition } from "./registry-BI9cgCt7.js";
1
+ import { t as getSearchParamsDefinition } from "./registry-Dp_hJL4s.js";
2
2
  import { useQueryStates } from "nuqs";
3
3
  //#region src/client/use-query-states.ts
4
4
  /**
@@ -93,4 +93,4 @@ function bindUseQueryStates(definition) {
93
93
  //#endregion
94
94
  export { useQueryStates$1 as n, bindUseQueryStates as t };
95
95
 
96
- //# sourceMappingURL=use-query-states-C9G6LP0F.js.map
96
+ //# sourceMappingURL=use-query-states-D9SBlAfp.js.map