Skip to content
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

add next.js app router example #101

Draft
wants to merge 15 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/examples.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,16 @@ jobs:
version: 8.15
- run: pnpm install --filter with-karma
- run: pnpm test --filter with-karma

next:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- uses: pnpm/action-setup@v2
with:
version: 7.12.1
- run: pnpm install --filter with-next
- run: pnpm test --filter with-next
29 changes: 9 additions & 20 deletions examples/with-angular/src/mockServiceWorker.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
* - Please do NOT serve this file on production.
*/

const PACKAGE_VERSION = '2.6.4'
const INTEGRITY_CHECKSUM = 'ca7800994cc8bfb5eb961e037c877074'
const PACKAGE_VERSION = '2.4.2'
const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()

Expand Down Expand Up @@ -62,12 +62,7 @@ self.addEventListener('message', async function (event) {

sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
payload: true,
})
break
}
Expand Down Expand Up @@ -160,10 +155,6 @@ async function handleRequest(event, requestId) {
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)

if (activeClientIds.has(event.clientId)) {
return client
}

if (client?.frameType === 'top-level') {
return client
}
Expand Down Expand Up @@ -192,14 +183,12 @@ async function getResponse(event, client, requestId) {
const requestClone = request.clone()

function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)

// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
headers.delete('accept', 'msw/passthrough')
const headers = Object.fromEntries(requestClone.headers.entries())

// Remove internal MSW request header so the passthrough request
// complies with any potential CORS preflight checks on the server.
// Some servers forbid unknown request headers.
delete headers['x-msw-intention']

return fetch(requestClone, { headers })
}
Expand Down
29 changes: 9 additions & 20 deletions examples/with-karma/test/mockServiceWorker.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
* - Please do NOT serve this file on production.
*/

const PACKAGE_VERSION = '2.6.4'
const INTEGRITY_CHECKSUM = 'ca7800994cc8bfb5eb961e037c877074'
const PACKAGE_VERSION = '2.4.2'
const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()

Expand Down Expand Up @@ -62,12 +62,7 @@ self.addEventListener('message', async function (event) {

sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
payload: true,
})
break
}
Expand Down Expand Up @@ -160,10 +155,6 @@ async function handleRequest(event, requestId) {
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)

if (activeClientIds.has(event.clientId)) {
return client
}

if (client?.frameType === 'top-level') {
return client
}
Expand Down Expand Up @@ -192,14 +183,12 @@ async function getResponse(event, client, requestId) {
const requestClone = request.clone()

function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)

// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
headers.delete('accept', 'msw/passthrough')
const headers = Object.fromEntries(requestClone.headers.entries())

// Remove internal MSW request header so the passthrough request
// complies with any potential CORS preflight checks on the server.
// Some servers forbid unknown request headers.
delete headers['x-msw-intention']

return fetch(requestClone, { headers })
}
Expand Down
36 changes: 36 additions & 0 deletions examples/with-next/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env*.local

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts
36 changes: 36 additions & 0 deletions examples/with-next/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).

## Getting Started

First, run the development server:

