mswjs / msw

Seamless REST/GraphQL API mocking library for browser and Node.js.
https://mswjs.io
MIT License
15.65k stars 503 forks source link

MSW does not mock APIS in react-router-6 loader in the first load #1653

Open abhaykumar01234 opened 1 year ago

abhaykumar01234 commented 1 year ago

Prerequisites

Environment check

Browsers

Chromium (Chrome, Brave, etc.)

Reproduction repository

https://github.com/abhaykumar01234/hacker-news

Reproduction steps

npm run dev:mock

Current behavior

I am running a vite application, using react-router-dom:v6 and msw:latest.

I have 2 pages

import { createBrowserRouter, RouterProvider } from "react-router-dom";

const router = createBrowserRouter([
  {
    path: "/",
    element: <App />,
    loader: appLoader,
  },
  {
    path: "/about",
    element: <About />,
    loader: aboutLoader,
  },
]);

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);

if (import.meta.env.VITE_MSW_MOCKED === "1") {
  const { worker } = await import("~/mocks/browser");
  await worker.start({ onUnhandledRequest: "bypass" });
}

root.render(<RouterProvider router={router} />);

Each page having code

import { Link, json, useLoaderData } from "react-router-dom";

export const loader = async () => {
  try {
    const res = await fetch(`${import.meta.env.VITE_BASE_URL}/global`);
    return json(await res.json());
  } catch (err) {
    console.error(err);
    return null;
  }
};

export default function Layout() {
  const data = useLoaderData();

  console.log("home", data);
  return (
    <div>
      <h1>Home</h1>
      <Link to="/about">About</Link>
    </div>
  );
}

and one link to the other page.

When the page loads for the first time, Mocks are enabled but the API endpoint fails. When the links are clicked to navigate back and forth the pages, the mock works the next time

image image

Expected behavior

Mocks should work the first time for loader APIs

abhaykumar01234 commented 1 year ago

Same API call gets mocked, if i write it inside useEffect hook in page. Looks like the loader API calls are invoked before the browser.ts file initializes the msw handlers (refer to the network tab screenshot)

kettanaito commented 1 year ago

Hi, @abhaykumar01234. Thanks for reporting this.

Can you double-check that the app's render root.render() appears after the worker is ready (you await the worker.start promise)? I have a suspicion that's not the case.

Also, I believe this framework you're using is the one Remix is using under the hood, is that correct? I don't have much experience with react-router directly and the loader pattern works fine in the latest Remix with MSW. I suspect, perhaps, that like Svelte, react-router may flush the loaders in a build phase and execute them apart from your application's execution. It would be nice to confirm/deny this.

abhaykumar01234 commented 1 year ago

@kettanaito I have tried to write root.render() inside a worker.start().then(() => {..........));, Also used setTimeout() for root.render(), but it doesn't work here. Although if I introduce a setTimout() inside loader, it works for larger timeout values. Yes, this way of using loaders is similar to the Remix framework.

If the react-router invokes the loaders before the mounting of page starts and there is no Mock Service worker available in browser, I tried the Server version of MSW as well i.e. server.listen() which we use in tests. But that was also throwing errors.

wangel13 commented 1 year ago

This works for me:

// Setup MSW
async function prepare() {
  if (import.meta.env.VITE_MSW === 'enabled') {
    const { worker } = await import('./mocks/browser')
    worker.start()
  }

  return Promise.resolve()
}

prepare().then(() => {
  ReactDOM.createRoot(document.getElementById('root')!).render(
    <React.StrictMode>
      <Providers>
        <App />
      </Providers>
    </React.StrictMode>
  )
})
abhaykumar01234 commented 1 year ago

@wangel13 Doesn't work for me

import { createBrowserRouter, RouterProvider } from "react-router-dom";

const router = createBrowserRouter([
  {
    path: "/",
    element: <App />,
    loader: appLoader,
    children: [
      {
        path: "/",
        element: <Home />,
        loader: homeLoader,
      },
      {
        path: "/about",
        element: <About />,
        loader: aboutLoader,
      },
    ],
  },
]);

// Setup MSW
async function prepare() {
  if (import.meta.env.VITE_MSW_MOCKED === "1") {
    const { worker } = await import("./mocks/browser");
    await worker.start({ onUnhandledRequest: "bypass" });
  }

  return Promise.resolve();
}

prepare().then(() => {
  const root = ReactDOM.createRoot(
    document.getElementById("root") as HTMLElement
  );
  root.render(<RouterProvider router={router} />);
});
image

For some reason, react-router-6 loaders are invoked before the handlers are mocked in msw.

