scottrippey / next-router-mock

Mock implementation of the Next.js Router

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Cannot use default (singleton) router: "You should only use "next/router" on the client side of your app"

sedlukha opened this issue · comments

Hello! First of all thanks for awesome package, @scottrippey!

I try to mock next-router with storybook. Is it real to mock default Router as we do it for useRouter?

For instance useRouter works fine:

useRouter example
import { useCallback } from 'react'
import { useRouter } from 'next/router'

const UseRouterPage = () => {
  const router = useRouter()

  const handleClick = useCallback(() => router.push({
      pathname: router?.pathname,
      query: {
        ...router?.query,
        test: '1'
      },
    }, undefined,
    {
      scroll: false,
      shallow: true
    }), [])

  return (
    <main>
      <button onClick={handleClick}>button</button>
    </main>
  )
}

export default UseRouterPage

But with default exported Router, the next error appears:

image

I try use default exported Router because in this case there is no component re-render.

useRouter example
import { useCallback } from 'react'
import Router from 'next/router'

const RouterPage = () => {
  const handleClick = useCallback(() => Router.push({
    pathname: Router?.pathname,
    query: {
      ...Router?.query,
      test: '1'
    },
  }, undefined,
  {
    scroll: false,
    shallow: true
  }), [])

  return (
    <main>
      <button onClick={handleClick}>button</button>
    </main>
  )
}

export default RouterPage

Repo to reproduce: https://github.com/sedlukha/next-router-mock-example

Unfortunately, Storybook does not have a per-story way to mock imported modules. I wish it did!

But, there still might be a solution. I haven't tried this, but you should be able to add an alias to Storybook's webpack config.
This will globally override the router for all your stories:

.storybook/main.js

module.exports = {
  webpackFinal: async (config, { configType }) => {
    config.resolve.alias = {
      ...config.resolve.alias,
      "next/router": "next-router-mock",
    };
    return config;
  },
};

If you try this, please let me know if it works! I'd be happy to update the documentation.

Caveat: I'll need to think about how this plays with next/link, since it uses a different import path -- you might need to use both approaches in that scenario.

@scottrippey thanks for the answer!

Your idea sounds great, but there is something wrongs with initial value in this case.
I pass url to MemoryRouterProvider, but here is what I get:
image

I've created new branch with changes
https://github.com/sedlukha/next-router-mock-example/tree/alias

Yeah, here's why: the MemoryRouterProvider will only work with the useRouter() hook. It doesn't use the singleton router, it creates its own. So you can't use it to mock the URL.

Can you explain a little more about your use-case? Since you're using the singleton router in your code, can I assume you're only using it for push or replace functionality? Or, are you actually reading the URL -- eg. pathname or asPath or query?
And, from a Storybook point-of-view, what kind of functionality would you like from next-router-mock? Do you want to be able to use the <MemoryRouterProvider onPush={...} onReplace={...}> events? Or do you want to actually mock the given url?

I'm asking, because I do have an idea how to integrate the singleton router with the MemoryRouterProvider, but I'd like to make sure I understand the scenario better.

My idea is this: I could add a singleton property to the MemoryRouterProvider, which would tell it to use the singleton router instead of creating its own. When the component gets mounted, it would set the singleton's URL.

This would allow all the components to play nicely together. I might even consider making this the default behavior, and perhaps creating an option for "isolated routers" to get the current behavior back.

@scottrippey
I use a singleton router for both: read pathname/query and push/replace.

From a Storybook point-of-view, I don't use <MemoryRouterProvider onPush={...} onReplace={...}> events.
I mock the given URL and change it via push/replace inside the story.

My use-case is a complex search page with search results, filters, and a load more button. I write stories with the play function to check how the router changed after filters/pagination manipulations.

And everything works great with useRouter. I can write any test/story and everything will be fine.

But I prefer to use a singleton router to reduce component renders. For instance, let's take a look at the load more button. When you press it, you want to add a query (e.g. ?page=2) to the URL without the button rerender.

But if you use useRouter, there would be rerendering on every query change.

import { useCallback } from 'react'
import { useRouter } from 'next/router'

// component renders on every useRouter hook changes
const PaginationButton = () => {
  const { query, pathname, push } = useRouter()

  const handleClick = useCallback(() => push({
    pathname: pathname,
    query: {
      ...query,
      page: +query.page + 1
    },
  }, undefined,
  {
    scroll: false,
    shallow: true
  }), [query, pathname, push])

  return (
    <button onClick={handleClick}>load more</button>
  )
}

That's why I prefer to use a singleton router. In this case, I read query and push only in a callback.

import { useCallback } from 'react'
import Router from 'next/router'

// component renders only once, and use query/pathname/push only in callback
const PaginationButton = () => {
  const handleClick = useCallback(() => Router.push({
    pathname: Router.pathname,
    query: {
      ...Router.query,
      page: +Router.query.page + 1
    },
  }, undefined,
  {
    scroll: false,
    shallow: true
  }), [])

  return (
    <button onClick={handleClick}>load more</button>
  )
}

Thank you for the explanation! That's a perfectly valid use-case for using the singleton router, so I totally get it.

I'm thinking, it might actually be best for me to update this repo's documentation. I can add the webpack alias configuration as the default approach for mocking in Storybook, since that will cover almost all use-cases.
You wouldn't even need to use the MemoryRouterProvider component any more -- unless you wanted to tie into events, or maybe have multiple "isolated" routers in a story for demo purposes? But I'll have to think about how useful that is, compared to just "configure and forget".

