mswjs / msw-storybook-addon

Mock API requests in Storybook with Mock Service Worker.
https://msw-sb.vercel.app
MIT License
408 stars 39 forks source link

Story-specific handler / request persisting after switching stories #82

Open mmirus opened 2 years ago

mmirus commented 2 years ago

Hi there! Thanks for the add-on!

I'm running into a problem with a story that demos a loading state:

  1. The Loading story applies a handler with ctx.delay('infinite'). This story works great!
    • Specifically, this is a graphql handler for a request from Apollo Client.
  2. But when I load that story and then switch to another story in the same file, that second story also appears as loading forever. The pending request from the first story remains in the network tab, and no new request appears from the second story.

Any help debugging / solving this would be appreciated! Please let me know if additional info would be helpful.

It's a bit hard to debug this / pinpoint what part of the stack may be responsible for not ending the pending request and initiating a new one when switching from the Loading story to the next one. Apologies if I'm barking up the wrong tree.

In the meanwhile, since refreshing the browser tab on the second (non-loading) story allows that story to load correctly, I've worked around this by using a decorator that reloads the iframe when leaving the Loading story. This is the only workaround I've found that helps.

// Yuck...
Loading.decorators = [
  (Story) => {
    useEffect(() => {
      return () => window.location.reload();
    }, []);

    return <Story />;
  },
];
tomgardiner-retailx commented 2 years ago

I'm having this exact same issue - and it was related to when I started specifically passing in multiple mocks to the msw handler. I still don't know what's causing the issue, but thanks for your workaround.

Example of what caused it:

// Works
Primary.parameters = {
  msw: {
    handlers: [someMock],
  },
};
Secondary.parameters = {
  msw: {
    handlers: [someMock],
  },
}

//doesn't work
Primary.parameters = {
  msw: {
    handlers: [someMock, someMock2],
  },
};
Secondary.parameters = {
  msw: {
    handlers: [someMock, someMock2],
  },
}
aaronmcadam commented 2 years ago

This seems to be an issue with msw 0.4x, as we don't see the problem with 0.3x.

@yannbf I've recreated this problem in a branch of the mealdrop app here: https://github.com/yannbf/mealdrop/compare/main...aaronmcadam:mealdrop:am-persisting-mocks

Any ideas?

aaronmcadam commented 2 years ago

Here's a screencap of the issue: CleanShot 2022-07-21 at 16 03 41

melissawahnish commented 2 years ago

I also experienced this when mocking different graphql query responses within the same storybook file. @mmirus's solution does work. Please let us know if this issue is addressed. Thanks!

aaronmcadam commented 2 years ago

I think if we had some way of resetting handlers like we can in unit tests (with server.resetHandlers()), that might help us make sure the story uses the correct handler.

philjones88 commented 2 years ago

Also experiencing this with rest handlers. Can't seem to override global handlers I defined in my preview file either.

fazulk commented 2 years ago

Noticing this aswell using 1.6.3. Thankfully doesn't affect chromatic tests. For now i need to refresh the page to get the correct handlers to load for that story. Not the solution of course.

philjones88 commented 2 years ago

I kinda work around this by setting RTK's refetchOnMountOrArgChange to true. That seems to workaround the issue and swapped to named handlers not arrays.

mattduggan commented 2 years ago

I've run into this issue and noticing that the way initialize works introduces a memory leak in the test environment if you reuse your stories there.

MSW documentation covers reseting handlers between tests and closing the server after each test file has completed. Storybook documentation also covers how to reuse stories in test.

By then introducing msw-storybook-handler:

  1. A server is created before each test file is run and never closed, unable to be garbage collected.
  2. The server(s) handlers aren't reset, so a previous server may still be intercepting the requests causing unexpected behavior.
  3. We can not reuse an existing worker/server if we've already set one up.

For all cases, I think resetHandlers should be called between stories.

