Skip to content

"{hook} may be used only in the context of a <Router> component" when running with vitest, node >=22.12 and there's a peer-dep on react-router #12785

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
acelaya opened this issue Jan 20, 2025 · 26 comments

Comments

@acelaya
Copy link

acelaya commented Jan 20, 2025

I'm using React Router as a...

library

Reproduction

I created the minimum steps to reproduce in this repository: https://github.com/acelaya/peer-react-router-vitest

The readme includes the steps to reproduce the issue.

System Info

System:
    OS: Linux 6.8 Ubuntu 24.04.1 LTS 24.04.1 LTS (Noble Numbat)
    CPU: (16) x64 11th Gen Intel(R) Core(TM) i7-11700K @ 3.60GHz
    Memory: 50.17 GB / 62.68 GB
    Container: Yes
    Shell: 5.9 - /usr/bin/zsh
  Binaries:
    Node: 22.13.0 - ~/.nvm/versions/node/v22.13.0/bin/node
    npm: 10.9.2 - ~/.nvm/versions/node/v22.13.0/bin/npm
  Browsers:
    Chrome: 131.0.6778.264
  npmPackages:
    react-router: ^7.1.3 => 7.1.3 
    vite: ^6.0.5 => 6.0.8

Used Package Manager

npm

Expected Behavior

It should be possible to depend on packages that have a peer dependency on react-router, and have no errors when running with vitest.

Actual Behavior

In short, if your project depends on react-router and a dependency which in turn has a peer-dependency on react router, when running with vitests, all imports from react-router inside that dependency will resolve a different instance than the ones in the root project, causing errors like useLocation() may be used only in the context of a <Router> component.

This issue was reported in #12475, but it was then closed as a solution was provided that solved it for other use cases, but the error still exists when running with vitest.

It also only affects node 22.12 and newer. Earlier versions work as expected. This can be seen in this GitHub workflow execution, from the repro repository above: https://github.com/acelaya/peer-react-router-vitest/actions/runs/12864662838

@acelaya acelaya added the bug label Jan 20, 2025
@acelaya acelaya changed the title "<hook> may be used only in the context of a <Router> component" when running with vitest, node >=22.12 and there's a peer-dep on react-router "{hook} may be used only in the context of a <Router> component" when running with vitest, node >=22.12 and there's a peer-dep on react-router Jan 20, 2025
@EmiBemi
Copy link

EmiBemi commented Jan 20, 2025

I am facing the same issues after replacing react-router-dom with react-router v7.

@justinwaite
Copy link

justinwaite commented Jan 20, 2025

This issue is present for me when using tsx to run a custom server in dev mode. When building and running in production, the issue is not present.

I've experimented with node 22.12 and 22.13, as well as vite 5 and vite 6. The issue persists across all of them. Using react-router 7.1.3.

The external package that I am using is @rvf/react-router

@justinwaite
Copy link

Removing module-sync in node_modules/react-router/package.json does resolve the issue for me.

@acelaya
Copy link
Author

acelaya commented Jan 21, 2025

Removing module-sync in node_modules/react-router/package.json does resolve the issue for me.

True! I forgot to mention that in the description.

That's indeed what fixed it for me in the original issue #12475 (comment)

@MichaelvdVeer
Copy link

MichaelvdVeer commented Jan 21, 2025

I am also facing the same issues with React Router v7 and Vite when using the new Clerk React Router SDK. Even when I use Node version 22.11 or lower, as well as Vite 5 and 6, I get this error message:

Image

I'm using these dependencies:

  "dependencies": {
    "@chakra-ui/react": "^3.1.0",
    "@clerk/react-router": "^0.2.1",
    "@emotion/react": "^11.13.3",
    "@reduxjs/toolkit": "^2.3.0",
    "next-themes": "^0.3.0",
    "react": "^18.3.1",
    "react-avatar-editor": "^13.0.2",
    "react-dom": "^18.3.1",
    "react-dropzone": "^14.2.10",
    "react-headroom": "^3.2.1",
    "react-icons": "^5.3.0",
    "react-redux": "^9.1.2",
    "react-router": "^7.1.3",
    "react-use": "^17.5.1",
    "redux-logger": "^3.0.6"
  },
  "devDependencies": {
    "@eslint/js": "^9.13.0",
    "@types/react": "^18.3.11",
    "@types/react-dom": "^18.3.1",
    "@vitejs/plugin-react": "^4.3.3",
    "eslint": "^9.13.0",
    "eslint-plugin-react": "^7.37.2",
    "eslint-plugin-react-hooks": "^5.0.0",
    "eslint-plugin-react-refresh": "^0.4.14",
    "globals": "^15.11.0",
    "vite": "^5.4.10",
    "vite-plugin-env": "^1.0.1"
  }