```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.

This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.

## Learn More

To learn more about Next.js, take a look at the following resources:

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!

## Deploy on Vercel

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.

Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
Binary file added examples/with-next/app/favicon.ico
Binary file not shown.
29 changes: 29 additions & 0 deletions examples/with-next/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { MSWProvider } from './msw-provider'

if (process.env.NEXT_RUNTIME === 'nodejs') {
const { server } = require('@/mocks/node')
server.listen()
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it recommended to do it here, or in the instrumentation file?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bitttttten, it is recommended to enable it in layout.tsx. You can forget about the instrumentation file. I've tried it before, but it doesn't support HMR and isn't a part of your app's life-cycle. That was a bad choice, and the Next.js team members have pointed that out for me.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In what way is it suggested to change the mock per test if the suggested way to set up the mocks is via the layout file?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@votemike wouldnt each url be in a way idempotent? do you mean you want to call 1 url multiple times and see different results?
if you want to run different things depending on search parameters and what not you can do that in the mock

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the same URL with different return values.
Something like a product page.
Product A with stock
Product A out of stock
Product A coming soon
Product A 404 or 500ing.

I suppose I could vary the URL to pick up the mock I want. But in reality, production code would be calling the same URL and getting different return values at different times. Hence my desire to be able to vary the mock per test.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
}

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body className={inter.className}>
<MSWProvider>{children}</MSWProvider>
</body>
</html>
)
Comment on lines +23 to +28
Copy link

@vedantshetty vedantshetty Oct 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand that the main goal of the provider is to ensure the service workers are loaded before the components makes any API calls

But it's not ideal that we need to change our application behaviour to only use client components.

We could update the layout.tsx code to conditionally wrap the children if the application is running in dev, this could still let us use server rendering in production.

But even with that, what about using server component specific logic (eg: Using next/navigation redirect)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So MSWProvider being a client component forces the rest of its children tree to be client components only? Is that the issue?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kettanaito It won't force the components within MSWProvider to be client components unless you import them directly into a file with the "use client" directive—server components can be passed as children or other props to client components.

However, I think passing null to fallback in the Suspense boundary in msw-provider.tsx results in a blank page until the promise resolves on the client-side. What we've done in our projects is pass children as the fallback so the original tree is rendered while the promise resolves. I have no idea if this has performance implications at scale, but it's worked for our purposes.

Copy link

@vedantshetty vedantshetty Oct 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dbk91 You're absolutely right. Can ignore my concerns since they are incorrect

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dbk91,

However, I think passing null to fallback in the Suspense boundary in msw-provider.tsx results in a blank page until the promise resolves on the client-side.

That is intended. Your app mustn't render until MSW is activated. If it does, it may fire HTTP requests and nothing will be there to intercept them. You must use null as the suspense fallback.

}
51 changes: 51 additions & 0 deletions examples/with-next/app/movie-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use client'

import { useState } from 'react'

export type Movie = {
id: string
title: string
}

export function MovieList() {
const [movies, setMovies] = useState<Array<Movie>>([])

const fetchMovies = () => {
fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `
query ListMovies {
movies {
id
title
}
}
`,
}),
})
.then((response) => response.json())
.then((response) => {
setMovies(response.data.movies)
})
.catch(() => setMovies([]))
}

return (
<div>
<button id="fetch-movies-button" onClick={fetchMovies}>
Fetch movies
</button>
{movies.length > 0 ? (
<ul id="movies-list">
{movies.map((movie) => (
<li key={movie.id}>{movie.title}</li>
))}
</ul>
) : null}
</div>
)
}
44 changes: 44 additions & 0 deletions examples/with-next/app/msw-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
'use client'

import { Suspense, use } from 'react'
import { handlers } from '@/mocks/handlers'

const mockingEnabledPromise =
typeof window !== 'undefined'
? import('@/mocks/browser').then(async ({ worker }) => {
await worker.start({
onUnhandledRequest(request, print) {
if (request.url.includes('_next')) {
return
}
print.warning()
},
})
worker.use(...handlers)

console.log(worker.listHandlers())
})
: Promise.resolve()

export function MSWProvider({
children,
}: Readonly<{
children: React.ReactNode
}>) {
Comment on lines +23 to +27
Copy link

@sebws sebws Nov 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export function MSWProvider({
children,
}: Readonly<{
children: React.ReactNode
}>) {
export default ({
children,
}: Readonly<{
children: React.ReactNode
}>) => {

@kettanaito

Some success 😄

The reason the interval is cleared in the Svelte example is because this listener for beforeunload is called.

In the Next example, this event listener is not called (presumably beforeunload doesn’t get called due to how Next.js handles page changes). Therefore, nothing clears the intervals and the workers are not garbage collected.

I'm not 100% sure how it works, since in Svelte beforeunload is happening but it doesn't seem like window.location.reload is being called. I did notice this meant that changing the handler used in the server rendered component doesn't cause it to be reloaded.

I believe Next.js doesn't perform a full page reload because the MSWProvider has a parent (layout.tsx) so a Fast Refresh is triggered. In a normal React setup, the changed file is at the root (pre-rendering) and has no parents, so a full page refresh is triggered.

We can take advantage of the fact that Fast Refresh use the casing of exports in a file to check if hot reload is possible. See this docs page https://nextjs.org/docs/messages/fast-refresh-reload

Exporting a component as an anonymous component breaks this, and so it always triggers a full reload. Therefore, we can make the change here to get HMR (via full page reload).

I think given this info, it's worth still thinking about module.hot.dispose() which does not force a full page reload. This mimics the behaviour of Svelte, including unfortunately that changing the handlers doesn't reflect in the server-rendered components (until manual reload).

In my eyes it depends upon if it's a bug or not a bug that beforeunload isn't being run in Next.js when there's HMR for the MSWProvider. If that isn't a bug, then there's no reason for setInterval to be cleared and so no reason for the extra worker to disappear. As a relevant note, Svelte mentions something along these lines in their HMR package. But at the moment I don't see a massive amount of difference between using beforeunload here to do this vs module.hot.dispose()

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried both approaches, named and anonymous export, but both fails on:
[MSW] Warning: intercepted a request without a matching request handler:

In browser logs I clearly see that the worker has started.

Starting MSW worker
[MSW] Mocking enabled.

On server side the requests are mocked, but on client side all requests are just warning logged.

What I am missing? Using Next 14.2

My provider looks like this:

'use client';

import { Suspense, use } from 'react';

const mockingEnabledPromise =
  typeof window !== 'undefined' && process.env.NEXT_RUNTIME !== 'nodejs'
    ? import('../mocks/browser').then(async ({ worker }) => {
        console.log('Starting MSW worker');
        await worker.start({
          onUnhandledRequest(request, print) {
            if (request.url.includes('_next')) {
              return;
            }
            const excludedRoutes = [
              'cognito-idp.eu-west-1.amazonaws.com',
              'cognito-identity.eu-west-1.amazonaws.com',
              'google-analytics.com',
            ];
            const isExcluded = excludedRoutes.some((route) => {
              return request?.url?.includes(route);
            });
            if (isExcluded) {
              return;
            }
            print.warning();
          },
        });
      })
    : Promise.resolve();

export default ({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) => {
  return (
    <Suspense fallback={null}>
      <MSWProviderWrapper>{children}</MSWProviderWrapper>
    </Suspense>
  );
};

function MSWProviderWrapper({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  use(mockingEnabledPromise);
  console.log('MSWProviderWrapper children', children);
  return children;
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@funes79 not sure this is relevant to this comment I'm afraid. seems like an issue with how you've set up the handlers.

@kettanaito did you see my comment above?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure what issues you are trying to solve here.

The current state of this PR gives you client-side mocking. If something is not mocked, follow the Debugging runbook, the issue is likely elsewhere.

The only thing missing in the current state is a proper HMR support for client-side mocking.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you responding to funes? My original comment is about client side HMR

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sebws, yes, that is correct.

Thanks for diving deeper into the HMR issue.

Therefore, nothing clears the intervals and the workers are not garbage collected.

So you are saying that interval keeps the worker object (not the service worker, mind) persist between HMR? That still sounds a bit odd to me. Do you have proof that clearing that interval indeed solves the issue?

I don't see module.hot.dispose() as the way forward, to be frank. This is a low-level hackery that an average Next.js user shouldn't be exposed to. I don't want to ask developers to do that. The issue is clearly specific to how Next.js handlers client-side HMR, otherwise we had the same issue in other frameworks. This is just a mention that the proper fix for this one is likely on Next.js' side, and that's why we have vercel/next.js#69098.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looking again, it looks like more generally it's about beforeunload being called and what that does, in particular deregistering the actual service worker.

      context.events.addListener(window, "beforeunload", () => {
        if (worker.state !== "redundant") {
          context.workerChannel.send("CLIENT_CLOSED");
        }
        window.clearInterval(context.keepAliveInterval);
      });

it's not just the interval which is keeping the worker object in memory. we can prove this by logging the creation of the interval, clearing it, and the issue persists.

I may have made the original post at 2am and I don't have a full picture of how msw works so some of this is best guess

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for context on how I'm looking at this for if you or others are interested, I'm using the "Memory" tab of chrome devtools, and taking heap snapshots at various points. then in the filter, adding SetupWorkerApi and looking at the retainers.

I found this pretty confusing but key for me has been clicking through when there is a line of code referenced, as well as trying out what happens when you right click -> ignore this retainer.

// If MSW is enabled, we need to wait for the worker to start,
// so we wrap the children in a Suspense boundary until it's ready.
return (
<Suspense fallback={null}>
<MSWProviderWrapper>{children}</MSWProviderWrapper>
</Suspense>
)
}

function MSWProviderWrapper({
children,
}: Readonly<{
children: React.ReactNode
}>) {
use(mockingEnabledPromise)
return children
}
23 changes: 23 additions & 0 deletions examples/with-next/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { MovieList } from './movie-list'

export type User = {
firstName: string
lastName: string
}

async function getUser() {
const response = await fetch('https://api.example.com/user')
const user = (await response.json()) as User
return user
}

export default async function Home() {
const user = await getUser()

return (
<main>
<p id="server-side-greeting">Hello, {user.firstName}!</p>
<MovieList />
</main>
)
}
3 changes: 3 additions & 0 deletions examples/with-next/mocks/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { setupWorker } from 'msw/browser'

export const worker = setupWorker()
32 changes: 32 additions & 0 deletions examples/with-next/mocks/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { http, graphql, HttpResponse } from 'msw'
import type { User } from '@/app/page'
import type { Movie } from '@/app/movie-list'

export const handlers = [
http.get<never, never, User>('https://api.example.com/user', () => {
return HttpResponse.json({
firstName: 'Sarah',
lastName: 'Maverick',
})
}),
graphql.query<{ movies: Array<Movie> }>('ListMovies', () => {
return HttpResponse.json({
data: {
movies: [
{
id: '6c6dba95-e027-4fe2-acab-e8c155a7f0ff',
title: '123 Lord of The Rings',
},
{
id: 'a2ae7712-75a7-47bb-82a9-8ed668e00fe3',
title: 'The Matrix',
},
{
id: '916fa462-3903-4656-9e76-3f182b37c56f',
title: 'Star Wars: The Empire Strikes Back',
},
],
},
})
}),
]
4 changes: 4 additions & 0 deletions examples/with-next/mocks/node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)
Loading
Loading