For reusing a server, perhaps initialize could accept it as another option to set as the internal "api". For test case, esp. where you're not reusing an existing server, documenting how to close it in test.

Ex:

// .storybook/preview.js
import {server} from '../mock/server';
export const api = initialize({api: server});

// setupFileAfterEnv.js
import {api} from './.storybook/preview';

beforeAll(() => api.listen());
afterEach(() => api.resetHandlers());
afterAll(() => api.close());

It's a little clunky, though. Would appreciate other's thoughts. Currently, I've resorted to checking the test environment in .storybook/preview.js and rewriting the mswDecorator to ensure things are cleaned up.

aaronmcadam commented 2 years ago

Currently, I've resorted to checking the test environment in .storybook/preview.js and rewriting the mswDecorator to ensure things are cleaned up.

How are you doing that?

mattduggan commented 2 years ago

Copy/pasting the internals of mswDecorator, replacing the api reference with our server.

import {server} from '../src/mock/server';

const isTest = process.env.NODE_ENV === 'test';

if (!isTest) {
  initialize()
}

export const decorators = [
  isTest
    ? (storyFn, context) => {
        const {
          parameters: {msw},
        } = context;
        if (msw) {
          if (Array.isArray(msw) && msw.length > 0) {
            // Support an Array of request handlers (backwards compatability).
            server.use(...msw);
          } else if ('handlers' in msw && msw.handlers) {
            // Support an Array named request handlers
            // or an Object of named request handlers with named arrays of handlers
            const handlers = Object.values(msw.handlers)
              .filter(Boolean)
              .reduce(
                (handlers, handlersList) => handlers.concat(handlersList),
                []
              );

            if (handlers.length > 0) {
              server.use(...handlers);
            }
          }
        }
        return storyFn();
      }
    : mswDecorator
];
mattduggan commented 2 years ago

Here's something a little better I have working:

export const mswDecoratorFactory = (api) => {
  const onUnmount = () => {
    api.close?.() || api.stop();
    api.resetHandlers();
  };

  return (Story, context) => {
    const initialRender = useRef(true);

    const {
      parameters: {msw},
    } = context;

    if (msw && initialRender.current) {
      let handlers = [];

      if (Array.isArray(msw)) {
        handlers = msw;
      } else if ('handlers' in msw && msw.handlers) {
        handlers = Object.values(msw.handlers)
          .filter(Boolean)
          .reduce(
            (handlers, handlersList) => handlers.concat(handlersList),
            []
          );
      }

      if (handlers.length > 0) {
        api.listen?.() || api.start();
        api.use(...handlers);
      }
    }

    useEffect(() => {
      initialRender.current = false;
      return onUnmount;
    }, []);

    return <Story />;
  };
};

Usage:

export const decorators = [
  mswDecoratorFactory(!!process?.versions?.node ? require('path/to/server').server : require('path/to/browser').worker)
];

In my case, I'm also using @mswjs/data for mocks, which can be used independently of msw itself. In my decorator, I've added a drop(db) to onUnmount to ensure it's clean between Stories. YMMV.

coofzilla commented 2 years ago

I'm having the same issue as this. I tried to import all my handlers into preview.js and then only add the handler required for each story, but that didn't work either. Like the others above, my current solution has just been to refresh.

export const marketHandler = rest.get(
  `some/coin/route`,
  (req, res, ctx) => {
    return res(ctx.json(mockMarketPrices));
  }
);

export const loadingHandler = rest.get(
  `some/coin/route`,
  (req, res, ctx) => {
    return res(ctx.delay('infinite'));
  }
);

export const allNamedHandlers = {
  marketHandler,
  loadingHandler
}
// preview.js
export const parameters = {
  msw: {
    handlers: allNamedHandlers,
  }
}
export const MockedSuccess = Template.bind({});
MockedSuccess.parameters = {
  msw: {
    handlers: { marketHandler },
  },
};