@MichaelvdVeer
Copy link

MichaelvdVeer commented Jan 21, 2025

@justinwaite Do you mean i need to remove the module-sync line in node_modules/react-router/package.json ?

 "exports": {
    ".": {
      "node": {
        "types": "./dist/development/index.d.ts",
        "module-sync": "./dist/development/index.mjs",        <--------------- Remove this line
        "default": "./dist/development/index.js"
      },
      "import": {
        "types": "./dist/development/index.d.mts",
        "default": "./dist/development/index.mjs"
      },
      "default": {
        "types": "./dist/development/index.d.ts",
        "default": "./dist/development/index.js"
      }
    },
    "./route-module": {
      "import": {
        "types": "./dist/development/lib/types/route-module.d.mts"
      },
      "default": {
        "types": "./dist/development/lib/types/route-module.d.ts"
      }
    },
    "./dom": {
      "node": {
        "types": "./dist/development/dom-export.d.ts",
        "module-sync": "./dist/development/dom-export.mjs",        <--------------- Remove this line
        "default": "./dist/development/dom-export.js"
      },
      "import": {
        "types": "./dist/development/dom-export.d.mts",
        "default": "./dist/development/dom-export.mjs"
      },
      "default": {
        "types": "./dist/development/dom-export.d.ts",
        "default": "./dist/development/dom-export.js"
      }
    },
    "./package.json": "./package.json"
  },

And then delete the cache?

rm -rf node_modules/.vite 

@acelaya
Copy link
Author

acelaya commented Jan 21, 2025

@MichaelvdVeer based on your description, it seems to me you are facing a different scenario. Perhaps you are in fact using the hook outside of a router.

@MichaelvdVeer
Copy link

Hi @acelaya,

Thanks for your reply!

I am using the ClerkProvider in main.jsx and then using createBrowserRouter in a separate file (AppRoutes.jsx). I’ve created a minimal reproduction of the issue, which you can find here: https://github.com/MichaelvdVeer/clerk-react-router-example.

Could you take a look at this and let me know if I’m missing something?

Thanks in advance! If needed, I will create a separate issue.

@acelaya
Copy link
Author

acelaya commented Jan 21, 2025

If needed, I will create a separate issue.

Yes, please. Let's not derail the topic here.

@sergei-maertens

This comment has been minimized.

@sergei-maertens
Copy link

@acelaya I think I've found the root cause - since I was able to use your repo to reproduce the issue and "fix" it by applying a workaround.

Looking at the bundles in node_modules/react-router/dist/development/ you find two separate .js bundles:

  • dom-export.js
  • index.js

Which are referred to from the package.json exports:

These are actual bundles, meaning that the RouterProvider components are defined in each of them, so they're separate JS objects in the runtime and that's the root cause of the problem. Whenever you use RouterProvider from react-router/dom it's supposed to wrap RouterProvider from react-router, but it's actually an entirely different copy. We import the hooks from react-router, so they're using pointing to different copies.

The ESM bundles (dom-export.mjs and index.mjs) don't have this problem, since they both import the shared API from the same chunk, meaning that RouterProvider in both is the same thing, and as such, so is the context it provides and the hook from the main package has access to the context of the react-router/dom wrapper RouterProvider.

You can verify this by editing node_modules/react-router/package.json and changing the references to the .js files to the .mjs files (just changing the extension is enough), and now everything works as expected.

Shell output after this modification:

➜  peer-react-router-vitest git:(main) npm run test                              

> [email protected] test
> vitest


 DEV  v3.0.2 /tmp/peer-react-router-vitest

stdout | test/DummyComponent.test.tsx > DummyComponent > fails to import correct router instance
{}

 ✓ test/DummyComponent.test.tsx (1 test) 16ms
   ✓ DummyComponent > fails to import correct router instance

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  12:07:11
   Duration  984ms (transform 58ms, setup 122ms, collect 222ms, tests 16ms, environment 359ms, prepare 85ms)

 PASS  Waiting for file changes...
       press h to show help, press q to quit