Did you try it with loaders in your react page making API calls?

wangel13 commented 1 year ago

Did you try it with loaders in your react page making API calls?

I tried with react-query, and it's working in my setup.

abhaykumar01234 commented 1 year ago

React query hits the API after the mounting of the page and uses useEffect underneath. Correct me if I am wrong. Also, I am looking for a solution to work with plain react-router-dom. loader calls. I know I may sound vanilla here, not using any packages, but shouldn't it work like that? @wangel13

abhaykumar01234 commented 1 year ago

Any updates??

kmiwa007 commented 1 year ago

@abhaykumar01234 sounds like same scenario as https://twitter.com/rossipedia/status/1611814575401500672?t=FT5BkbsMiff2r3-2mXPRxw&s=19. Looks like you have to find a way to call createBrowserRouter() after prepare() resolves.

wangel13 commented 1 year ago

What if we wrap createBrowserRouter in a function:

export const makeRouter = () => createBrowserRouter([])
<RouterProvider router={makeRouter()} />
marcomuser commented 1 year ago

@wangel13 Doesn't work for me

import { createBrowserRouter, RouterProvider } from "react-router-dom";

const router = createBrowserRouter([
  {
    path: "/",
    element: <App />,
    loader: appLoader,
    children: [
      {
        path: "/",
        element: <Home />,
        loader: homeLoader,
      },
      {
        path: "/about",
        element: <About />,
        loader: aboutLoader,
      },
    ],
  },
]);

// Setup MSW
async function prepare() {
  if (import.meta.env.VITE_MSW_MOCKED === "1") {
    const { worker } = await import("./mocks/browser");
    await worker.start({ onUnhandledRequest: "bypass" });
  }

  return Promise.resolve();
}

prepare().then(() => {
  const root = ReactDOM.createRoot(
    document.getElementById("root") as HTMLElement
  );
  root.render(<RouterProvider router={router} />);
});
image

For some reason, react-router-6 loaders are invoked before the handlers are mocked in msw.

Did you try it with loaders in your react page making API calls?

createBrowserRouter itself invokes the loaders, which means it is called before your prepare fn is awaited. Keep in mind that the main purpose of loaders in React-Router/Remix is to decouple data loading from component rendering. Thus, you cannot rely on the component render tree for your setup order. If you want to do something before the loaders are triggered, you need to make sure that createBrowserRouter is executed after the setup you want to run first.

ciceropablo commented 1 year ago

Any news?

adam-boduch commented 1 year ago

I'm having the same issue. MSW is not working when if make a call in react router loader. It works if I wrap my code in useEffect hook inside the component.

FredericoGauz commented 1 year ago

This works for me:

// Setup MSW
async function prepare() {
  if (import.meta.env.VITE_MSW === 'enabled') {
    const { worker } = await import('./mocks/browser')
    worker.start()
  }

  return Promise.resolve()
}

prepare().then(() => {
  ReactDOM.createRoot(document.getElementById('root')!).render(
    <React.StrictMode>
      <Providers>
        <App />
      </Providers>
    </React.StrictMode>
  )
})

I managed to have it working with a similar set up, I also created a simpler wrapper over the router to have something for the development team to see... (I use mobx for state management)

(Sorry the code block is messed up) ` const startRestApiMocks = async () => { if (!applicationState.isDevelopment()) return; const { worker } = await import('./app/mocks/browser.rest.api.mocks');

worker.start();

};

startRestApiMocks().then(() => { applicationState.setMockApiLoaded(true); });

const Loader = observer(() => { return ( <> {applicationState.mockApiLoaded ? (

        ) : (
            <div className="w-screen h-screen grid text-2xl items-center justify-center">
                <div>
                    <Spin size="large" />
                    <h1>Dev's life is hard! </h1>
                    <p>Loading mock up apis...</p>
                </div>
            </div>
        )}
    </>
);

}); ReactDOM.render(

, document.getElementById('root'), ); `
manavm1990 commented 11 months ago

If you're using Remix, there is a section of the docs📝 showing how to integrate msw..

For 'vanilla' React Router @abhaykumar01234 b/c of the decoupling that React Router does between mounting/rendering and the data loading, it pretty much won't work. I too am facing the same issue. The loaders will 🔥 up and start fetching before MSW can kick in. :(

The browser console will show the 'real fetch' and then after that will come: [MSW] Mocking enabled.

I did get a partial, yet insufficient solution as follows:

In my loader, I import an apiService: import apiService from "../api.service";. Within that, if I put something like:

import worker from "./tests/mocks/worker";

if (process.env.NODE_ENV === "development") {
  worker.start();
}

it works. In my browser, I see: [MSW] Mocking enabled. and it renders the mock 🤡 data.

To be clear, you don't need to use any separate apiService, and your imports might look different. I prefer export default, so mine is: import worker from "./tests/mocks/worker"; (no {s).

But, if you do something like this directly wherever your loaders are, it should start the mocking 🤡 .

insufficient solution

For me, this was still insufficient b/c I have no way of running npm t for my tests ✅ . I get: Invariant Violation: [MSW] Failed to executesetupWorkerin a non-browser environment. Consider usingsetupServerfor Node.js environment instead.

That's because despite setting up my tests to use aforementioned server, when it starts loading it's getting 😕 by the 'browser worker' in the loader/API service.

I then tried something like this, which is really sloppy, but since the intercept setup has to happen in the API service (or the loader)...

if (process.env.NODE_ENV === "development") {
  const workerModule = await import("./tests/mocks/worker");
  workerModule.default.start();
} else if (process.env.NODE_ENV === "test") {
  beforeAll(() => server.listen());

  afterEach(() => {
    server.resetHandlers();
  });

  // clean up once the tests are done
  afterAll(() => server.close());
}

It kind of seemed to work intermittently at best...

I could probably restructure the app to do some more decoupling, but not going to bother. Going to stick with Cypress intercept, which pretty much solves the same problem 🤞🏾 and makes it a bit easier than React Testing Library and msw for this case, at least.

marcomuser commented 11 months ago

As I stated above you can use msw with react-router loaders. The only important thing is that the worker is started before createBrowserRouter is called. For this, you can for example simply memoize createBrowserRouter with useMemo and call it within your App component. This will make sure that worker.start() is called before the loaders are initiated. This will also let it work in typical testing setups (with vitest for example).

stefan-huck commented 10 months ago

As @marcomuser stated. Here is the solution

function App() {
  const router = useMemo(() => {
    return createBrowserRouter([
      {
        path: "/",
        // ...
      },
    ])
  }, [])
  return <RouterProvider router={router}></RouterProvider>
}
aaronschwartz commented 10 months ago

For those that are stuck this is what helped me understand it better.

Does NOT work because createBrowserRouter is called before MSW is initialized

import React from 'react'
import ReactDOM from 'react-dom/client'
import {RouterProvider, createBrowserRouter} from "react-router-dom";

const router = createBrowserRouter([...]);

async function deferRender() {
    if (process.env.NODE_ENV !== 'development') {
        return
    }

    const {worker} = await import('./mocks/browser')

    // `worker.start()` returns a Promise that resolves
    // once the Service Worker is up and ready to intercept requests.
    return worker.start()
}

deferRender().then(() => {
    ReactDOM.createRoot(document.getElementById('root')!).render(
        <React.StrictMode>
            <RouterProvider router={router}/>
        </React.StrictMode>,
    )
})

DOES work because createBrowserRouter is called after MSW is initialized

import React from 'react'
import ReactDOM from 'react-dom/client'
import {RouterProvider, createBrowserRouter} from "react-router-dom";

const createRouter = () => createBrowserRouter([...]);

async function deferRender() {
    if (process.env.NODE_ENV !== 'development') {
        return
    }

    const {worker} = await import('./mocks/browser')

    // `worker.start()` returns a Promise that resolves
    // once the Service Worker is up and ready to intercept requests.
    return worker.start()
}

deferRender().then(() => {
    ReactDOM.createRoot(document.getElementById('root')!).render(
        <React.StrictMode>
            <RouterProvider router={createRouter()}/>
        </React.StrictMode>,
    )
})
marcomuser commented 10 months ago

If you can use ES2022 (with top-level await) you can simply do the following:

import { createRoot } from 'react-dom/client';
import { StrictMode } from 'react';
import { RouterProvider, createBrowserRouter } from "react-router-dom";

if (process.env.NODE_ENV === 'development') {
  const { worker } = await import('./app/testing/mocks/browser.js');
  await worker.start();
}

const router = createBrowserRouter([...]);

const root = createRoot(document.getElementById('root'));

root.render(
  <StrictMode>
    <RouterProvider router={router}/>
  </StrictMode>
);

This would be the easiest setup. If you, however, do not include the msw- and the react-router related code in the same module (file), you need to be a bit more careful due to the nature of how ESM imports work. Let's assume you have the following two files:

  1. main.js
    
    import { createRoot } from 'react-dom/client';
    import { StrictMode } from 'react';

