-
-
Notifications
You must be signed in to change notification settings - Fork 215
add next.js app router example #101
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
base: main
Are you sure you want to change the base?
Changes from 7 commits
759bf1c
f45cce8
eb017b0
704b320
bb7ea62
c01169b
40ce6d7
29979fc
c745f58
fc467a3
ea6ed2e
a7ef8f7
dc7a437
b3db34c
d2c3e03
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import type { Metadata } from 'next' | ||
| import { Inter } from 'next/font/google' | ||
| import { MockProvider } from './mockProvider' | ||
|
|
||
| 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}> | ||
| <MockProvider>{children}</MockProvider> | ||
| </body> | ||
| </html> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,31 @@ | ||||||
| 'use client' | ||||||
| import { useEffect, useState } from 'react' | ||||||
|
|
||||||
| export function MockProvider({ | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How about using suspense? mockProvider.tsx 'use client'
let triggered = false
async function enableApiMocking() {
const { worker } = await import('../mocks/browser')
await worker.start()
}
export function MockProvider() {
if (!triggered) {
triggered = true
throw enableApiMocking()
}
return null
}layout.tsx export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body className={inter.className}>
<MockProvider />
{children}
</body>
</html>
)
}By doing so, we can avoid wrapping children in the mock provider client component.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The goal of this is to defer the rendering of the children until the service worker is activated. You are proposing keeping the state internally but I don't see it affecting |
||||||
| children, | ||||||
| }: Readonly<{ | ||||||
| children: React.ReactNode | ||||||
| }>) { | ||||||
| const [mockingEnabled, enableMocking] = useState(false) | ||||||
|
|
||||||
| useEffect(() => { | ||||||
| async function enableApiMocking() { | ||||||
| /** | ||||||
| * @fixme Next puts this import to the top of | ||||||
| * this module and runs it during the build | ||||||
| * in Node.js. This makes "msw/browser" import to fail. | ||||||
| */ | ||||||
| const { worker } = await import('../mocks/browser') | ||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Next.js puts this dynamic import from the browser runtime to the Node.js build by moving it to the top of the module. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How about fixing like this? if (typeof window !== 'undefined') {
const { worker } = await import('../mocks/browser')
await worker.start()
}There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This didn't work for me. I had to do this instead: if (process.env.NEXT_RUNTIME !== "nodejs") {
const { worker } = await import("../mocks/browser");
await worker.start();
}
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @brycefranzen, that may actually work around webpack resolving this import in Node.js as well. I still think that's a bug though. |
||||||
| await worker.start() | ||||||
| enableMocking(true) | ||||||
| } | ||||||
|
|
||||||
| enableApiMocking() | ||||||
| }, []) | ||||||
|
|
||||||
| if (!mockingEnabled) { | ||||||
| return null | ||||||
| } | ||||||
|
|
||||||
| return <>{children}</> | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| '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> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import { MovieList } from './movieList' | ||
|
|
||
| export type User = { | ||
| firstName: string | ||
| lastName: string | ||
| } | ||
|
|
||
| async function getUser() { | ||
| console.log('fetching user', fetch) | ||
| 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> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| export async function register() { | ||
| if (process.env.NEXT_RUNTIME === 'nodejs') { | ||
| const { server } = await import('./mocks/node') | ||
| server.listen() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| import { setupWorker } from 'msw/browser' | ||
| import { handlers } from './handlers' | ||
|
|
||
| export const worker = setupWorker(...handlers) |
| 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/movieList' | ||
|
|
||
| export const handlers = [ | ||
| http.get<never, never, User>('https://api.example.com/user', () => { | ||
| return HttpResponse.json({ | ||
| firstName: 'John', | ||
| lastName: 'Maverick', | ||
| }) | ||
| }), | ||
| graphql.query<{ movies: Array<Movie> }>('ListMovies', () => { | ||
| return HttpResponse.json({ | ||
| data: { | ||
| movies: [ | ||
| { | ||
| id: '6c6dba95-e027-4fe2-acab-e8c155a7f0ff', | ||
| title: 'The 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', | ||
| }, | ||
| ], | ||
| }, | ||
| }) | ||
| }), | ||
| ] |
| 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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| /** @type {import('next').NextConfig} */ | ||
| const nextConfig = { | ||
| experimental: { | ||
| instrumentationHook: true, | ||
| }, | ||
| webpack(config, { isServer }) { | ||
| /** | ||
| * @fixme This is completely redundant. webpack should understand | ||
| * export conditions and don't try to import "msw/browser" code | ||
| * that's clearly marked as client-side only in the app. | ||
| */ | ||
| if (isServer) { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a hack. I'm not sure why webpack has trouble resolving export conditions. I suspect this isn't webpack's fault. Next.js runs a pure client-side component in Node.js during SSR build, which results in webpack thinking those client-side imports must be resolved in Node.js. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @kettanaito I think the Having said that, I pulled this repository down and ran dev and build and both succeeded.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Got it, thanks for clarifying, @dbk91! I suspect webpack extracts that import and puts it at the top of the module for whichever optimization. This is a bit odd since I know this example succeeds. I've added tests to confirm that and they are passing. But I'm not looking for the first working thing. I'm looking for an integration that'd last and make sense for developers. This one, in its current state, doesn't, as it has a couple of fundamentals problems. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Understood, that makes sense! I totally missed your follow up messages in your original Tweet—I was expecting something non-functional and didn't realize there was extra work to get these tests passing. Either way, I've been following this for quite some time and appreciate the work you've put into MSW and specifically this integration. My team was using it prior to upgrading to app router and we've sorely missed it, but that's on us for upgrading. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I know it's not a long-term solution, but `await eval("import('msw/node')") is an option to avoid nextjs trying to be clever. |
||
| if (Array.isArray(config.resolve.alias)) { | ||
| config.resolve.alias.push({ name: 'msw/browser', alias: false }) | ||
| } else { | ||
| config.resolve.alias['msw/browser'] = false | ||
| } | ||
| } else { | ||
| if (Array.isArray(config.resolve.alias)) { | ||
| config.resolve.alias.push({ name: 'msw/node', alias: false }) | ||
| } else { | ||
| config.resolve.alias['msw/node'] = false | ||
| } | ||
| } | ||
|
|
||
| return config | ||
| }, | ||
| } | ||
|
|
||
| export default nextConfig | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| { | ||
| "name": "with-next", | ||
| "version": "0.1.0", | ||
| "private": true, | ||
| "scripts": { | ||
| "dev": "next dev", | ||
| "build": "next build", | ||
| "start": "next start", | ||
| "lint": "next lint", | ||
| "test": "playwright test", | ||
| "postinstall": "pnpm exec playwright install" | ||
| }, | ||
| "dependencies": { | ||
| "next": "14.1.0", | ||
| "react": "^18", | ||
| "react-dom": "^18" | ||
| }, | ||
| "devDependencies": { | ||
| "@playwright/test": "^1.41.1", | ||
| "@types/node": "^20", | ||
| "@types/react": "^18", | ||
| "@types/react-dom": "^18", | ||
| "msw": "2.0.14", | ||
| "typescript": "^5" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { defineConfig } from '@playwright/test' | ||
|
|
||
| export default defineConfig({ | ||
| webServer: { | ||
| command: 'pnpm dev', | ||
| port: 3000, | ||
| reuseExistingServer: !process.env.CI, | ||
| }, | ||
| use: { | ||
| baseURL: 'http://localhost:3000', | ||
| }, | ||
| }) |


Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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/navigationredirect)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So
MSWProviderbeing a client component forces the rest of its children tree to be client components only? Is that the issue?There was a problem hiding this comment.
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
MSWProviderto be client components unless you import them directly into a file with the"use client"directive—server components can be passed aschildrenor other props to client components.However, I think passing
nulltofallbackin theSuspenseboundary inmsw-provider.tsxresults in a blank page until the promise resolves on the client-side. What we've done in our projects is passchildrenas thefallbackso 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.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@dbk91,
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
nullas the suspense fallback.