@acelaya
Copy link
Author

acelaya commented Jan 23, 2025

Thanks @sergei-maertens, but yeah, the root cause was already known, and discussed in #12475

I reported this to see why with certain versions of node, and when using vitest, the wrong bundle was being resolved for packages that peer-depend on react-router.

I don't know if the fix should be done in react-router or some other package.

@sergei-maertens
Copy link

Ah damn, I've been reading through some many issues and still dismissed that one...

IMO the bundling/packaging problem is something to fix in react-router because anything not able to use the modules is affected by this and it looks like there's no workaround for it (except for manually providing the flushSync implementation I suppose).

I'm going to dive a bit deeper in the module resolution and see if I can either figure out why it's happening or if there's some Vite config that would make it possible to point it to the right imports.

Thanks for the extra insights!

@sergei-maertens
Copy link

Thanks @sergei-maertens, but yeah, the root cause was already known, and discussed in #12475

Hrm, I read through the entire issue and I'm not sure if the conclusion is the same - that issue seems to be more about the module-sync not being picked up consistently, but I don't see it address the different identities present in index.js and dom-exports.js for the same source code.

My tests pass if I update my import from import {RouterProvider} from 'react-router/dom'; to import {RouterProvider} from 'react-router';, because (I suspect) they're all relying on react-router/dist/development/index.js, while the correct usage leads to hooks and context objects being used from react-router/dist/development/index.js while the provider from react-router/dist/development/dom-exports.js has different hooks and context objects that lead to the mismatch. This problem is resolved when you use the ESM exports because they happen to import all those objects from the same chunks, thereby giving them the same identity, and I get the feeling that this is more by coincidence than deliberate chunk design.

If the CJS bundles (index.js and dom-exports.js) would also import/require from the same shared module, I suspect this problem wouldn't exist in the first place, so my suspicion is that fully standalone CJS bundles are the root cause.

@sergei-maertens
Copy link

Okay, I didn't manage to fully understand why vitest seems to prefer the CommonJS build - maybe it's because there's no module conditional in the react-router package.json, but overriding the module resolution via the Vite config works with Vitest and seems to work with my dev and production builds too:

diff --git a/vite.config.mts b/vite.config.mts
index 472f0ed7..7921afa9 100644
--- a/vite.config.mts
+++ b/vite.config.mts
@@ -4,6 +4,7 @@ import {codecovVitePlugin} from '@codecov/vite-plugin';
 import replace from '@rollup/plugin-replace';
 import {sentryVitePlugin} from '@sentry/vite-plugin';
 import react from '@vitejs/plugin-react';
+import path from 'path';
 import type {OutputOptions} from 'rollup';
 import {defineConfig} from 'vite';
 import jsconfigPaths from 'vite-jsconfig-paths';
@@ -164,6 +165,16 @@ export default defineConfig(({mode}) => ({
       uploadToken: process.env.CODECOV_TOKEN,
     }),
   ],
