@timber-js/app 0.1.17 → 0.1.18

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.
@@ -1,14 +1,19 @@
1
1
  /**
2
- * timber-shims — Vite sub-plugin for next/* → timber shim resolution.
2
+ * timber-shims — Vite sub-plugin for next/* → timber shim resolution
3
+ * and #/ subpath import canonicalization.
3
4
  *
4
- * Intercepts imports of next/* modules and redirects them to timber.js
5
- * shim implementations. This enables Next.js-compatible libraries
6
- * (nuqs, next-intl, etc.) to work unmodified.
5
+ * Two responsibilities:
6
+ * 1. Intercepts imports of next/* modules and redirects them to timber.js
7
+ * shim implementations. This enables Next.js-compatible libraries
8
+ * (nuqs, next-intl, etc.) to work unmodified.
9
+ * 2. Canonicalizes #/* subpath imports (package.json "imports" field) to
10
+ * absolute file paths, preventing Vite dev from creating duplicate
11
+ * module instances when the same file is reached via different import
12
+ * paths (e.g., #/client/router-ref.js vs ./router-ref.js).
7
13
  *
8
- * NOTE: This plugin does NOT resolve @timber-js/app/* subpath imports.
9
- * Those are handled by Vite's native package.json `exports` resolution,
10
- * which maps them to dist/ files. This ensures a single module instance
11
- * for shared modules like request-context (ALS singleton).
14
+ * NOTE: This plugin does NOT resolve @timber-js/app/* subpath imports
15
+ * (except @timber-js/app/server). Those are handled by Vite's native
16
+ * package.json `exports` resolution, which maps them to dist/ files.
12
17
  *
13
18
  * Design doc: 18-build-system.md §"Shim Map"
14
19
  */
