From 43c2538234adc318391752b5b67af29671ee1832 Mon Sep 17 00:00:00 2001 From: unpunnyfuns Date: Wed, 15 Jul 2026 16:55:06 +0200 Subject: [PATCH 1/4] fix(tanstack-react): resolve mock redirects through Vite's resolver The interception plugin returned raw file paths for the mock modules. Direct user imports of the same mocks (the documented '@storybook/tanstack-react/react-router' spy-assertion path) resolve through Vite, which stamps a version query on the id, so the same file registered as two module instances and user assertions saw spies the app never called. Resolve through this.resolve() so both import paths share one module. Found by asserting on the useNavigate spy in the conformance suite. --- .../src/plugins/module-interception.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/code/frameworks/tanstack-react/src/plugins/module-interception.ts b/code/frameworks/tanstack-react/src/plugins/module-interception.ts index b32532b3b772..5fec5759d537 100644 --- a/code/frameworks/tanstack-react/src/plugins/module-interception.ts +++ b/code/frameworks/tanstack-react/src/plugins/module-interception.ts @@ -22,7 +22,19 @@ export function moduleInterceptionPlugin({ enforce: 'pre', resolveId: { order: 'pre', - handler(id: string, importer: string | undefined) { + async handler(id: string, importer: string | undefined) { + // Resolve mock files through Vite instead of returning the raw file + // path. Users also import the mocks directly (the documented + // `@storybook/tanstack-react/react-router` spy-assertion path), and + // that import goes through Vite's resolver, which stamps a `?v=` + // query on the id. A raw path here would register a SECOND module + // instance of the same file, so user assertions would see spies the + // app never calls. + const resolveMock = async (mockPath: string) => { + const resolved = await this.resolve(mockPath, importer, { skipSelf: true }); + return resolved ?? mockPath; + }; + // Redirect @tanstack/react-router to our mock, except when // the importer IS the mock (to avoid a circular alias). if ( @@ -30,21 +42,21 @@ export function moduleInterceptionPlugin({ importer && !importer.includes('export-mocks') ) { - return routerMockPath; + return resolveMock(routerMockPath); } if (START_SERVER_MODULES.includes(id) || id === '@tanstack/react-start') { - return startMockPath; + return resolveMock(startMockPath); } if (id === '@tanstack/start-storage-context') { - return startStorageContextMockPath; + return resolveMock(startStorageContextMockPath); } // Intercept virtual/server/worker entries for (const pattern of INTERCEPTED_PATTERNS) { if (id.includes(pattern)) { - return startMockPath; + return resolveMock(startMockPath); } } From b7b54886a71eb19d9b0359cc476646bb9425713e Mon Sep 17 00:00:00 2001 From: unpunnyfuns Date: Fri, 17 Jul 2026 23:24:10 +0200 Subject: [PATCH 2/4] test(tanstack-react): cover module interception resolveId Add unit tests for the interception plugin: router and start mocks resolve through Vite (so direct mock imports share one module instance), the circular guard for mock importers, intercepted patterns, and the raw-path fallback. --- .../src/plugins/module-interception.test.ts | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 code/frameworks/tanstack-react/src/plugins/module-interception.test.ts diff --git a/code/frameworks/tanstack-react/src/plugins/module-interception.test.ts b/code/frameworks/tanstack-react/src/plugins/module-interception.test.ts new file mode 100644 index 000000000000..98e3d9fa8c52 --- /dev/null +++ b/code/frameworks/tanstack-react/src/plugins/module-interception.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { moduleInterceptionPlugin } from './module-interception.ts'; + +const MOCKS = { + startMockPath: '/mocks/start.ts', + startStorageContextMockPath: '/mocks/start-storage-context.ts', + routerMockPath: '/mocks/react-router.ts', +}; + +function getHandler() { + const plugin = moduleInterceptionPlugin(MOCKS); + const resolveId = plugin.resolveId as any; + return typeof resolveId === 'function' ? resolveId : resolveId.handler; +} + +// Vite's resolver stamps a `?v=` query on the resolved id. The mock mirrors +// that so a raw path (the pre-fix behavior) is distinguishable from a properly +// delegated resolution. +function viteResolve() { + return vi.fn(async (source: string) => ({ id: `${source}?v=abc123` })); +} + +async function resolveId( + id: string, + importer: string | undefined, + resolve = viteResolve() +): Promise<{ result: unknown; resolve: ReturnType }> { + const handler = getHandler(); + const result = await handler.call({ resolve }, id, importer); + return { result, resolve }; +} + +describe('moduleInterceptionPlugin resolveId', () => { + describe('router interception', () => { + it('resolves the router mock through Vite instead of returning the raw path', async () => { + const { result, resolve } = await resolveId('@tanstack/react-router', '/src/story.ts'); + + expect(resolve).toHaveBeenCalledWith(MOCKS.routerMockPath, '/src/story.ts', { + skipSelf: true, + }); + expect((result as { id: string }).id).toBe('/mocks/react-router.ts?v=abc123'); + }); + + it('intercepts router subpath imports', async () => { + const { result } = await resolveId('@tanstack/react-router/ssr', '/src/story.ts'); + expect((result as { id: string }).id).toBe('/mocks/react-router.ts?v=abc123'); + }); + + it('does not intercept when the importer is a mock (avoids a circular alias)', async () => { + const { result, resolve } = await resolveId( + '@tanstack/react-router', + '/framework/src/export-mocks/react-router.ts' + ); + expect(result).toBeNull(); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('does not intercept a bare router import with no importer', async () => { + const { result } = await resolveId('@tanstack/react-router', undefined); + expect(result).toBeNull(); + }); + }); + + describe('start interception', () => { + it.each([ + '@tanstack/react-start', + '@tanstack/react-start/server', + '@tanstack/react-start-server', + '@tanstack/start-server-core', + ])('resolves the start mock for %s', async (id) => { + const { result, resolve } = await resolveId(id, '/src/story.ts'); + expect(resolve).toHaveBeenCalledWith(MOCKS.startMockPath, '/src/story.ts', { + skipSelf: true, + }); + expect((result as { id: string }).id).toBe('/mocks/start.ts?v=abc123'); + }); + + it('resolves the storage-context mock', async () => { + const { result } = await resolveId('@tanstack/start-storage-context', '/src/story.ts'); + expect((result as { id: string }).id).toBe('/mocks/start-storage-context.ts?v=abc123'); + }); + + it.each(['virtual:cloudflare', 'server-entry', 'worker-entry'])( + 'resolves the start mock for intercepted pattern %s', + async (pattern) => { + const { result } = await resolveId(`some/${pattern}/thing`, '/src/story.ts'); + expect((result as { id: string }).id).toBe('/mocks/start.ts?v=abc123'); + } + ); + }); + + describe('fallbacks', () => { + it('falls back to the raw mock path when Vite cannot resolve it', async () => { + const resolve = vi.fn(async () => null); + const { result } = await resolveId('@tanstack/react-router', '/src/story.ts', resolve); + expect(result).toBe(MOCKS.routerMockPath); + }); + + it('returns null for unrelated ids', async () => { + const { result, resolve } = await resolveId('react', '/src/story.ts'); + expect(result).toBeNull(); + expect(resolve).not.toHaveBeenCalled(); + }); + }); +}); From dc16219a4b5fc0dbcdb80aa9754b7535890781bb Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Fri, 24 Jul 2026 11:16:34 +0200 Subject: [PATCH 3/4] Update code/frameworks/tanstack-react/src/plugins/module-interception.ts --- .../tanstack-react/src/plugins/module-interception.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/code/frameworks/tanstack-react/src/plugins/module-interception.ts b/code/frameworks/tanstack-react/src/plugins/module-interception.ts index 5fec5759d537..d98eeb9c1caa 100644 --- a/code/frameworks/tanstack-react/src/plugins/module-interception.ts +++ b/code/frameworks/tanstack-react/src/plugins/module-interception.ts @@ -23,13 +23,6 @@ export function moduleInterceptionPlugin({ resolveId: { order: 'pre', async handler(id: string, importer: string | undefined) { - // Resolve mock files through Vite instead of returning the raw file - // path. Users also import the mocks directly (the documented - // `@storybook/tanstack-react/react-router` spy-assertion path), and - // that import goes through Vite's resolver, which stamps a `?v=` - // query on the id. A raw path here would register a SECOND module - // instance of the same file, so user assertions would see spies the - // app never calls. const resolveMock = async (mockPath: string) => { const resolved = await this.resolve(mockPath, importer, { skipSelf: true }); return resolved ?? mockPath; From 61b4f9a4aad77f5702414b92e03428cf8295ea3e Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Fri, 24 Jul 2026 13:39:24 +0200 Subject: [PATCH 4/4] Update code/frameworks/tanstack-react/src/plugins/module-interception.test.ts --- .../tanstack-react/src/plugins/module-interception.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/code/frameworks/tanstack-react/src/plugins/module-interception.test.ts b/code/frameworks/tanstack-react/src/plugins/module-interception.test.ts index 98e3d9fa8c52..eca68b4645ab 100644 --- a/code/frameworks/tanstack-react/src/plugins/module-interception.test.ts +++ b/code/frameworks/tanstack-react/src/plugins/module-interception.test.ts @@ -18,7 +18,9 @@ function getHandler() { // that so a raw path (the pre-fix behavior) is distinguishable from a properly // delegated resolution. function viteResolve() { - return vi.fn(async (source: string) => ({ id: `${source}?v=abc123` })); + return vi.fn( + async (source: string): Promise<{ id: string } | null> => ({ id: `${source}?v=abc123` }) + ); } async function resolveId(