export const MockedLoading = Template.bind({});
MockedLoading.parameters = {
  msw: {
    handlers: { loadingHandler },
  },
};
JohnValJohn commented 2 years ago

I thought I had the same issue, but it turned out that my problem was actually this one : https://github.com/mswjs/msw/issues/251#issuecomment-650200531 (I am using Apollo and the requests were cached and never re-executed)

mmirus commented 2 years ago

@JohnValJohn Did you find a way to clear the cache when switching stories?

JohnValJohn commented 2 years ago

@JohnValJohn Did you find a way to clear the cache when switching stories?

edit: I just found out that the cache object has a reset method. So I ended up importing the cache object used by Apollo and just calling the reset method.

Creation of apollo client:

export const cache = new InMemoryCache();

export const client =  new ApolloClient({cache});

And then in a Decorator:

cache.reset()
mrskiro commented 1 year ago

I had the same issue

chad-levesque commented 1 year ago

Also suffering from this. Added the window.location.reload() decorator as suggested which solves the problem but makes the UX of clicking around the Storybook feel glitchy and slow.

JeffreyStevens commented 1 year ago

I've been watching this issue since I also implemented the workaround of reload(). It would be nice to have MSW clean up on story switches.

isimmons commented 1 year ago

This really causes issues when you have autodocs enabled. The reload hack makes the Default and Loading stories work correctly but then where they load together in the docs page everything is showing the loading skeleton. If I move my non loading handler to the meta section then all stories including the loading story are using the non-loading handler. parameters in my stories don't seem to override parameters in the meta section.

isimmons commented 1 year ago

Is this an issue with the addon, msw, or storybook?

By the way, this is storybook 7 / vite/ react 18 I'm using.

I replaced the addon by removing it and directly calling setupWorker in my stories file and get the same results.

Also I added a couple of fake handlers with console.log and added console.log to my handlers and one at the top of the actual component. Here is what I have and the result.

//...imports

const defaultWorker = setupWorker(
  rest.get(BASE_URL, (_req, res, ctx) => {
    console.log("from the default worker");
    return res(ctx.json(restaurants));
  })
);

const loadingWorker = setupWorker(
  rest.get(BASE_URL, (_req, res, ctx) => {
    console.log("from the loading worker");
    return res(ctx.delay("infinite"));
  })
);

const foo = () => {
  console.log("foo");
};

const bar = () => {
  console.log("bar");
};

const getRestaurants = () => {
  defaultWorker.start();
};

const getNoResponse = () => {
  loadingWorker.start();
};

const meta: Meta<typeof RestaurantsSection> = {
  title: "Pages/HomePage/Components/RestaurantsSection",
  component: RestaurantsSection,
  parameters: {
    design: {
      type: "figma",
      url: "https://www.figma.com/file/3Q1HTCalD0lJnNvcMoEw1x/Mealdrop?type=design&node-id=135-311&t=QGU1YHR0aYc88VDn-4",
    },
  },
  args: {
    title: "Our Favorite Picks",
  },
};

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
  parameters: {
    handlers: [getRestaurants(), foo()],
  },
};

export const Loading: Story = {
  parameters: {
    handlers: [getNoResponse(), bar()],
  },
};

On initial page load of the docs section I don't see the [MSW] Mocking enabled. that I expect to see twice because docs loads both stories but if I go to docs and then each story and refresh the page I get the following results in devtools console.

docs result

foo
bar
[MSW] Mocking enabled.
[MSW] Mocking enabled.
from the component
from the component
from the component
from the default worker
from the loading worker
from the default worker
from the loading worker
from the component
from the default worker
from the loading worker
from the component

default story result

foo
bar
from the component
from the component
[MSW] Mocking enabled.
[MSW] Mocking enabled.
from the default worker
from the loading worker

loading story result

foo
bar
from the component
from the component
[MSW] Mocking enabled.
[MSW] Mocking enabled.
from the default worker
from the loading worker