@@ -1 +1 @@
1
- {"version":3,"file":"shims.d.ts","sourceRoot":"","sources":["../../src/plugins/shims.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAGnC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AA6DhD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM,CAkIvD"}
1
+ {"version":3,"file":"shims.d.ts","sourceRoot":"","sources":["../../src/plugins/shims.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAInC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAmFhD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM,CAmJvD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Vite-native React framework for Cloudflare Workers — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -1,9 +1,17 @@
1
1
  // Global router reference — shared between browser-entry and client hooks.
2
2
  // This module has no dependencies on virtual modules, so it can be safely
3
3
  // imported by client hooks without pulling in browser-entry's virtual imports.
4
+ //
5
+ // The router is stored as a module-level variable. The timber-shims plugin
6
+ // canonicalizes #/* subpath imports to absolute file paths, ensuring that
7
+ // all import chains (shim chain, browser-entry relative imports, etc.)
8
+ // resolve to the same module instance in Vite's module graph.
9
+ //
10
+ // See design/18-build-system.md §"Subpath Import Canonicalization"
4
11
 
5
12
  import type { RouterInstance } from './router.js';
6
13
 
14
+ /** Module-level singleton — set once during bootstrap. */
7
15
  let globalRouter: RouterInstance | null = null;
8
16
 
9
17
  /**
@@ -32,3 +40,12 @@ export function getRouter(): RouterInstance {
32
40
  export function getRouterOrNull(): RouterInstance | null {
33
41
  return globalRouter;
34
42
  }
43
+
44
+ /**
45
+ * Reset the global router to null. Used only in tests to isolate
46
+ * module-level state between test cases.
47
+ * @internal
48
+ */
49
+ export function resetGlobalRouter(): void {
50
+ globalRouter = null;
51
+ }
@@ -27,16 +27,6 @@ export interface AppRouterInstance {
27
27
  prefetch(href: string): void;
28
28
  }
29
29
 
30
- /** No-op router returned during SSR or before bootstrap. All methods are safe no-ops. */
31
- const SSR_NOOP_ROUTER: AppRouterInstance = {
32
- push() {},
33
- replace() {},
34
- refresh() {},
35
- back() {},
36
- forward() {},
37
- prefetch() {},
38
- };
39
-
40
30
  /**
41
31
  * Get a router instance for programmatic navigation.
42
32
  *
@@ -47,7 +37,7 @@ const SSR_NOOP_ROUTER: AppRouterInstance = {
47
37
  * because during hydration, React synchronously executes component render
48
38
  * functions *before* the router is bootstrapped in browser-entry.ts.
49
39
  * If we eagerly captured the router during render, components would get
50
- * the SSR_NOOP_ROUTER and be stuck with silent no-ops forever.
40
+ * a null reference and be stuck with silent no-ops forever.
51
41
  *
52
42
  * Returns safe no-ops during SSR or before bootstrap. The `typeof window`
53
43
  * check is insufficient because Vite's client SSR environment defines
@@ -60,7 +50,9 @@ export function useRouter(): AppRouterInstance {
60
50
  const router = getRouterOrNull();
61
51
  if (!router) {
62
52
  if (process.env.NODE_ENV === 'development') {
63
- console.error('[timber] useRouter().push() called but router is not initialized. This is a bug — please report it.');
53
+ console.error(
54
+ '[timber] useRouter().push() called but router is not initialized. This is a bug — please report it.'
55
+ );
64
56
  }
65
57
  return;
66
58
  }
@@ -1,20 +1,26 @@
1
1
  /**
2
- * timber-shims — Vite sub-plugin for next/* → timber shim resolution.
2
+ * timber-shims — Vite sub-plugin for next/* → timber shim resolution
3
+ * and #/ subpath import canonicalization.
3
4
  *
4
- * Intercepts imports of next/* modules and redirects them to timber.js
5
- * shim implementations. This enables Next.js-compatible libraries
6
- * (nuqs, next-intl, etc.) to work unmodified.
5
+ * Two responsibilities:
6
+ * 1. Intercepts imports of next/* modules and redirects them to timber.js
7
+ * shim implementations. This enables Next.js-compatible libraries
8
+ * (nuqs, next-intl, etc.) to work unmodified.
9
+ * 2. Canonicalizes #/* subpath imports (package.json "imports" field) to
10
+ * absolute file paths, preventing Vite dev from creating duplicate
11
+ * module instances when the same file is reached via different import
12
+ * paths (e.g., #/client/router-ref.js vs ./router-ref.js).
7
13
  *
8
- * NOTE: This plugin does NOT resolve @timber-js/app/* subpath imports.
9
- * Those are handled by Vite's native package.json `exports` resolution,
10
- * which maps them to dist/ files. This ensures a single module instance
11
- * for shared modules like request-context (ALS singleton).
14
+ * NOTE: This plugin does NOT resolve @timber-js/app/* subpath imports
15
+ * (except @timber-js/app/server). Those are handled by Vite's native
16
+ * package.json `exports` resolution, which maps them to dist/ files.
12
17
  *
13
18
  * Design doc: 18-build-system.md §"Shim Map"
14
19
  */
15
20
 
16
21
  import type { Plugin } from 'vite';
17
22
  import { resolve, dirname } from 'node:path';
23
+ import { existsSync } from 'node:fs';
18
24
  import { fileURLToPath } from 'node:url';
19
25
  import type { PluginContext } from '#/index.js';
20
26
 
@@ -26,6 +32,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
26
32
  const PKG_ROOT = __dirname.endsWith('plugins')
27
33
  ? resolve(__dirname, '..', '..')
28
34
  : resolve(__dirname, '..');
35
+ const SRC_DIR = resolve(PKG_ROOT, 'src');
29
36
  const SHIMS_DIR = resolve(PKG_ROOT, 'src', 'shims');
30
37
 
31
38
  /**
@@ -55,18 +62,6 @@ const SHIM_MAP: Record<string, string> = {
55
62
  'next/font/local': '\0@timber/fonts/local',
56
63
  };
57
64
 
58
- /**
59
- * Client-only shim overrides for the browser environment.
60
- *
61
- * next/navigation in the client environment resolves to navigation-client.ts
62
- * which only re-exports client hooks — not server functions like redirect()
63
- * and deny(). This prevents server/primitives.ts from being pulled into the
64
- * browser bundle via tree-shaking-resistant imports.
65
- */
66
- const CLIENT_SHIM_OVERRIDES: Record<string, string> = {
67
- 'next/navigation': resolve(SHIMS_DIR, 'navigation-client.ts'),
68
- };
69
-
70
65
  /**
71
66
  * Strip .js extension from an import specifier.
72
67
  *
@@ -77,6 +72,39 @@ function stripJsExtension(id: string): string {
77
72
  return id.endsWith('.js') ? id.slice(0, -3) : id;
78
73
  }
79
74
 
75
+ /**
76
+ * Resolve a #/* subpath import to an absolute file path.
77
+ *
78
+ * The package.json "imports" field maps #/* → ./src/*. Vite's resolver
79
+ * handles this via Node.js subpath imports, but in dev mode the resulting
80
+ * module URL can differ from a relative import to the same file. This
81
+ * causes module duplication — the same file loaded as two separate ES
82
+ * modules with separate module-level state.
83
+ *
84
+ * This function resolves #/foo/bar.js to <PKG_ROOT>/src/foo/bar.ts
85
+ * (trying .ts first, then .tsx, then .js, then the raw path).
86
+ * Returns null if the import is not a #/ import or the file doesn't exist.
87
+ */
88
+ function resolveSubpathImport(id: string): string | null {
89
+ if (!id.startsWith('#/')) return null;
90
+
91
+ // Strip the #/ prefix and map to src/
92
+ const subpath = id.slice(2);
93
+ const basePath = resolve(SRC_DIR, subpath);
94
+
95
+ // Strip .js extension and try TypeScript extensions first
96
+ const withoutJs = stripJsExtension(basePath);
97
+ for (const ext of ['.ts', '.tsx', '.js']) {
98
+ const candidate = withoutJs + ext;
99
+ if (existsSync(candidate)) return candidate;
100
+ }
101
+
102
+ // Try the raw path (e.g., if it already has the right extension)
103
+ if (existsSync(basePath)) return basePath;
104
+
105
+ return null;
106
+ }
107
+
80
108
  /**
81
109
  * Create the timber-shims Vite plugin.
82
110
  *
@@ -92,13 +120,21 @@ export function timberShims(_ctx: PluginContext): Plugin {
92
120
  enforce: 'pre',
93
121
 
94
122
  /**
95
- * Resolve next/* imports to shim files.
123
+ * Resolve imports to canonical file paths.
96
124
  *
97
125
  * Resolution order:
98
126
  * 1. Check server-only / client-only poison pill packages
99
- * 2. Strip .js extension from the import specifier
100
- * 3. Check next/* shim map
101
- * 4. Return null (pass through) for everything else
127
+ * 2. Canonicalize #/* subpath imports to absolute paths
128
+ * 3. Strip .js extension from the import specifier
129
+ * 4. Check next/* shim map (with client environment override for navigation)
130
+ * 5. Handle @timber-js/app/server
131
+ * 6. Return null (pass through) for everything else
132
+ *
133
+ * #/* canonicalization (step 2) prevents module duplication in Vite dev.
134
+ * Package.json "imports" maps #/* → ./src/*, but Vite can resolve the
135
+ * same file to different module URLs depending on the import chain.
136
+ * By resolving #/ imports to absolute paths here, all import paths
137
+ * converge to a single module instance.
102
138
  *
103
139
  * @timber-js/app/server is resolved to src/ so it shares the same module
104
140
  * instance as framework internals (which import via #/). This ensures
@@ -112,15 +148,24 @@ export function timberShims(_ctx: PluginContext): Plugin {
112
148
  if (id === 'server-only') return SERVER_ONLY_VIRTUAL;
113
149
  if (id === 'client-only') return CLIENT_ONLY_VIRTUAL;
114
150
 
151
+ // Canonicalize #/* subpath imports to absolute file paths.
152
+ // This is the fix for module duplication (LOCAL-302): ensures that
153
+ // #/client/router-ref.js and ./router-ref.js from the same directory
154
+ // resolve to the same module URL in Vite's module graph.
155
+ const subpathResolved = resolveSubpathImport(id);
156
+ if (subpathResolved) return subpathResolved;
157
+
115
158
  const cleanId = stripJsExtension(id);
116
159
 
117
160
  // Check next/* shim map.
118
- // In the client (browser) environment, use client-only shim overrides
119
- // to avoid pulling server code (primitives.ts) into the browser bundle.
161
+ // In the client (browser) environment, next/navigation resolves to
162
+ // navigation-client.ts which only re-exports client hooks not server
163
+ // functions like redirect() and deny(). This prevents server/primitives.ts
164
+ // from being pulled into the browser bundle.
120
165
  if (cleanId in SHIM_MAP) {
121
166
  const envName = (this as unknown as { environment?: { name?: string } }).environment?.name;
122
- if (envName === 'client' && cleanId in CLIENT_SHIM_OVERRIDES) {
123
- return CLIENT_SHIM_OVERRIDES[cleanId];
167
+ if (envName === 'client' && cleanId === 'next/navigation') {
168
+ return resolve(SHIMS_DIR, 'navigation-client.ts');
124
169
  }
125
170
  return SHIM_MAP[cleanId];
126
171
  }