@timber-js/app 0.1.28 → 0.1.30

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.
package/dist/cli.d.ts CHANGED
@@ -13,17 +13,23 @@ export interface CommandOptions {
13
13
  * Accepts: timber <command> [--config|-c <path>]
14
14
  */
15
15
  export declare function parseArgs(args: string[]): ParsedArgs;
16
+ /** @internal Dependency injection for testing. */
17
+ export interface ViteDeps {
18
+ createServer?: typeof import('vite').createServer;
19
+ createBuilder?: typeof import('vite').createBuilder;
20
+ preview?: typeof import('vite').preview;
21
+ }
16
22
  /**
17
23
  * Start the Vite dev server.
18
24
  * Middleware re-runs on file change via HMR wiring in timber-routing.
19
25
  */
20
- export declare function runDev(options: CommandOptions): Promise<void>;
26
+ export declare function runDev(options: CommandOptions, _deps?: ViteDeps): Promise<void>;
21
27
  /**
22
28
  * Run the production build using createBuilder + buildApp.
23
29
  * Direct build() calls do NOT trigger the RSC plugin's multi-environment
24
30
  * pipeline — createBuilder/buildApp is required.
25
31
  */
26
- export declare function runBuild(options: CommandOptions): Promise<void>;
32
+ export declare function runBuild(options: CommandOptions, _deps?: ViteDeps): Promise<void>;
27
33
  /**
28
34
  * Determine whether to use the adapter's preview or Vite's built-in preview.
29
35
  * Exported for testing — the actual runPreview function uses this internally.
@@ -34,7 +40,7 @@ export declare function resolvePreviewStrategy(adapter: import('./adapters/types
34
40
  * If the adapter provides a preview() method, it takes priority.
35
41
  * Otherwise falls back to Vite's built-in preview server.
36
42
  */
37
- export declare function runPreview(options: CommandOptions): Promise<void>;
43
+ export declare function runPreview(options: CommandOptions, _deps?: ViteDeps): Promise<void>;
38
44
  /**
39
45
  * Validate types and routes without producing build output.
40
46
  * Runs tsgo --noEmit for type checking.
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAaA,QAAA,MAAM,QAAQ,+CAAgD,CAAC;AAC/D,KAAK,OAAO,GAAG,CAAC,OAAO,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC;AAEzC,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;CAC5B;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,CAuBpD;AAID;;;GAGG;AACH,wBAAsB,MAAM,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAOnE;AAED;;;;GAIG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAMrE;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,OAAO,kBAAkB,EAAE,qBAAqB,GAAG,SAAS,GACpE,SAAS,GAAG,MAAM,CAKpB;AA2BD;;;;GAIG;AACH,wBAAsB,UAAU,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAqBvE;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAerE"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAaA,QAAA,MAAM,QAAQ,+CAAgD,CAAC;AAC/D,KAAK,OAAO,GAAG,CAAC,OAAO,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC;AAEzC,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;CAC5B;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,CAuBpD;AAID,kDAAkD;AAClD,MAAM,WAAW,QAAQ;IACvB,YAAY,CAAC,EAAE,cAAc,MAAM,EAAE,YAAY,CAAC;IAClD,aAAa,CAAC,EAAE,cAAc,MAAM,EAAE,aAAa,CAAC;IACpD,OAAO,CAAC,EAAE,cAAc,MAAM,EAAE,OAAO,CAAC;CACzC;AAED;;;GAGG;AACH,wBAAsB,MAAM,CAAC,OAAO,EAAE,cAAc,EAAE,KAAK,CAAC,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAOrF;AAED;;;;GAIG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,cAAc,EAAE,KAAK,CAAC,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAMvF;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,OAAO,kBAAkB,EAAE,qBAAqB,GAAG,SAAS,GACpE,SAAS,GAAG,MAAM,CAKpB;AA2BD;;;;GAIG;AACH,wBAAsB,UAAU,CAAC,OAAO,EAAE,cAAc,EAAE,KAAK,CAAC,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAqBzF;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAerE"}
package/dist/cli.js CHANGED
@@ -28,9 +28,8 @@ function parseArgs(args) {
28
28
  * Start the Vite dev server.
29
29
  * Middleware re-runs on file change via HMR wiring in timber-routing.
30
30
  */
31
- async function runDev(options) {
32
- const { createServer } = await import("vite");
33
- const server = await createServer({ configFile: options.config });
31
+ async function runDev(options, _deps) {
32
+ const server = await (_deps?.createServer ?? (await import("vite")).createServer)({ configFile: options.config });
34
33
  await server.listen();
35
34
  server.printUrls();
36
35
  }
@@ -39,9 +38,8 @@ async function runDev(options) {
39
38
  * Direct build() calls do NOT trigger the RSC plugin's multi-environment
40
39
  * pipeline — createBuilder/buildApp is required.
41
40
  */
42
- async function runBuild(options) {
43
- const { createBuilder } = await import("vite");
44
- await (await createBuilder({ configFile: options.config })).buildApp();
41
+ async function runBuild(options, _deps) {
42
+ await (await (_deps?.createBuilder ?? (await import("vite")).createBuilder)({ configFile: options.config })).buildApp();
45
43
  }
46
44
  /**
47
45
  * Determine whether to use the adapter's preview or Vite's built-in preview.
@@ -78,7 +76,7 @@ async function loadTimberConfig(root) {
78
76
  * If the adapter provides a preview() method, it takes priority.
79
77
  * Otherwise falls back to Vite's built-in preview server.
80
78
  */
81
- async function runPreview(options) {
79
+ async function runPreview(options, _deps) {
82
80
  const { join } = await import("node:path");
83
81
  const root = process.cwd();
84
82
  const config = await loadTimberConfig(root).catch(() => null);
@@ -89,8 +87,7 @@ async function runPreview(options) {
89
87
  await adapter.preview(timberConfig, buildDir);
90
88
  return;
91
89
  }
92
- const { preview } = await import("vite");
93
- (await preview({ configFile: options.config })).printUrls();
90
+ (await (_deps?.preview ?? (await import("vite")).preview)({ configFile: options.config })).printUrls();
94
91
  }
95
92
  /**
96
93
  * Validate types and routes without producing build output.
package/dist/cli.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.js","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n\n// timber.js CLI\n//\n// Wraps Vite commands with timber-specific behavior.\n// See design/18-build-system.md §\"CLI\".\n//\n// Commands:\n// timber dev — Start Vite dev server with HMR\n// timber build — Run multi-environment build via createBuilder/buildApp\n// timber preview — Serve the production build\n// timber check — Validate types + routes without building\n\nconst COMMANDS = ['dev', 'build', 'preview', 'check'] as const;\ntype Command = (typeof COMMANDS)[number];\n\nexport interface ParsedArgs {\n command: Command;\n config: string | undefined;\n}\n\nexport interface CommandOptions {\n config?: string;\n}\n\n/**\n * Parse CLI arguments into a structured command + options.\n * Accepts: timber <command> [--config|-c <path>]\n */\nexport function parseArgs(args: string[]): ParsedArgs {\n if (args.length === 0) {\n throw new Error(\n 'No command provided. Usage: timber <dev|build|preview|check> [--config <path>]'\n );\n }\n\n const command = args[0];\n if (!COMMANDS.includes(command as Command)) {\n throw new Error(`Unknown command: ${command}. Available commands: ${COMMANDS.join(', ')}`);\n }\n\n let config: string | undefined;\n for (let i = 1; i < args.length; i++) {\n if (args[i] === '--config' || args[i] === '-c') {\n config = args[++i];\n if (!config) {\n throw new Error('--config requires a path argument');\n }\n }\n }\n\n return { command: command as Command, config };\n}\n\n// ─── Command Implementations ─────────────────────────────────────────────────\n\n/**\n * Start the Vite dev server.\n * Middleware re-runs on file change via HMR wiring in timber-routing.\n */\nexport async function runDev(options: CommandOptions): Promise<void> {\n const { createServer } = await import('vite');\n const server = await createServer({\n configFile: options.config,\n });\n await server.listen();\n server.printUrls();\n}\n\n/**\n * Run the production build using createBuilder + buildApp.\n * Direct build() calls do NOT trigger the RSC plugin's multi-environment\n * pipeline — createBuilder/buildApp is required.\n */\nexport async function runBuild(options: CommandOptions): Promise<void> {\n const { createBuilder } = await import('vite');\n const builder = await createBuilder({\n configFile: options.config,\n });\n await builder.buildApp();\n}\n\n/**\n * Determine whether to use the adapter's preview or Vite's built-in preview.\n * Exported for testing — the actual runPreview function uses this internally.\n */\nexport function resolvePreviewStrategy(\n adapter: import('./adapters/types').TimberPlatformAdapter | undefined\n): 'adapter' | 'vite' {\n if (adapter && typeof adapter.preview === 'function') {\n return 'adapter';\n }\n return 'vite';\n}\n\n/**\n * Load timber.config.ts from the project root.\n * Returns the config object with adapter, output, etc.\n * Returns null if no config file is found.\n */\nasync function loadTimberConfig(\n root: string\n): Promise<{ adapter?: import('./adapters/types').TimberPlatformAdapter; output?: string } | null> {\n const { existsSync } = await import('node:fs');\n const { join } = await import('node:path');\n const { pathToFileURL } = await import('node:url');\n\n const configNames = ['timber.config.ts', 'timber.config.js', 'timber.config.mjs'];\n\n for (const name of configNames) {\n const configPath = join(root, name);\n if (existsSync(configPath)) {\n // Use Vite's built-in config loading to handle TypeScript\n const mod = await import(pathToFileURL(configPath).href);\n return mod.default ?? mod;\n }\n }\n return null;\n}\n\n/**\n * Serve the production build for local testing.\n * If the adapter provides a preview() method, it takes priority.\n * Otherwise falls back to Vite's built-in preview server.\n */\nexport async function runPreview(options: CommandOptions): Promise<void> {\n const { join } = await import('node:path');\n\n // Try to load timber config for adapter-specific preview\n const root = process.cwd();\n const config = await loadTimberConfig(root).catch(() => null);\n const adapter = config?.adapter as import('./adapters/types').TimberPlatformAdapter | undefined;\n\n if (resolvePreviewStrategy(adapter) === 'adapter') {\n const buildDir = join(root, 'dist');\n const timberConfig = { output: (config?.output ?? 'server') as 'server' | 'static' };\n await adapter!.preview!(timberConfig, buildDir);\n return;\n }\n\n // Fallback: Vite's built-in preview server\n const { preview } = await import('vite');\n const server = await preview({\n configFile: options.config,\n });\n server.printUrls();\n}\n\n/**\n * Validate types and routes without producing build output.\n * Runs tsgo --noEmit for type checking.\n */\nexport async function runCheck(options: CommandOptions): Promise<void> {\n const { execFile } = await import('node:child_process');\n\n await new Promise<void>((resolve, reject) => {\n const configArgs = options.config ? ['--project', options.config] : [];\n execFile('tsgo', ['--noEmit', ...configArgs], (err, stdout, stderr) => {\n if (stdout) process.stdout.write(stdout);\n if (stderr) process.stderr.write(stderr);\n if (err) {\n reject(new Error(`Type check failed with exit code ${err.code}`));\n } else {\n resolve();\n }\n });\n });\n}\n\n// ─── Main Entry Point ────────────────────────────────────────────────────────\n\nasync function main(): Promise<void> {\n const parsed = parseArgs(process.argv.slice(2));\n const options: CommandOptions = { config: parsed.config };\n\n switch (parsed.command) {\n case 'dev':\n await runDev(options);\n break;\n case 'build':\n await runBuild(options);\n break;\n case 'preview':\n await runPreview(options);\n break;\n case 'check':\n await runCheck(options);\n break;\n }\n}\n\n// Run main when executed as a CLI (not imported in tests).\n// The bin shim (bin/timber.mjs) does `import '../dist/cli.js'`, so\n// process.argv[1] points to the shim, not this file. We check both:\n// direct execution AND being imported by the timber bin shim.\nconst isDirectExecution =\n typeof process !== 'undefined' &&\n process.argv[1] &&\n (import.meta.url.endsWith(process.argv[1]) ||\n process.argv[1].endsWith('bin/timber.mjs') ||\n process.argv[1].endsWith('bin/timber'));\n\nif (isDirectExecution) {\n main().catch((err) => {\n console.error(err.message);\n process.exit(1);\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"cli.js","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n\n// timber.js CLI\n//\n// Wraps Vite commands with timber-specific behavior.\n// See design/18-build-system.md §\"CLI\".\n//\n// Commands:\n// timber dev — Start Vite dev server with HMR\n// timber build — Run multi-environment build via createBuilder/buildApp\n// timber preview — Serve the production build\n// timber check — Validate types + routes without building\n\nconst COMMANDS = ['dev', 'build', 'preview', 'check'] as const;\ntype Command = (typeof COMMANDS)[number];\n\nexport interface ParsedArgs {\n command: Command;\n config: string | undefined;\n}\n\nexport interface CommandOptions {\n config?: string;\n}\n\n/**\n * Parse CLI arguments into a structured command + options.\n * Accepts: timber <command> [--config|-c <path>]\n */\nexport function parseArgs(args: string[]): ParsedArgs {\n if (args.length === 0) {\n throw new Error(\n 'No command provided. Usage: timber <dev|build|preview|check> [--config <path>]'\n );\n }\n\n const command = args[0];\n if (!COMMANDS.includes(command as Command)) {\n throw new Error(`Unknown command: ${command}. Available commands: ${COMMANDS.join(', ')}`);\n }\n\n let config: string | undefined;\n for (let i = 1; i < args.length; i++) {\n if (args[i] === '--config' || args[i] === '-c') {\n config = args[++i];\n if (!config) {\n throw new Error('--config requires a path argument');\n }\n }\n }\n\n return { command: command as Command, config };\n}\n\n// ─── Command Implementations ─────────────────────────────────────────────────\n\n/** @internal Dependency injection for testing. */\nexport interface ViteDeps {\n createServer?: typeof import('vite').createServer;\n createBuilder?: typeof import('vite').createBuilder;\n preview?: typeof import('vite').preview;\n}\n\n/**\n * Start the Vite dev server.\n * Middleware re-runs on file change via HMR wiring in timber-routing.\n */\nexport async function runDev(options: CommandOptions, _deps?: ViteDeps): Promise<void> {\n const createServer = _deps?.createServer ?? (await import('vite')).createServer;\n const server = await createServer({\n configFile: options.config,\n });\n await server.listen();\n server.printUrls();\n}\n\n/**\n * Run the production build using createBuilder + buildApp.\n * Direct build() calls do NOT trigger the RSC plugin's multi-environment\n * pipeline — createBuilder/buildApp is required.\n */\nexport async function runBuild(options: CommandOptions, _deps?: ViteDeps): Promise<void> {\n const createBuilder = _deps?.createBuilder ?? (await import('vite')).createBuilder;\n const builder = await createBuilder({\n configFile: options.config,\n });\n await builder.buildApp();\n}\n\n/**\n * Determine whether to use the adapter's preview or Vite's built-in preview.\n * Exported for testing — the actual runPreview function uses this internally.\n */\nexport function resolvePreviewStrategy(\n adapter: import('./adapters/types').TimberPlatformAdapter | undefined\n): 'adapter' | 'vite' {\n if (adapter && typeof adapter.preview === 'function') {\n return 'adapter';\n }\n return 'vite';\n}\n\n/**\n * Load timber.config.ts from the project root.\n * Returns the config object with adapter, output, etc.\n * Returns null if no config file is found.\n */\nasync function loadTimberConfig(\n root: string\n): Promise<{ adapter?: import('./adapters/types').TimberPlatformAdapter; output?: string } | null> {\n const { existsSync } = await import('node:fs');\n const { join } = await import('node:path');\n const { pathToFileURL } = await import('node:url');\n\n const configNames = ['timber.config.ts', 'timber.config.js', 'timber.config.mjs'];\n\n for (const name of configNames) {\n const configPath = join(root, name);\n if (existsSync(configPath)) {\n // Use Vite's built-in config loading to handle TypeScript\n const mod = await import(pathToFileURL(configPath).href);\n return mod.default ?? mod;\n }\n }\n return null;\n}\n\n/**\n * Serve the production build for local testing.\n * If the adapter provides a preview() method, it takes priority.\n * Otherwise falls back to Vite's built-in preview server.\n */\nexport async function runPreview(options: CommandOptions, _deps?: ViteDeps): Promise<void> {\n const { join } = await import('node:path');\n\n // Try to load timber config for adapter-specific preview\n const root = process.cwd();\n const config = await loadTimberConfig(root).catch(() => null);\n const adapter = config?.adapter as import('./adapters/types').TimberPlatformAdapter | undefined;\n\n if (resolvePreviewStrategy(adapter) === 'adapter') {\n const buildDir = join(root, 'dist');\n const timberConfig = { output: (config?.output ?? 'server') as 'server' | 'static' };\n await adapter!.preview!(timberConfig, buildDir);\n return;\n }\n\n // Fallback: Vite's built-in preview server\n const preview = _deps?.preview ?? (await import('vite')).preview;\n const server = await preview({\n configFile: options.config,\n });\n server.printUrls();\n}\n\n/**\n * Validate types and routes without producing build output.\n * Runs tsgo --noEmit for type checking.\n */\nexport async function runCheck(options: CommandOptions): Promise<void> {\n const { execFile } = await import('node:child_process');\n\n await new Promise<void>((resolve, reject) => {\n const configArgs = options.config ? ['--project', options.config] : [];\n execFile('tsgo', ['--noEmit', ...configArgs], (err, stdout, stderr) => {\n if (stdout) process.stdout.write(stdout);\n if (stderr) process.stderr.write(stderr);\n if (err) {\n reject(new Error(`Type check failed with exit code ${err.code}`));\n } else {\n resolve();\n }\n });\n });\n}\n\n// ─── Main Entry Point ────────────────────────────────────────────────────────\n\nasync function main(): Promise<void> {\n const parsed = parseArgs(process.argv.slice(2));\n const options: CommandOptions = { config: parsed.config };\n\n switch (parsed.command) {\n case 'dev':\n await runDev(options);\n break;\n case 'build':\n await runBuild(options);\n break;\n case 'preview':\n await runPreview(options);\n break;\n case 'check':\n await runCheck(options);\n break;\n }\n}\n\n// Run main when executed as a CLI (not imported in tests).\n// The bin shim (bin/timber.mjs) does `import '../dist/cli.js'`, so\n// process.argv[1] points to the shim, not this file. We check both:\n// direct execution AND being imported by the timber bin shim.\nconst isDirectExecution =\n typeof process !== 'undefined' &&\n process.argv[1] &&\n (import.meta.url.endsWith(process.argv[1]) ||\n process.argv[1].endsWith('bin/timber.mjs') ||\n process.argv[1].endsWith('bin/timber'));\n\nif (isDirectExecution) {\n main().catch((err) => {\n console.error(err.message);\n process.exit(1);\n });\n}\n"],"mappings":"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"}
@@ -4,9 +4,8 @@ import { n as useQueryStates, t as bindUseQueryStates } from "../_chunks/use-que
4
4
  import { t as useCookie } from "../_chunks/use-cookie-dDbpCTx-.js";
5
5
  import { TimberErrorBoundary } from "./error-boundary.js";
6
6
  import React, { createContext, createElement, useActionState as useActionState$1, useContext, useEffect, useMemo, useRef, useSyncExternalStore, useTransition } from "react";
7
- import { jsxDEV } from "react/jsx-dev-runtime";
7
+ import { jsx } from "react/jsx-runtime";
8
8
  //#region src/client/link-navigate-interceptor.tsx
9
- var _jsxFileName$2 = "/Users/dsaewitz/y/timber-js-fresh/packages/timber-app/src/client/link-navigate-interceptor.tsx";
10
9
  /** Symbol used to store the onNavigate callback on anchor elements. */
11
10
  var ON_NAVIGATE_KEY = "__timberOnNavigate";
12
11
  /**
@@ -26,15 +25,11 @@ function LinkNavigateInterceptor({ onNavigate, children }) {
26
25
  delete anchor[ON_NAVIGATE_KEY];
27
26
  };
28
27
  }, [onNavigate]);
29
- return /* @__PURE__ */ jsxDEV("span", {
28
+ return /* @__PURE__ */ jsx("span", {
30
29
  ref,
31
30
  style: { display: "contents" },
32
31
  children
33
- }, void 0, false, {
34
- fileName: _jsxFileName$2,
35
- lineNumber: 58,
36
- columnNumber: 5
37
- }, this);
32
+ });
38
33
  }
39
34
  //#endregion
40
35
  //#region src/client/use-link-status.ts
@@ -72,66 +67,111 @@ function useLinkStatus() {
72
67
  return useContext(LinkStatusContext);
73
68
  }
74
69
  //#endregion
75
- //#region src/client/router-ref.ts
70
+ //#region src/client/navigation-context.ts
76
71
  /**
77
- * Set the global router instance. Called once during bootstrap.
72
+ * NavigationContext React context for navigation state.
73
+ *
74
+ * Holds the current route params and pathname, updated atomically
75
+ * with the RSC tree on each navigation. This replaces the previous
76
+ * useSyncExternalStore approach for useParams() and usePathname(),
77
+ * which suffered from a timing gap: the new tree could commit before
78
+ * the external store re-renders fired, causing a frame where both
79
+ * old and new active states were visible simultaneously.
80
+ *
81
+ * By wrapping the RSC payload element in NavigationProvider inside
82
+ * renderRoot(), the context value and the element tree are passed to
83
+ * reactRoot.render() in the same call — atomic by construction.
84
+ * All consumers (useParams, usePathname) see the new values in the
85
+ * same render pass as the new tree.
86
+ *
87
+ * During SSR, no NavigationProvider is mounted. Hooks fall back to
88
+ * the ALS-backed getSsrData() for per-request isolation.
89
+ *
90
+ * IMPORTANT: createContext and useContext are NOT available in the RSC
91
+ * environment (React Server Components use a stripped-down React).
92
+ * The context is lazily initialized on first access, and all functions
93
+ * that depend on these APIs are safe to call from any environment —
94
+ * they return null or no-op when the APIs aren't available.
95
+ *
96
+ * See design/19-client-navigation.md §"NavigationContext"
78
97
  */
79
- function setGlobalRouter(router) {
80
- _setGlobalRouter(router);
98
+ /**
99
+ * The context is created lazily to avoid calling createContext at module
100
+ * level. In the RSC environment, React.createContext doesn't exist —
101
+ * calling it at import time would crash the server.
102
+ */
103
+ var _context;
104
+ function getOrCreateContext() {
105
+ if (_context !== void 0) return _context;
106
+ if (typeof React.createContext === "function") _context = React.createContext(null);
107
+ return _context;
81
108
  }
82
109
  /**
83
- * Get the global router instance. Throws if called before bootstrap.
84
- * Used by client-side hooks (useNavigationPending, etc.)
110
+ * Read the navigation context. Returns null during SSR (no provider)
111
+ * or in the RSC environment (no context available).
112
+ * Internal — used by useParams() and usePathname().
85
113
  */
86
- function getRouter() {
87
- if (!globalRouter) throw new Error("[timber] Router not initialized. getRouter() was called before bootstrap().");
88
- return globalRouter;
114
+ function useNavigationContext() {
115
+ const ctx = getOrCreateContext();
116
+ if (!ctx) return null;
117
+ if (typeof React.useContext !== "function") return null;
118
+ return React.useContext(ctx);
89
119
  }
90
120
  /**
91
- * Get the global router instance or null if not yet initialized.
92
- * Used by useRouter() methods to avoid silent failures — callers
93
- * can log a meaningful warning instead of silently no-oping.
121
+ * Wraps children with NavigationContext.Provider.
122
+ *
123
+ * Used in browser-entry.ts renderRoot to wrap the RSC payload element
124
+ * so that navigation state updates atomically with the tree render.
94
125
  */
95
- function getRouterOrNull() {
96
- return globalRouter;
126
+ function NavigationProvider({ value, children }) {
127
+ const ctx = getOrCreateContext();
128
+ if (!ctx) return children;
129
+ return createElement(ctx.Provider, { value }, children);
130
+ }
131
+ /**
132
+ * Module-level navigation state. Updated by the router before calling
133
+ * renderRoot(). The renderRoot callback reads this to create the
134
+ * NavigationProvider with the correct values.
135
+ *
136
+ * This is NOT used by hooks directly — hooks read from React context.
137
+ * This exists only as a communication channel between the router
138
+ * (which knows the new nav state) and renderRoot (which wraps the element).
139
+ */
140
+ var _currentNavState = {
141
+ params: {},
142
+ pathname: "/",
143
+ pendingUrl: null
144
+ };
145
+ function setNavigationState(state) {
146
+ _currentNavState = state;
147
+ }
148
+ function getNavigationState() {
149
+ return _currentNavState;
97
150
  }
98
151
  //#endregion
99
152
  //#region src/client/link-status-provider.tsx
100
- var _jsxFileName$1 = "/Users/dsaewitz/y/timber-js-fresh/packages/timber-app/src/client/link-status-provider.tsx";
101
153
  var NOT_PENDING = { pending: false };
102
154
  var IS_PENDING = { pending: true };
103
155
  /**
104
- * Client component that subscribes to the router's pending URL and provides
105
- * a scoped LinkStatusContext to children. Renders no extra DOM — just a
106
- * context provider around children.
156
+ * Client component that reads the pending URL from NavigationContext and
157
+ * provides a scoped LinkStatusContext to children. Renders no extra DOM —
158
+ * just a context provider around children.
159
+ *
160
+ * Because pendingUrl lives in NavigationContext alongside params and pathname,
161
+ * all three update in the same React commit via renderRoot(). This eliminates
162
+ * the two-commit timing gap that existed when pendingUrl was read via
163
+ * useSyncExternalStore (external module-level state) while params came from
164
+ * NavigationContext (React context).
107
165
  */
108
166
  function LinkStatusProvider({ href, children }) {
109
- const status = useSyncExternalStore((callback) => {
110
- try {
111
- return getRouter().onPendingChange(callback);
112
- } catch {
113
- return () => {};
114
- }
115
- }, () => {
116
- try {
117
- if (getRouter().getPendingUrl() === href) return IS_PENDING;
118
- return NOT_PENDING;
119
- } catch {
120
- return NOT_PENDING;
121
- }
122
- }, () => NOT_PENDING);
123
- return /* @__PURE__ */ jsxDEV(LinkStatusContext.Provider, {
167
+ const status = useNavigationContext()?.pendingUrl === href ? IS_PENDING : NOT_PENDING;
168
+ return /* @__PURE__ */ jsx(LinkStatusContext.Provider, {
124
169
  value: status,
125
170
  children
126
- }, void 0, false, {
127
- fileName: _jsxFileName$1,
128
- lineNumber: 39,
129
- columnNumber: 10
130
- }, this);
171
+ });
131
172
  }
132
173
  //#endregion
133
174
  //#region src/client/link.tsx
134
- var _jsxFileName = "/Users/dsaewitz/y/timber-js-fresh/packages/timber-app/src/client/link.tsx";
135
175
  /**
136
176
  * Reject dangerous URL schemes that could execute script.
137
177
  * Security: design/13-security.md § Link scheme injection (test #9)
@@ -227,30 +267,18 @@ function Link({ href, prefetch, scroll, params, searchParams, onNavigate, childr
227
267
  params,
228
268
  searchParams
229
269
  });
230
- const inner = /* @__PURE__ */ jsxDEV(LinkStatusProvider, {
270
+ const inner = /* @__PURE__ */ jsx(LinkStatusProvider, {
231
271
  href: linkProps.href,
232
272
  children
233
- }, void 0, false, {
234
- fileName: _jsxFileName,
235
- lineNumber: 301,
236
- columnNumber: 17
237
- }, this);
238
- return /* @__PURE__ */ jsxDEV("a", {
273
+ });
274
+ return /* @__PURE__ */ jsx("a", {
239
275
  ...rest,
240
276
  ...linkProps,
241
- children: onNavigate ? /* @__PURE__ */ jsxDEV(LinkNavigateInterceptor, {
277
+ children: onNavigate ? /* @__PURE__ */ jsx(LinkNavigateInterceptor, {
242
278
  onNavigate,
243
279
  children: inner
244
- }, void 0, false, {
245
- fileName: _jsxFileName,
246
- lineNumber: 306,
247
- columnNumber: 9
248
- }, this) : inner
249
- }, void 0, false, {
250
- fileName: _jsxFileName,
251
- lineNumber: 304,
252
- columnNumber: 5
253
- }, this);
280
+ }) : inner
281
+ });
254
282
  }
255
283
  //#endregion
256
284
  //#region src/client/segment-cache.ts
@@ -379,87 +407,6 @@ var HistoryStack = class {
379
407
  }
380
408
  };
381
409
  //#endregion
382
- //#region src/client/navigation-context.ts
383
- /**
384
- * NavigationContext — React context for navigation state.
385
- *
386
- * Holds the current route params and pathname, updated atomically
387
- * with the RSC tree on each navigation. This replaces the previous
388
- * useSyncExternalStore approach for useParams() and usePathname(),
389
- * which suffered from a timing gap: the new tree could commit before
390
- * the external store re-renders fired, causing a frame where both
391
- * old and new active states were visible simultaneously.
392
- *
393
- * By wrapping the RSC payload element in NavigationProvider inside
394
- * renderRoot(), the context value and the element tree are passed to
395
- * reactRoot.render() in the same call — atomic by construction.
396
- * All consumers (useParams, usePathname) see the new values in the
397
- * same render pass as the new tree.
398
- *
399
- * During SSR, no NavigationProvider is mounted. Hooks fall back to
400
- * the ALS-backed getSsrData() for per-request isolation.
401
- *
402
- * IMPORTANT: createContext and useContext are NOT available in the RSC
403
- * environment (React Server Components use a stripped-down React).
404
- * The context is lazily initialized on first access, and all functions
405
- * that depend on these APIs are safe to call from any environment —
406
- * they return null or no-op when the APIs aren't available.
407
- *
408
- * See design/19-client-navigation.md §"NavigationContext"
409
- */
410
- /**
411
- * The context is created lazily to avoid calling createContext at module
412
- * level. In the RSC environment, React.createContext doesn't exist —
413
- * calling it at import time would crash the server.
414
- */
415
- var _context;
416
- function getOrCreateContext() {
417
- if (_context !== void 0) return _context;
418
- if (typeof React.createContext === "function") _context = React.createContext(null);
419
- return _context;
420
- }
421
- /**
422
- * Read the navigation context. Returns null during SSR (no provider)
423
- * or in the RSC environment (no context available).
424
- * Internal — used by useParams() and usePathname().
425
- */
426
- function useNavigationContext() {
427
- const ctx = getOrCreateContext();
428
- if (!ctx) return null;
429
- if (typeof React.useContext !== "function") return null;
430
- return React.useContext(ctx);
431
- }
432
- /**
433
- * Wraps children with NavigationContext.Provider.
434
- *
435
- * Used in browser-entry.ts renderRoot to wrap the RSC payload element
436
- * so that navigation state updates atomically with the tree render.
437
- */
438
- function NavigationProvider({ value, children }) {
439
- const ctx = getOrCreateContext();
440
- if (!ctx) return children;
441
- return createElement(ctx.Provider, { value }, children);
442
- }
443
- /**
444
- * Module-level navigation state. Updated by the router before calling
445
- * renderRoot(). The renderRoot callback reads this to create the
446
- * NavigationProvider with the correct values.
447
- *
448
- * This is NOT used by hooks directly — hooks read from React context.
449
- * This exists only as a communication channel between the router
450
- * (which knows the new nav state) and renderRoot (which wraps the element).
451
- */
452
- var _currentNavState = {
453
- params: {},
454
- pathname: "/"
455
- };
456
- function setNavigationState(state) {
457
- _currentNavState = state;
458
- }
459
- function getNavigationState() {
460
- return _currentNavState;
461
- }
462
- //#endregion
463
410
  //#region src/client/use-params.ts
464
411
  /**
465
412
  * Set the current route params in the module-level store.
@@ -639,12 +586,21 @@ function createRouter(deps) {
639
586
  let pending = false;
640
587
  let pendingUrl = null;
641
588
  const pendingListeners = /* @__PURE__ */ new Set();
589
+ /** Last rendered payload — used to re-render at navigation start with pendingUrl set. */
590
+ let lastRenderedPayload = null;
642
591
  function setPending(value, url) {
643
592
  const newPendingUrl = value && url ? url : null;
644
593
  if (pending === value && pendingUrl === newPendingUrl) return;
645
594
  pending = value;
646
595
  pendingUrl = newPendingUrl;
647
596
  for (const listener of pendingListeners) listener(value);
597
+ if (value && lastRenderedPayload !== null) {
598
+ setNavigationState({
599
+ ...getNavigationState(),
600
+ pendingUrl: newPendingUrl
601
+ });
602
+ renderPayload(lastRenderedPayload);
603
+ }
648
604
  }
649
605
  /** Update the segment cache from server-provided segment metadata. */
650
606
  function updateSegmentCache(segmentInfo) {
@@ -654,22 +610,29 @@ function createRouter(deps) {
654
610
  }
655
611
  /** Render a decoded RSC payload into the DOM if a renderer is available. */
656
612
  function renderPayload(payload) {
613
+ lastRenderedPayload = payload;
657
614
  if (deps.renderRoot) deps.renderRoot(payload);
658
615
  }
659
616
  /**
660
- * Update navigation state (params + pathname) for the next render.
617
+ * Update navigation state (params + pathname + pendingUrl) for the next render.
661
618
  *
662
619
  * Sets both the module-level fallback (for tests and SSR) and the
663
620
  * navigation context state (read by renderRoot to wrap the element
664
621
  * in NavigationProvider). The context update is atomic with the tree
665
622
  * render — both are passed to reactRoot.render() in the same call.
623
+ *
624
+ * pendingUrl is included so that LinkStatusProvider (which reads from
625
+ * NavigationContext) sees the pending state change in the same React
626
+ * commit as params/pathname — preventing the gap where the spinner
627
+ * disappears before the active state updates.
666
628
  */
667
- function updateNavigationState(params, url) {
629
+ function updateNavigationState(params, url, navPendingUrl = null) {
668
630
  const resolvedParams = params ?? {};
669
631
  setCurrentParams(resolvedParams);
670
632
  setNavigationState({
671
633
  params: resolvedParams,
672
- pathname: url.startsWith("http") ? new URL(url).pathname : url.split("?")[0] || "/"
634
+ pathname: url.startsWith("http") ? new URL(url).pathname : url.split("?")[0] || "/",
635
+ pendingUrl: navPendingUrl
673
636
  });
674
637
  }
675
638
  /** Apply head elements (title, meta tags) to the DOM if available. */
@@ -846,15 +809,33 @@ function createRouter(deps) {
846
809
  * ```
847
810
  */
848
811
  function useNavigationPending() {
849
- return useSyncExternalStore((callback) => {
850
- return getRouter().onPendingChange(callback);
851
- }, () => {
852
- try {
853
- return getRouter().isPending();
854
- } catch {
855
- return false;
856
- }
857
- }, () => false);
812
+ const navState = useNavigationContext();
813
+ if (!navState) return false;
814
+ return navState.pendingUrl !== null;
815
+ }
816
+ //#endregion
817
+ //#region src/client/router-ref.ts
818
+ /**
819
+ * Set the global router instance. Called once during bootstrap.
820
+ */
821
+ function setGlobalRouter(router) {
822
+ _setGlobalRouter(router);
823
+ }
824
+ /**
825
+ * Get the global router instance. Throws if called before bootstrap.
826
+ * Used by client-side hooks (useNavigationPending, etc.)
827
+ */
828
+ function getRouter() {
829
+ if (!globalRouter) throw new Error("[timber] Router not initialized. getRouter() was called before bootstrap().");
830
+ return globalRouter;
831
+ }
832
+ /**
833
+ * Get the global router instance or null if not yet initialized.
834
+ * Used by useRouter() methods to avoid silent failures — callers
835
+ * can log a meaningful warning instead of silently no-oping.
836
+ */
837
+ function getRouterOrNull() {
838
+ return globalRouter;
858
839
  }
859
840
  //#endregion
860
841
  //#region src/client/use-router.ts