I don't understand why both stories call both handlers. I would expect each one to overwrite the handler instead of calling both. The foo and bar handlers show it's a storybook issue don't they?

isimmons commented 1 year ago

Since I'm just learning on the meal drop project, I could afford to scrap things and start over. After creating a new vite app with latest storybook 7.0.20 and the same version msw-storybook-addon and now the two stories with different handlers works with the exception of autodocs. So I can only guess something updated in storybook fixed the problem for me.

No page refresh needed

export const Default: Story = {
  name: "Default Story",
  parameters: {
    msw: {
      handlers: [
        rest.get(BASE_URL, (_req, res, ctx) => res(ctx.json(restaurants))),
      ],
    },
  },
};

export const Loading: Story = {
  name: "Loading Story",
  parameters: {
    msw: {
      handlers: [
        rest.get(BASE_URL, (_req, res, ctx) => res(ctx.delay("infinite"))),
      ],
    },
  },
};

The autodocs issue is that it chooses to display by the last handler by default I guess? So it shows the loading story for the components in the docs page.

yannbf commented 1 year ago

Hey everyone! Thanks for all the discussions, and @aaronmcadam thanks for the reproduction. That one reproduction specifically seems to be a bug in MSW version 0.42.3 which crashes after setting the first mock with the following error:

[MSW] Uncaught exception in the request handler for "GET https://mealdrop.netlify.app/.netlify/functions/restaurants?id=1":