+  resolve: {
+    alias: {
+      // ensure react-router imports don't end up with multiple copies/installations. See
+      // https://github.com/remix-run/react-router/issues/12785 for more context.
+      'react-router/dom': path.resolve(
+        './node_modules/react-router/dist/development/dom-export.mjs'
+      ),
+      'react-router': path.resolve('./node_modules/react-router/dist/development/index.mjs'),
+    },
+  },
   build: {
     target: 'modules', // the default
     assetsInlineLimit: 8 * 1024, // 8 KiB

sergei-maertens added a commit to open-formulieren/open-forms-sdk that referenced this issue Jan 23, 2025
In vitest the react-router and react-router/dom imports resolve to
(CommonJS?) bundles which each contain copies of the contexts/objects
used by react router, which make you effectively end up with multiple
installations of react router. This leads to broken contexts and failing
tests, see remix-run/react-router#12785 for
more context.

Patching the resolution in vite config allows us to force loading the
mjs modules instead of the CJS build which doesn't have this problem,
because both the react-router and react-router/dom entrypoints depend
on the same chunk containing the shared code.
sergei-maertens added a commit to open-formulieren/open-forms-sdk that referenced this issue Jan 23, 2025
In vitest the react-router and react-router/dom imports resolve to
(CommonJS?) bundles which each contain copies of the contexts/objects
used by react router, which make you effectively end up with multiple
installations of react router. This leads to broken contexts and failing
tests, see remix-run/react-router#12785 for
more context.

Patching the resolution in vite config allows us to force loading the
mjs modules instead of the CJS build which doesn't have this problem,
because both the react-router and react-router/dom entrypoints depend
on the same chunk containing the shared code.
sergei-maertens added a commit to open-formulieren/open-forms-sdk that referenced this issue Jan 23, 2025
In vitest the react-router and react-router/dom imports resolve to
(CommonJS?) bundles which each contain copies of the contexts/objects
used by react router, which make you effectively end up with multiple
installations of react router. This leads to broken contexts and failing
tests, see remix-run/react-router#12785 for
more context.

Patching the resolution in vite config allows us to force loading the
mjs modules instead of the CJS build which doesn't have this problem,
because both the react-router and react-router/dom entrypoints depend
on the same chunk containing the shared code.
@acelaya
Copy link
Author

acelaya commented Jan 24, 2025

Okay, I didn't manage to fully understand why vitest seems to prefer the CommonJS build - maybe it's because there's no module conditional in the react-router package.json, but overriding the module resolution via the Vite config works with Vitest and seems to work with my dev and production builds too:

index 472f0ed7..7921afa9 100644
--- a/vite.config.mts
+++ b/vite.config.mts
@@ -4,6 +4,7 @@ import {codecovVitePlugin} from '@codecov/vite-plugin';
 import replace from '@rollup/plugin-replace';
 import {sentryVitePlugin} from '@sentry/vite-plugin';
 import react from '@vitejs/plugin-react';
+import path from 'path';
 import type {OutputOptions} from 'rollup';
 import {defineConfig} from 'vite';
 import jsconfigPaths from 'vite-jsconfig-paths';
@@ -164,6 +165,16 @@ export default defineConfig(({mode}) => ({
       uploadToken: process.env.CODECOV_TOKEN,
     }),
   ],
+  resolve: {
+    alias: {
+      // ensure react-router imports don't end up with multiple copies/installations. See
+      // https://github.com/remix-run/react-router/issues/12785 for more context.
+      'react-router/dom': path.resolve(
+        './node_modules/react-router/dist/development/dom-export.mjs'
+      ),
+      'react-router': path.resolve('./node_modules/react-router/dist/development/index.mjs'),
+    },
+  },
   build: {
     target: 'modules', // the default
     assetsInlineLimit: 8 * 1024, // 8 KiB

This is a nice workaround for node >=22.12, but it seems to have the opposite effect, and makes it fail with older node versions.

I guess it would be possible to detect the node version and add that dynamically.

@sergei-maertens
Copy link

That's... Interesting since I'm on Node 20 😬

@acelaya
Copy link
Author

acelaya commented Feb 6, 2025

I found a not so terrible workaround, inspired on @sergei-maertens proposal.

Since I'm only experiencing this problem when running tests with vitests, I used the aliasing resolution only inside the test block, to not affect vite dev server or vite bundling.

If you use different config files for vite and vitest, then you can use it as described there.

Additionally, adding the alises fixes the problem for node >22.10, but it breaks it for older versions, where it works without the aliases. Hence, I have added a small logic to check current node version, and dynamically add the aliases only if running in node >22.10.

vite.config.ts

import { defineConfig } from 'vitest/config';

+ const DEFAULT_NODE_VERSION = 'v22.10.0';
+ const nodeVersion = process.version ?? DEFAULT_NODE_VERSION;

export default defineConfig({
  plugins: [/* [...] */],
  // [...]

  test: {
    // [...]

+   alias: nodeVersion > DEFAULT_NODE_VERSION
+      ? {
+       'react-router': resolve(__dirname, 'node_modules/react-router/dist/development/index.mjs'),
+       }
+       : undefined,
  }
});

Notice the way the node version is compared (nodeVersion > DEFAULT_NODE_VERSION) is a bit brittle. A proper version comparison would be better, but this gives you an idea.

@markdalgleish
Copy link
Member

If you're experiencing this issue, can you try adding the following to the Vite config you use for Vitest and report back?

  resolve: {
    conditions: ["module-sync"],
  },

@acelaya
Copy link
Author

acelaya commented Mar 18, 2025

If you're experiencing this issue, can you try adding the following to the Vite config you use for Vitest and report back?

resolve: {
conditions: ["module-sync"],
},

Yep, it does fix it for node >=22.12, but it makes it fail with older versions.

@dwiyatci
Copy link

dwiyatci commented Apr 3, 2025

Off on a tangent, for tormented miserable souls out there who are still stuck in CRA project (like me), here's the CRA counterpart (webpack config) using react-app-rewired's override and it works for me :v

module.exports = function override(config) {
  const reactRouterPath = path.resolve(
    __dirname,
    'node_modules/react-router/dist/development/index.mjs',
  );
  const moduleScopePlugin = config.resolve.plugins.find(
    (plugin) => plugin.constructor.name === 'ModuleScopePlugin',
  );
  moduleScopePlugin.allowedFiles.add(reactRouterPath);
  moduleScopePlugin.allowedPaths.push(path.dirname(reactRouterPath));

  config.resolve.alias = {
    ...config.resolve.alias,
    'react-router': reactRouterPath,
  };

  return config;
};

pechersky added a commit to pechersky/lean4web that referenced this issue Apr 3, 2025
Only on initial load; overrides the stored preferences

With expansion possibility for other preference keys
In the future, might want to also _set_ params
with useSearchParams hook
although I ran into
remix-run/react-router#12785
trying that

Also clean up TODO about typing of newPreferences
joneugster pushed a commit to leanprover-community/lean4web that referenced this issue Apr 11, 2025
* Support reading showGoalNames from URL params on page load

Only on initial load; overrides the stored preferences

With expansion possibility for other preference keys
In the future, might want to also _set_ params
with useSearchParams hook
although I ran into
remix-run/react-router#12785
trying that

Also clean up TODO about typing of newPreferences

* Factor out Entries with a docstring

* expand user settable values

and combine searchparam and storage branches
fix the record typing, so that in the typeof branches, the expected types match up

* allow url param mobile override

* documentation on URL parameters

* support shorthand themes in urlparams
@stevensacks
Copy link

@markdalgleish I was having this issue in Vitest and adding the resolve conditions module sync block to vitest.config.ts solved it!

@brophdawg11
Copy link
Contributor

Hey folks - we are fixing the bundling issue mentioned above in #13497 and that's available in an experimental release (0.0.0-experimental-818f8e08d) if anyone wants to see if that helps resolve their issues. I think that will resolve any issues on versions of node <22.12 but I believe there is still an issue on node 22.12 and above.

@brophdawg11
Copy link
Contributor

@markdalgleish Bringing the conversation from #12512 over here since I think it's the same issue.

In that reproduction it's not a 3rd party lib with a peerDep that imports react-router, but instead react-router/dom which imports from react-router. It also only starts happening on node 22.12 there as well.

I did some instrumentation on my branch in the repro and was able to confirm:

  • our import statements from the test file both land in the CJS version of react-router and react-router/dom
    • this creates the first set of RR React Contexts
  • but then when the CJS version of react-router/dom does require('react-router'), that loads the ESM version of react -router
    • this creates the second set of React contexts

The source code looks like this:

import { createMemoryRouter, RouterProvider, useNavigate } from "react-router";
import { RouterProvider as RouterProviderDOM } from "react-router/dom";

And my instrumented logs come out as:

> npm run test
...
Loaded index.js (CJS), creating contexts                 # From import {} from 'react-router'
Loaded dom-export.js (CJS)                               # From import {} from 'react-router/dom'
calling require("react-router") from dom-export.js
Loaded chunk-XXXXXXX.js (ESM), creating contexts (ESM)   # require() loads the ESM version 🤔
done require("react-router") from dom-export.js

I can also confirm that adding resolve: { conditions: ["module-sync"] } to the vite config in my repro fixes the issue.

@JakeGinnivan
Copy link

JakeGinnivan commented May 6, 2025

How did you do this instrumentation @brophdawg11, pretty sure I tried the above and still had the issue when running the app in dev mode.

@brophdawg11
Copy link
Contributor

I just added console logs to the files in node_modules/react-router/dist/develoment to see which files loaded at runtime

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

10 participants