next-router-mock icon indicating copy to clipboard operation
next-router-mock copied to clipboard

Issue with next/link in Next 12.2.0

Open mateuszlazar opened this issue 3 years ago • 8 comments

Hello!

I've noticed that after upgrade to newest version of Next 12.2.0 (released 3 days ago) there is an issue with tests utilising Next Link.

import Link from "next/link"
...
jest.mock("next/router", () => require("next-router-mock"));
jest.mock("next/dist/client/router", () => require("next-router-mock"));
...
it("", () => {
  const { getByText } = render(<div><Link href={"/foo"}>Foo</Link></div>);
  const foo = getByText("Foo");
  fireEvent.click(foo);
});
...

test fails with following error:

TypeError: Cannot read properties of null (reading 'push') 57 | fireEvent.click(foo);

After Next downgrade to previous stable version (12.1.6) everything works fine.

I am on the latest version of next-router-mock

I suspected that there may be some changes in the paths or refactor of Link component in the 12.2.0 release but didn't found anything like that in the PR. Do you have any clue what's going on?

Best!

mateuszlazar avatar Jul 01 '22 07:07 mateuszlazar

Looking into the source, I do see some significant changes to next/link in the 12.2.0 release... In 12.1.6, it was importing the "singleton" router from next/dist/client/router like so: https://github.com/vercel/next.js/blob/v12.1.6/packages/next/client/link.tsx#L12

import { useRouter } from './router';

But now, it looks like it obtains the router from a Context, like so: https://github.com/vercel/next.js/blob/canary/packages/next/client/link.tsx#L293

let router = React.useContext(RouterContext)

This is not great ... it makes it very difficult to use jest.mock. I'll brainstorm a bit on how this could be solved ...

scottrippey avatar Jul 01 '22 19:07 scottrippey

One potential solution/workaround:

jest.mock('next/dist/shared/lib/router-context', () => {
  const React = require("react");
  const router = require("next-router-mock");
  const RouterContext = createContext(router);
  return { RouterContext };
})

This will replace the RouterContext with a new context, defaulted to the singleton mock router.

If this works, it might be worth adding this into the library, like:

jest.mock('next/dist/shared/lib/router-context', () => require('next-router-mock/router-context'));

scottrippey avatar Jul 01 '22 19:07 scottrippey

For reference, I started this https://github.com/vercel/next.js/discussions/38248 to discuss ways to prevent these kinds of breaking changes.

scottrippey avatar Jul 01 '22 20:07 scottrippey

Your solution/workaround doesn't seem to resolve the situation entirely.

I've added the following code (we're on React 18):

jest.mock("next/dist/shared/lib/router-context", () => {
  const { createContext } = require("react");
  const router = require("next-router-mock");
  const RouterContext = createContext(router);
  return { RouterContext };
});

This results in an error of:

TypeError: Cannot read property 'split' of undefined

    41   |   const router = useRouter();
    42   |   console.log(router);
  > 43   |   const trail = router.asPath.split("/");
         |                               ^
    44   |
    45   |   const crumbs: Breadcrumb[] = trail.map((crumb, index) => {
    46   |     return {

If I'm right it's because the Routing object during the test is:

{
  useMemoryRouter: [Getter],
  MemoryRouter: [Getter],
  BaseRouter: [Getter],
  memoryRouter: MemoryRouter {
    isReady: true,
    route: '',
    pathname: '<PathDetails>',
    hash: '',
    query: [Object: null prototype] {},
    asPath: '<PathDetails>',
    basePath: '',
    isFallback: false,
    isPreview: false,
    isLocaleDomain: false,
    locale: undefined,
    locales: [],
    events: {
      on: [Function: on],
      off: [Function: off],
      emit: [Function: emit]
    },
    async: false,
    registerPaths: [Function (anonymous)],
    push: [Function (anonymous)],
    replace: [Function (anonymous)],
    setCurrentUrl: [Function (anonymous)]
  },
  useRouter: [Function: useRouter],
  withRouter: [Function: withRouter],
  default: MemoryRouter {
    isReady: true,
    route: '',
    pathname: '<PathDetails>',
    hash: '',
    query: [Object: null prototype] {},
    asPath: '<PathDetails>',
    basePath: '',
    isFallback: false,
    isPreview: false,
    isLocaleDomain: false,
    locale: undefined,
    locales: [],
    events: {
      on: [Function: on],
      off: [Function: off],
      emit: [Function: emit]
    },
    async: false,
    registerPaths: [Function (anonymous)],
    push: [Function (anonymous)],
    replace: [Function (anonymous)],
    setCurrentUrl: [Function (anonymous)]
  }
}

Any thoughts on how to resolve?

Damo1155 avatar Jul 12 '22 12:07 Damo1155

Oh I see ... this is simply a problem with require, versus import ... let's try this (adding in .default):

jest.mock("next/dist/shared/lib/router-context", () => {
  const React = require("react");
  const router = require("next-router-mock").default;
  const RouterContext = React.createContext(router);
  return { RouterContext };
});

scottrippey avatar Jul 12 '22 16:07 scottrippey

Thanks for the input.

Just to note, the above code resolves most of the issue but it still seems like you need both of the following:

jest.mock("next/dist/client/router", () => require("next-router-mock"));

jest.mock("next/dist/shared/lib/router-context", () => {
  const { createContext } = require("react");
  const router = require("next-router-mock").default;
  const RouterContext = createContext(router);
  return { RouterContext };
});

Not having line one continues to break the tests but keeping line one passes them all.

Damo1155 avatar Jul 13 '22 07:07 Damo1155

Not having line one continues to break the tests but keeping line one passes them all.

Having both lines worked for me, but I had an ESLint warning to disable:

/* eslint-disable global-require */ // Disable global-require for mocks

So I swapped the require statements for jest.requireActual:

jest.mock('next/dist/client/router', () =>
  jest.requireActual('next-router-mock')
);
jest.mock('next/dist/shared/lib/router-context', () => {
  const { createContext } = jest.requireActual('react');
  const router = jest.requireActual('next-router-mock').default;
  const RouterContext = createContext(router);
  return { RouterContext };
});

DoctorDerek avatar Sep 08 '22 19:09 DoctorDerek

Thanks for the feedback! As a long-term solution, I think it might be good to encapsulate some of this logic inside this package, so you could do:

// To support next/router (singleton router and useRouter)
jest.mock("next/router", () => require("next-router-mock"));

// To support next/link  with next@<12.2.0
jest.mock("next/dist/client/router", () => require("next-router-mock")); 

// To support next/link with next@>=12.2.0
jest.mock("next/dist/shared/lib/router-context", () => require("next-router-mock/router-context"));
//  ------------- this would be new ---------------------------------------------^^^^^^^^^^^^^^

But as you can imagine, this is all just excessively brittle -- these internal paths just keep changing. Perhaps it would be better to mock next/link entirely, I'm not sure how hard that would be.

scottrippey avatar Sep 08 '22 20:09 scottrippey

I've started work on this router-context concept, but it's not quite complete, and I'm not sure if it's a long-term solution. I'll post here as I update.

I'm attempting to get in contact with Vercel directly, so that I can make some headway on a long-term solution.

scottrippey avatar Sep 28 '22 22:09 scottrippey

I thank you very much for all your efforts, but I wish Next.JS would be shipped with such a thing under a next/test-support module. I know that I am not helping, but I am moving to Svelte Kit precisely because of this issue.

phcoliveira avatar Oct 07 '22 13:10 phcoliveira

I've found a fundamental flaw with this approach. Supplying a "default value" for the context is not a good solution, because the value will never update, so the next/link will always be looking at a stale url. I'm looking for an alternative.

scottrippey avatar Nov 07 '22 17:11 scottrippey

The ONLY way I can think of handling this, currently, is for all tests to add a <MemoryRouterProvider> wrapper. I can probably add this feature, but would need help testing it.

Would anyone here be able to test things out, once I get a prerelease published?

scottrippey avatar Nov 07 '22 17:11 scottrippey

The ONLY way I can think of handling this, currently, is for all tests to add a <MemoryRouterProvider> wrapper. I can probably add this feature, but would need help testing it.

Would anyone here be able to test things out, once I get a prerelease published?

I am using your library and would be willing to try a prerelease, let me know if I can help!

simonedavico avatar Nov 14 '22 17:11 simonedavico

@simonedavico @mateuszlazar 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 [email protected]
  2. Read the updated README, especially the section on Compatibility with next/link to update your tests
  3. Tell me how it goes!

scottrippey avatar Jan 11 '23 20:01 scottrippey

@simonedavico @mateuszlazar 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 [email protected]
  2. Read the updated README, especially the section on Compatibility with next/link to update your tests
  3. Tell me how it goes!

Will do and get back to you ASAP! Thanks for you hard work! 🙏

simonedavico avatar Jan 12 '23 08:01 simonedavico

I've merged and published a new version, 0.9.1. This includes primarily a new README, which includes new instructions for compatibility with next/link in Next 12.2+ & 13+.
TL;DR: to mock with next/link, you must add a <MemoryRouterProvider> wrapper.

scottrippey avatar Jan 30 '23 06:01 scottrippey