@scottrippey
In my opinion, MemoryRouterProvider should be covered in docs, because it is really important for "isolated" routers.
I can share with you my experience with next-router-mock. I use it on every story to add an isolated URL. That's why I like this package!

But as I wrote earlier, right now webpack alias doesn't work correctly. We alias I can push/replace, but can't set the URL initial value. Maybe am I doing something wrong? (working example here https://github.com/sedlukha/next-router-mock-example/tree/alias)

I hadn't thought of your use-case before, but now I'm realizing that it's a totally valid, great approach for performance. Using the singleton router (for components that don't need to rerender on URL changes) seems like the right thing to do.

So I guess I'm just trying to think of how to best integrate this with Storybook.

In your example repo, the MemoryRouterProvider is only providing an isolated router for the useRouter hook, so it won't do anything to affect the singleton router.

The only way I can think of, to set the singleton URL, per Story, is by doing something like this:

import router from 'next-router-mock';
const SingletonRouterProvider = ({ url, children }) => {
  useEffect(() => {
    router.setCurrentUrl(url);
  }, [ url ]);

  return <>{children}</>;
}

const MyStory = () => (
  <SingletonRouterProvider url="/example">
    <MyComponent />
  </SingletonRouterProvider>
);

Ok, well, I've given this more thought.

  1. This library should support the singleton router better. The current approach for unit tests, via jest.mock, works fine. However, the Provider-based approach for Storybook does not work.
  2. The only way to enable the singleton router inside Storybook is to use the webpack alias approach.
  3. When using the webpack alias approach, the useRouter hook gets overridden, and no longer works with the Provider approach. So the MemoryRouterProvider no longer works, and we ONLY deal with the singleton.

So I'm thinking of ways to fix # 3... thinking out loud:

The SingletonRouterProvider approach I posted above seems like a good way to handle it, and might work for simple cases. However, I think there might be a lot of caveats if there's more than 1 provider for a story. For example, if I had a global decorator that set the url to /global, then I'm not sure if I can override this for an individual story. The useEffects trigger from inside-out, so the URL would always get set to the /global path last.

I could attempt to improve the MemoryRouterProvider by adding a Context to it, and checking for that context within the useRouter hook. This would allow us to truly have isolated, overridable routers. But to do this, I'd also have to orchestrate some "sync" behavior between the singleton router and the local router. It wouldn't be terribly difficult, but it would add a new level of complexity. We would be able to have multiple routers in a single story, but the singleton router could behave strangely with that setup.

I'll continue to problem-solve here, because I really do want to support the singleton router better. For now, the SingletonRouterProvider approach above, along with the webpack alias, will probably work for you!

Hey @scottrippey! Thanks again for your answers.

I can confirm that SingletonRouterProvider with useEffect works great!
Can't wait to see the next-router-mock new version. But even now everything works fine.

Let me know if I can help you somehow. For example, I can add more examples in my repo and you will add them to the library

Oh! I've found new relative issue.

Link doesn't work with alias.

image

I've updated example repo:
https://github.com/sedlukha/next-router-mock-example/blob/alias/pages/link.js

Yes, I was going to talk about next/link because that one is interesting too...

Basically, to mock it using the alias approach, you'd need another alias, since next/link imports from a different path.
Try adding this to your webpack config:

module.exports = {
  webpackFinal: async (config, { configType }) => {
    config.resolve.alias = {
      ...config.resolve.alias,
      "next/router": "next-router-mock",
      "next/dist/client/router": "next-router-mock",
    };
    return config;
  },
};

Yes, I've tried to add this alias. But, unfortunately, doesn't work

@scottrippey may be will be easier to fix it for next 12.2?

12.2.5 also doesn't work, but returns different error. with and without alias:
image

example
https://github.com/sedlukha/next-router-mock-example/tree/next.12.2

Oh, you're right. With Next 12.2 they changed the way next/link works: #58
There's actually some sample code in that issue that fixes it, but would only work for jest. It would require a little more work to adapt that workaround to work with Storybook.

I'll keep thinking about it, because it seems like there should be a way to expose a solution that works for all scenarios.

Hi @scottrippey ,
Any update to make next/link (12.2) working with storybook.
I'm getting this error while running npm run storybook

ModuleNotFoundError: Module not found: Error: Can't resolve 'next/dist/next-server/lib/router-context' in 'my-project\node_modules\next-router-mock\dist\MemoryRouterProvider'

@amolagre That's a separate issue; you can follow along here: #58
Also, judging by that error message, can you check that you're not importing the next-10 version? You might have this:

import MemoryRouterProvider from "next-router-mock/MemoryRouterProvider/next-10";

But the path should just be "next-router-mock/MemoryRouterProvider"

Hi @scottrippey,

Thanks for update. I see that you are already working on a solution.
And, I'm using "next-router-mock/MemoryRouterProvider" only, not the next-10 version.

@amolagre I had the same ModuleNotFoundError error as you, but it seems to work by importing the next-12 version specifically:

import { MemoryRouterProvider } from "next-router-mock/MemoryRouterProvider/next-12";

@sedlukha I've been working hard to fix this, and I have a prerelease ready to test!

There aren't huge changes in this version, but the documentation was difficult to get right ...
Could you please read this updated README.md, and give me any feedback if it's not clear?

Instructions:

  1. Install this prerelease: npm install next-router-mock@0.9.1-beta.0
  2. Read the updated README, especially the section on Usage with Storybook to update your stories
  3. Tell me how it goes!

I've merged and published a new version, 0.9.1. This includes primarily a new README, which includes instructions for using webpack aliases for Storybook, which fixes this issue.