TypeError: response2.headers.all is not a function
    at Object.transformResponse (http://localhost:6006/node_modules/.cache/sb-vite/deps/chunk-XODS3P5D.js?v=f2eee344:23901:36)
    at handleRequest (http://localhost:6006/node_modules/.cache/sb-vite/deps/chunk-XODS3P5D.js?v=f2eee344:23840:144)
    at async http://localhost:6006/node_modules/.cache/sb-vite/deps/chunk-XODS3P5D.js?v=f2eee344:23851:11

That's why the mocks seem to leak, but it turns out that MSW is then broken and can't reset/set up mocks after.

I tried with later versions of MSW, including the most recent one 1.3.2, and things seem to be working correctly.

To everyone here, can you first try a few things for me:

  1. Upgrade MSW in your project. See if that fixes the issue.
  2. Use the mswLoader instead of mswDecorator. Using MSW in a decorator works for most scenarios, but there's a slight chance the service worker will not get registered in time. Timing issues could potentially cause leaking. A potential solution to that is to use Storybook loaders. They get executed before a story renders, differently than decorators, which execute as the story renders. You can find instructions here, and make sure to remove mswDecorator if you are using mswLoader. This unfortunately affects users who use composeStories, because loaders are not handled in composeStories, contrary to decorators.
  3. If none of the above works, please set up a minimal reproduction, or use MealDrop, which is a project known to a few already, that already has MSW working. If you can get a reproduction there, it will help immensely as well, but any shareable reproduction is greatly appreciated!

Thank you so much, and sorry this has been affecting you. Whenever I tried to reproduce this issue reliably, I couldn't. If there is a proper reproduction, it will go a long way to fixing this for everyone!

MehediH commented 9 months ago

@yannbf Hey, just adding to this bug - I think one of the cases where the issue most definitely occurs is when you use await delay('infinite') from the msw package. If you have a story with a handler that uses delay('infinite') and then switch to a story without any delay in the handler, the new handler doesn't resolve.

scottyschup commented 6 months ago

I'm experiencing this issue with msw v2.2.13, so "upgrade MSW" isn't an option. Also already using mswLoader.

MehediH commented 4 months ago

@yannbf any updates here? still experiencing the same issue in the latest version of storybook + addon

johnshift commented 3 months ago

if you're using react-query you can do something like this:

const Providers = ({ children }: { children: React.ReactNode }) => (
    <ReactQueryProvider queryClient={queryClient}>
      {children}
    </ReactQueryProvider>
);

const ResetQueries = () => {
  const queryClient = useQueryClient();

  useEffect(() => {
    return () => {
      queryClient.resetQueries();
    };
  }, []);

  return null;
};

const decorators: Preview['decorators'] = [
  (Story) => (
    <Providers>
      <Story />
      <ResetQueries />
    </Providers>
  ),
];
vhfmag commented 2 months ago

I was having this issue and just got it fixed — in my case it was a misconfiguration on my part. I was doing something like this:

export const loaders = [
  async (ctx) => {
    initialize();
    return mswLoader(ctx);
  },
];

After quite a bit of debugging I found out that because loaders run once per story, that was reinitiliazing the MSW worker without cleaning up the previous one, which caused both the old and the new requests handlers to run, and the older one to "win". The fix was to do just like the docs recommend:

initialize();
export const loaders = [mswLoader];
JonasMazza commented 2 weeks ago

I thought I had the same issue, but it turned out that my problem was actually this one : mswjs/msw#251 (comment) (I am using Apollo and the requests were cached and never re-executed)

Same for me, but I am using SWR. To solve the issue I used the following decorator:

import React from 'react';
import { SWRConfig, mutate } from 'swr';

export const invalidateSWRCache = (Story, { parameters }) => {
  if (parameters.invalidateSWRCache) {
    mutate(() => true, undefined, { revalidate: false });

    return (
      <SWRConfig
        value={{
          dedupingInterval: 0,
          revalidateOnFocus: false,
          revalidateOnMount: true,
          revalidateIfStale: false,
        }}
      >
        <Story />
      </SWRConfig>
    );
  }

  return <Story />;
};

then in your story add the invalidateSWRCache: true parameter. It will make SWR fetch fresh results every time.

JonasMazza commented 2 weeks ago

I also faced the delay('infinite') issue, when opening the story for the first time, the request is fired normally, but after you switch to another story and back, the request is not fired again. This was important for testing my component.

To solve this, in association with the solution above, and also with using different ids for the different requests (to create different URLs), I implemented this arguably hacky solution:

import type { Meta, StoryObj } from '@storybook/react';
import { http, HttpResponse } from 'msw';
import { useEffect } from 'react';

import { MyComponent } from './MyComponent';

const InfinitePromise = (returnVal: HttpResponse) => {
  let resolve = () => {};
  const promise = new Promise<HttpResponse>(res => {
    resolve = () => {
      res(returnVal);
    };
  });

  return {
    promise,
    resolve,
  };
};

let infinitePromise = InfinitePromise(HttpResponse.json({}));

const meta: Meta<typeof MyComponent> = {
  parameters: {
    invalidateSWRCache: true,
  },
  component: MyComponent,
};

export default meta;
type Story = StoryObj<typeof meta>;

export const RendersTheComponent: Story = {
  args: {
    thingyId: 'some_id',
  },
  parameters: {
    msw: {
      handlers: {
        getThingy: http.get(`https://example.com/some_id`, () => {
          return HttpResponse.json({ thingy_id: 'some_id' });
        }),
      },
    },
  },
};

const ResolveOnUnmount = Story => {
  useEffect(() => {
    return () => {
      infinitePromise.resolve();
    };
  }, []);

  return <Story />;
};

export const ShowsLoadingState: Story = {
  args: {
    thingyId: 'some_other_id',
  },
  parameters: {
    msw: {
      handlers: {
        getThingy: http.get(`https://example.com/some_other_id`, async () => {
          infinitePromise = InfinitePromise(
            HttpResponse.json({ thingy_id: 'some_other_id' })
          );
          return await infinitePromise.promise;
        }),
      },
    },
  },
  decorators: [ResolveOnUnmount],
};

It won't work if the story you are switching to fires the exact same request to the exact same URL. I assume it's because SWR won't issue another request to the same URL if the previous one is still ongoing, which is of course what you'd expect in this situation.