@timber-js/app 0.1.16 → 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.
- package/dist/client/index.js +21 -19
- package/dist/client/index.js.map +1 -1
- package/dist/client/router-ref.d.ts +12 -0
- package/dist/client/router-ref.d.ts.map +1 -1
- package/dist/client/use-router.d.ts +1 -1
- package/dist/client/use-router.d.ts.map +1 -1
- package/dist/index.js +36 -14
- package/dist/index.js.map +1 -1
- package/dist/plugins/shims.d.ts +13 -8
- package/dist/plugins/shims.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/browser-entry.ts +77 -54
- package/src/client/router-ref.ts +26 -0
- package/src/client/use-router.ts +27 -20
- package/src/plugins/shims.ts +73 -28
package/dist/plugins/shims.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
10
|
-
* which maps them to dist/ files.
|
|
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
|
|
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
|
@@ -130,6 +130,17 @@ setServerCallback(async (id: string, args: unknown[]) => {
|
|
|
130
130
|
* Hydrates the server-rendered HTML with React, then initializes
|
|
131
131
|
* client-side navigation for SPA transitions.
|
|
132
132
|
*/
|
|
133
|
+
/** Read scroll position from window or scroll containers. */
|
|
134
|
+
function getScrollY(): number {
|
|
135
|
+
if (window.scrollY || document.documentElement.scrollTop || document.body.scrollTop) {
|
|
136
|
+
return window.scrollY || document.documentElement.scrollTop || document.body.scrollTop;
|
|
137
|
+
}
|
|
138
|
+
for (const el of document.querySelectorAll('[data-timber-scroll-restoration]')) {
|
|
139
|
+
if ((el as HTMLElement).scrollTop > 0) return (el as HTMLElement).scrollTop;
|
|
140
|
+
}
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
133
144
|
function bootstrap(runtimeConfig: typeof config): void {
|
|
134
145
|
const _config = runtimeConfig;
|
|
135
146
|
|
|
@@ -157,6 +168,9 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
157
168
|
|
|
158
169
|
let reactRoot: Root | null = null;
|
|
159
170
|
let initialElement: unknown = null;
|
|
171
|
+
// Declared here so it's accessible after the if/else hydration block.
|
|
172
|
+
// Assigned inside initRouter() which is called in both branches.
|
|
173
|
+
let router!: RouterInstance;
|
|
160
174
|
|
|
161
175
|
if (timberChunks) {
|
|
162
176
|
const encoder = new TextEncoder();
|
|
@@ -240,6 +254,15 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
240
254
|
|
|
241
255
|
const element = createFromReadableStream(rscPayload);
|
|
242
256
|
initialElement = element;
|
|
257
|
+
|
|
258
|
+
// ── Initialize the navigation router BEFORE hydration ──────────────
|
|
259
|
+
// hydrateRoot() synchronously executes component render functions.
|
|
260
|
+
// Components that call useRouter() during render need the global
|
|
261
|
+
// router to be available, otherwise they get a stale no-op reference.
|
|
262
|
+
// The renderRoot callback reads `reactRoot` lazily (via closure), so
|
|
263
|
+
// it's safe to create the router before reactRoot is assigned.
|
|
264
|
+
initRouter();
|
|
265
|
+
|
|
243
266
|
// Hydrate on document — the root layout renders the full <html> tree,
|
|
244
267
|
// so React owns the entire document from the root.
|
|
245
268
|
// Wrap with TimberNuqsAdapter so useQueryStates works out of the box.
|
|
@@ -267,66 +290,66 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
267
290
|
// non-hydrated root so client navigation can still render RSC payloads.
|
|
268
291
|
// The initial SSR HTML remains as-is; the first client navigation will
|
|
269
292
|
// replace it with a React-managed tree.
|
|
293
|
+
initRouter();
|
|
270
294
|
reactRoot = createRoot(document);
|
|
271
295
|
}
|
|
272
296
|
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
(
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
decodeRsc: (fetchPromise: Promise<Response>) => {
|
|
302
|
-
return createFromFetch(fetchPromise);
|
|
303
|
-
},
|
|
297
|
+
// ── Router initialization (hoisted above hydrateRoot) ────────────────
|
|
298
|
+
// Extracted into a function so both the hydration and createRoot paths
|
|
299
|
+
// can call it. Must run before hydrateRoot so useRouter() works during
|
|
300
|
+
// the initial render. The renderRoot dep reads `reactRoot` via closure,
|
|
301
|
+
// so it's fine that reactRoot is assigned after this runs.
|
|
302
|
+
function initRouter(): void {
|
|
303
|
+
const deps: RouterDeps = {
|
|
304
|
+
fetch: (url, init) => window.fetch(url, init),
|
|
305
|
+
pushState: (data, unused, url) => window.history.pushState(data, unused, url),
|
|
306
|
+
replaceState: (data, unused, url) => window.history.replaceState(data, unused, url),
|
|
307
|
+
scrollTo: (x, y) => {
|
|
308
|
+
window.scrollTo(x, y);
|
|
309
|
+
document.documentElement.scrollTop = y;
|
|
310
|
+
document.body.scrollTop = y;
|
|
311
|
+
// Also scroll any element explicitly marked as a scroll container.
|
|
312
|
+
for (const el of document.querySelectorAll('[data-timber-scroll-restoration]')) {
|
|
313
|
+
(el as HTMLElement).scrollTop = y;
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
getCurrentUrl: () => window.location.pathname + window.location.search,
|
|
317
|
+
getScrollY,
|
|
318
|
+
|
|
319
|
+
// Decode RSC Flight stream using createFromFetch.
|
|
320
|
+
// createFromFetch takes a Promise<Response> and progressively
|
|
321
|
+
// parses the RSC stream as chunks arrive.
|
|
322
|
+
decodeRsc: (fetchPromise: Promise<Response>) => {
|
|
323
|
+
return createFromFetch(fetchPromise);
|
|
324
|
+
},
|
|
304
325
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
326
|
+
// Render decoded RSC tree into the hydrated React root.
|
|
327
|
+
// Wrap with TimberNuqsAdapter to maintain nuqs context across navigations.
|
|
328
|
+
// Reads `reactRoot` from the outer closure — assigned after hydrateRoot().
|
|
329
|
+
renderRoot: (element: unknown) => {
|
|
330
|
+
if (reactRoot) {
|
|
331
|
+
const wrapped = createElement(TimberNuqsAdapter, null, element as React.ReactNode);
|
|
332
|
+
reactRoot.render(wrapped);
|
|
333
|
+
}
|
|
334
|
+
},
|
|
313
335
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
336
|
+
// Schedule a callback after the next paint so scroll operations
|
|
337
|
+
// happen after React commits the new content to the DOM.
|
|
338
|
+
// Double-rAF ensures the browser has painted the new frame.
|
|
339
|
+
afterPaint: (callback: () => void) => {
|
|
340
|
+
requestAnimationFrame(() => {
|
|
341
|
+
requestAnimationFrame(callback);
|
|
342
|
+
});
|
|
343
|
+
},
|
|
322
344
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
345
|
+
// Apply resolved head elements (title, meta tags) to the DOM after
|
|
346
|
+
// SPA navigation. See design/16-metadata.md.
|
|
347
|
+
applyHead: applyHeadElements,
|
|
348
|
+
};
|
|
327
349
|
|
|
328
|
-
|
|
329
|
-
|
|
350
|
+
router = createRouter(deps);
|
|
351
|
+
setGlobalRouter(router);
|
|
352
|
+
}
|
|
330
353
|
|
|
331
354
|
// Store the initial page in the history stack so back-button works
|
|
332
355
|
// after the first navigation. We store the decoded RSC element so
|
|
@@ -379,7 +402,7 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
379
402
|
const state = window.history.state;
|
|
380
403
|
if (state && typeof state === 'object') {
|
|
381
404
|
// Use getScrollY to capture scroll from overflow containers too.
|
|
382
|
-
window.history.replaceState({ ...state, scrollY:
|
|
405
|
+
window.history.replaceState({ ...state, scrollY: getScrollY() }, '');
|
|
383
406
|
}
|
|
384
407
|
}, 100);
|
|
385
408
|
}
|
package/src/client/router-ref.ts
CHANGED
|
@@ -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
|
/**
|
|
@@ -23,3 +31,21 @@ export function getRouter(): RouterInstance {
|
|
|
23
31
|
}
|
|
24
32
|
return globalRouter;
|
|
25
33
|
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the global router instance or null if not yet initialized.
|
|
37
|
+
* Used by useRouter() methods to avoid silent failures — callers
|
|
38
|
+
* can log a meaningful warning instead of silently no-oping.
|
|
39
|
+
*/
|
|
40
|
+
export function getRouterOrNull(): RouterInstance | null {
|
|
41
|
+
return globalRouter;
|
|
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
|
+
}
|
package/src/client/use-router.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { startTransition } from 'react';
|
|
13
|
-
import {
|
|
13
|
+
import { getRouterOrNull } from './router-ref.js';
|
|
14
14
|
|
|
15
15
|
export interface AppRouterInstance {
|
|
16
16
|
/** Navigate to a URL, pushing a new history entry */
|
|
@@ -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
|
-
*
|
|
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
|
|
@@ -57,8 +47,15 @@ const SSR_NOOP_ROUTER: AppRouterInstance = {
|
|
|
57
47
|
export function useRouter(): AppRouterInstance {
|
|
58
48
|
return {
|
|
59
49
|
push(href: string, options?: { scroll?: boolean }) {
|
|
60
|
-
|
|
61
|
-
|
|
50
|
+
const router = getRouterOrNull();
|
|
51
|
+
if (!router) {
|
|
52
|
+
if (process.env.NODE_ENV === 'development') {
|
|
53
|
+
console.error(
|
|
54
|
+
'[timber] useRouter().push() called but router is not initialized. This is a bug — please report it.'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
62
59
|
// Wrap in startTransition so React 19 tracks the async navigation.
|
|
63
60
|
// React 19's startTransition accepts async callbacks — it keeps
|
|
64
61
|
// isPending=true until the returned promise resolves. This means
|
|
@@ -69,15 +66,25 @@ export function useRouter(): AppRouterInstance {
|
|
|
69
66
|
});
|
|
70
67
|
},
|
|
71
68
|
replace(href: string, options?: { scroll?: boolean }) {
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
const router = getRouterOrNull();
|
|
70
|
+
if (!router) {
|
|
71
|
+
if (process.env.NODE_ENV === 'development') {
|
|
72
|
+
console.error('[timber] useRouter().replace() called but router is not initialized.');
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
74
76
|
startTransition(async () => {
|
|
75
77
|
await router.navigate(href, { scroll: options?.scroll, replace: true });
|
|
76
78
|
});
|
|
77
79
|
},
|
|
78
80
|
refresh() {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
const router = getRouterOrNull();
|
|
82
|
+
if (!router) {
|
|
83
|
+
if (process.env.NODE_ENV === 'development') {
|
|
84
|
+
console.error('[timber] useRouter().refresh() called but router is not initialized.');
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
81
88
|
startTransition(async () => {
|
|
82
89
|
await router.refresh();
|
|
83
90
|
});
|
|
@@ -89,8 +96,8 @@ export function useRouter(): AppRouterInstance {
|
|
|
89
96
|
if (typeof window !== 'undefined') window.history.forward();
|
|
90
97
|
},
|
|
91
98
|
prefetch(href: string) {
|
|
92
|
-
|
|
93
|
-
|
|
99
|
+
const router = getRouterOrNull();
|
|
100
|
+
if (!router) return; // Silent — prefetch failure is non-fatal
|
|
94
101
|
router.prefetch(href);
|
|
95
102
|
},
|
|
96
103
|
};
|
package/src/plugins/shims.ts
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
10
|
-
* which maps them to dist/ files.
|
|
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
|
|
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.
|
|
100
|
-
* 3.
|
|
101
|
-
* 4.
|
|
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,
|
|
119
|
-
//
|
|
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
|
|
123
|
-
return
|
|
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
|
}
|