import { App } from './app/App.jsx';

if (process.env.NODE_ENV === 'development') { const { worker } = await import('./app/testing/mocks/browser.js'); await worker.start(); }

const root = createRoot(document.getElementById('root'));

root.render(

);

2. App.jsx
```JSX
import { RouterProvider, createBrowserRouter } from "react-router-dom";

const router = createBrowserRouter([...]);

export function App() {
  return(
    <RouterProvider router={router}/>
  );
}

Now we need to know one thing about the execution order of this code. The ESM imports are evaluated first! This means the order of execution is the following (roughly speaking):

  1. Evaluate imports of main.js
  2. Find App.js import and load it
  3. Execute the App.js code that is not within functions. createBrowserRouter is invoked which fires off the react-router loaders
  4. main.js continues to be executed. This starts the worker and then renders the react root
  5. Now we are at the point where the App component is rendered

This is why you need to call createBrowserRouter from within the App component (make sure to memoize it!) if you want to follow this file structure. For this you can do it for example as @lucider5 has suggested above: https://github.com/mswjs/msw/issues/1653#issuecomment-1776867147. Hope this helps! I think this could be better documented in the react-router documentation but I don't see any issues on the msw side. I think we can close this issue.

ionMoraru commented 9 months ago

In my case exporting the worker.start() from another file worked for me: image image

wataruoguchi commented 9 months ago

Dynamic-loading of the module that depends on react-router-dom solved the case.

Here's my src/main.ts

import React from "react";
import ReactDOM from "react-dom/client";

enableMocking().then(async () => {
  const { Router } = await import("./router.tsx");
  ReactDOM.createRoot(document.getElementById("root")!).render(
    <React.StrictMode>
      <Router />
    </React.StrictMode>,
  );
});

async function enableMocking() {
  if (import.meta.env.MODE !== "development") return;
  const { worker } = await import("./mocks/browser");
  return worker.start();
}
xJkit commented 8 months ago

Dynamic import is not necessary. Try this way, and it works.

Just make sure that you create the browser router after resolving the worker.start().

That is, put the createBrowserRouter after the async function enableMocking() like so:

async function enableMocking() {
  if (import.meta.env.VITE_MOCK !== 'TRUE') {
    return;
  }

  const { worker } = await import('./mocks/browser');

  // `worker.start()` returns a Promise that resolves
  // once the Service Worker is up and ready to intercept requests.
  return worker.start();
}

enableMocking().then(() => {
  const router = createBrowserRouter([
    {
      path: '/',
      element: <App />,
      loader: async () => {
        const data = await fetch('/api/test', {
          headers: {
            'Content-Type': 'application/json',
          },
        });
        return data;
      },
      errorElement: <Error />,
    },
  ]);

  ReactDOM.createRoot(document.getElementById('root')).render(
    <React.StrictMode>
        <RouterProvider router={router} />
    </React.StrictMode>
  );
});
RodrigoSosa95 commented 7 months ago

In my case exporting the worker.start() from another file worked for me: image image

This worked perfectly, also it needs to be called as soon as possible and before createBrowserRouter

jacksonwen001 commented 4 months ago

Any updates?

SuroZaqaryan commented 3 months ago

routes.js

async function enableMocking() {
  const { worker } = await import('../server/browser');
  return worker.start();
}

await enableMocking();

const router = createBrowserRouter([]);

export default router;

App.js

import React from 'react';
import { RouterProvider } from 'react-router-dom';
import router from 'routes/routes';

function App() {
  return <RouterProvider router={router} />
}

export default App;
dargue3 commented 1 month ago

I've been having these same issues where initial load of an app with react-router deferred loaders was causing the mocked calls to go unhandled. My index.tsx looks the same as what people have here:

if (isMocked()) {
  const { startWorker } = await import('./mocks/browser.js');
  await startWorker();
}

const app = ReactDOM.createRoot(document.getElementById('uhc')!);

app.render(
  <AppProviders>
    <RouterProvider router={createRouter()} />
  </AppProviders>,
);

The trick that just fixed it for me, which I cannot explain, is to ensure you have a leading slash in the path name.

export const mockGetRoute = http.get('/api/myRoute', () => {
  return HttpResponse.json({});
});

For whatever reason, without the leading slash, the routes would work fine after the initial load, but during initial defer() load these would not be called properly. What's odd is that there were some of my Routes that worked perfectly fine with no leading slash, and it seemed like once I was loading ~3 loaders at once for any given route, I was running into this issue where MSW wasn't matching the API calls anymore and just letting them 404.