Add tests for routing experiment (#36618)

## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have helpful link attached, see `contributing.md`

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR.
- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have helpful link attached, see `contributing.md`

## Documentation / Examples

- [ ] Make sure the linting passes by running `yarn lint`
This commit is contained in:
Tim Neutkens 2022-05-02 12:18:16 +02:00 committed by GitHub
parent ddba1aab1f
commit 6bb0e91a0c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 711 additions and 0 deletions

View file

@ -0,0 +1,205 @@
/* global location */
import '../build/polyfills/polyfill-module'
// @ts-ignore react-dom/client exists when using React 18
import ReactDOMClient from 'react-dom/client'
// @ts-ignore startTransition exists when using React 18
import React, { useState, startTransition } from 'react'
import { RefreshContext } from './streaming/refresh'
import { createFromFetch } from 'next/dist/compiled/react-server-dom-webpack'
/// <reference types="react-dom/experimental" />
export const version = process.env.__NEXT_VERSION
const appElement: HTMLElement | Document | null = document
let reactRoot: any = null
function renderReactElement(
domEl: HTMLElement | Document,
fn: () => JSX.Element
): void {
const reactEl = fn()
if (!reactRoot) {
// Unlike with createRoot, you don't need a separate root.render() call here
reactRoot = (ReactDOMClient as any).hydrateRoot(domEl, reactEl)
} else {
reactRoot.render(reactEl)
}
}
const getCacheKey = () => {
const { pathname, search } = location
return pathname + search
}
const encoder = new TextEncoder()
let initialServerDataBuffer: string[] | undefined = undefined
let initialServerDataWriter: WritableStreamDefaultWriter | undefined = undefined
let initialServerDataLoaded = false
let initialServerDataFlushed = false
function nextServerDataCallback(seg: [number, string, string]) {
if (seg[0] === 0) {
initialServerDataBuffer = []
} else {
if (!initialServerDataBuffer)
throw new Error('Unexpected server data: missing bootstrap script.')
if (initialServerDataWriter) {
initialServerDataWriter.write(encoder.encode(seg[2]))
} else {
initialServerDataBuffer.push(seg[2])
}
}
}
// There might be race conditions between `nextServerDataRegisterWriter` and
// `DOMContentLoaded`. The former will be called when React starts to hydrate
// the root, the latter will be called when the DOM is fully loaded.
// For streaming, the former is called first due to partial hydration.
// For non-streaming, the latter can be called first.
// Hence, we use two variables `initialServerDataLoaded` and
// `initialServerDataFlushed` to make sure the writer will be closed and
// `initialServerDataBuffer` will be cleared in the right time.
function nextServerDataRegisterWriter(writer: WritableStreamDefaultWriter) {
if (initialServerDataBuffer) {
initialServerDataBuffer.forEach((val) => {
writer.write(encoder.encode(val))
})
if (initialServerDataLoaded && !initialServerDataFlushed) {
writer.close()
initialServerDataFlushed = true
initialServerDataBuffer = undefined
}
}
initialServerDataWriter = writer
}
// When `DOMContentLoaded`, we can close all pending writers to finish hydration.
const DOMContentLoaded = function () {
if (initialServerDataWriter && !initialServerDataFlushed) {
initialServerDataWriter.close()
initialServerDataFlushed = true
initialServerDataBuffer = undefined
}
initialServerDataLoaded = true
}
// It's possible that the DOM is already loaded.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', DOMContentLoaded, false)
} else {
DOMContentLoaded()
}
const nextServerDataLoadingGlobal = ((self as any).__next_s =
(self as any).__next_s || [])
nextServerDataLoadingGlobal.forEach(nextServerDataCallback)
nextServerDataLoadingGlobal.push = nextServerDataCallback
function createResponseCache() {
return new Map<string, any>()
}
const rscCache = createResponseCache()
function fetchFlight(href: string, props?: any) {
const url = new URL(href, location.origin)
const searchParams = url.searchParams
searchParams.append('__flight__', '1')
if (props) {
searchParams.append('__props__', JSON.stringify(props))
}
return fetch(url.toString())
}
function useServerResponse(cacheKey: string, serialized?: string) {
let response = rscCache.get(cacheKey)
if (response) return response
if (initialServerDataBuffer) {
const t = new TransformStream()
const writer = t.writable.getWriter()
response = createFromFetch(Promise.resolve({ body: t.readable }))
nextServerDataRegisterWriter(writer)
} else {
const fetchPromise = serialized
? (() => {
const t = new TransformStream()
const writer = t.writable.getWriter()
writer.ready.then(() => {
writer.write(new TextEncoder().encode(serialized))
})
return Promise.resolve({ body: t.readable })
})()
: fetchFlight(getCacheKey())
response = createFromFetch(fetchPromise)
}
rscCache.set(cacheKey, response)
return response
}
const ServerRoot = ({
cacheKey,
serialized,
}: {
cacheKey: string
serialized?: string
}) => {
React.useEffect(() => {
rscCache.delete(cacheKey)
})
const response = useServerResponse(cacheKey, serialized)
const root = response.readRoot()
return root
}
function Root({ children }: React.PropsWithChildren<{}>): React.ReactElement {
if (process.env.__NEXT_TEST_MODE) {
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useEffect(() => {
window.__NEXT_HYDRATED = true
if (window.__NEXT_HYDRATED_CB) {
window.__NEXT_HYDRATED_CB()
}
}, [])
}
return children as React.ReactElement
}
const RSCComponent = (props: any) => {
const cacheKey = getCacheKey()
const { __flight_serialized__ } = props
const [, dispatch] = useState({})
const rerender = () => dispatch({})
// If there is no cache, or there is serialized data already
function refreshCache(nextProps: any) {
startTransition(() => {
const currentCacheKey = getCacheKey()
const response = createFromFetch(fetchFlight(currentCacheKey, nextProps))
rscCache.set(currentCacheKey, response)
rerender()
})
}
return (
<RefreshContext.Provider value={refreshCache}>
<ServerRoot cacheKey={cacheKey} serialized={__flight_serialized__} />
</RefreshContext.Provider>
)
}
export function hydrate() {
renderReactElement(appElement!, () => (
<React.StrictMode>
<Root>
<RSCComponent />
</Root>
</React.StrictMode>
))
}

View file

@ -0,0 +1,8 @@
import { hydrate, version } from './root-index'
window.next = {
version,
root: true,
}
hydrate()

View file

@ -95,6 +95,7 @@ export interface ExperimentalConfig {
scrollRestoration?: boolean
externalDir?: boolean
conformance?: boolean
rootDir?: boolean
amp?: {
optimizer?: any
validator?: string
@ -490,6 +491,7 @@ export const defaultConfig: NextConfig = {
swcFileReading: true,
craCompat: false,
esmExternals: true,
rootDir: false,
// default to 50MB limit
isrMemoryCacheSize: 50 * 1024 * 1024,
serverComponents: false,

View file

@ -0,0 +1,8 @@
module.exports = {
experimental: {
rootDir: true,
runtime: 'nodejs',
reactRoot: true,
serverComponents: true,
},
}

View file

@ -0,0 +1,7 @@
export default function Page(props) {
return (
<>
<p>hello from pages/blog/[slug]</p>
</>
)
}

View file

@ -0,0 +1,7 @@
export default function Page(props) {
return (
<>
<p>hello from pages/index</p>
</>
)
}

View file

@ -0,0 +1 @@
hello world

View file

@ -0,0 +1,11 @@
export default function Root({ headChildren, bodyChildren }) {
return (
<html className="this-is-the-document-html">
<head>
{headChildren}
<title>Test</title>
</head>
<body className="this-is-the-document-body">{bodyChildren}</body>
</html>
)
}

View file

@ -0,0 +1,12 @@
import { useState, useEffect } from 'react'
export default function ClientComponentRoute() {
const [count, setCount] = useState(0)
useEffect(() => {
setCount(1)
}, [count])
return (
<>
<p>hello from root/client-component-route. count: {count}</p>
</>
)
}

View file

@ -0,0 +1,15 @@
import { useState, useEffect } from 'react'
export default function ClientNestedLayout({ children }) {
const [count, setCount] = useState(0)
useEffect(() => {
setCount(1)
}, [])
return (
<>
<h1>Client Nested. Count: {count}</h1>
<button onClick={() => setCount(count + 1)}>{count}</button>
{children}
</>
)
}

View file

@ -0,0 +1,7 @@
export default function ClientPage() {
return (
<>
<p>hello from root/client-nested</p>
</>
)
}

View file

@ -0,0 +1,27 @@
export async function getServerSideProps({ params }) {
if (params.slug === 'nonexistent') {
return {
notFound: true,
}
}
return {
props: {
isUser: params.slug === 'tim',
isBoth: params.slug === 'both',
},
}
}
export default function UserOrTeam({ isUser, isBoth, user, team }) {
return (
<>
{isUser && !isBoth ? user : team}
{isBoth ? (
<>
{user}
{team}
</>
) : null}
</>
)
}

View file

@ -0,0 +1,7 @@
export default function TeamHomePage(props) {
return (
<>
<p>hello from team homepage</p>
</>
)
}

View file

@ -0,0 +1,7 @@
export default function TeamMembersPage(props) {
return (
<>
<p>hello from team/members</p>
</>
)
}

View file

@ -0,0 +1,7 @@
export default function UserHomePage(props) {
return (
<>
<p>hello from user homepage</p>
</>
)
}

View file

@ -0,0 +1,7 @@
export default function UserTeamsPage(props) {
return (
<>
<p>hello from user/teams</p>
</>
)
}

View file

@ -0,0 +1,7 @@
export default function ChangelogPage(props) {
return (
<>
<p>hello from root/dashboard/changelog</p>
</>
)
}

View file

@ -0,0 +1,7 @@
export default function HelloPage(props) {
return (
<>
<p>hello from root/dashboard/rootonly/hello</p>
</>
)
}

View file

@ -0,0 +1,8 @@
export default function DashboardLayout({ children }) {
return (
<>
<h1>Dashboard</h1>
{children}
</>
)
}

View file

@ -0,0 +1,16 @@
export function getServerSideProps() {
return {
props: {
message: 'hello',
},
}
}
export default function DeploymentsLayout({ message, children }) {
return (
<>
<h2>Deployments {message}</h2>
{children}
</>
)
}

View file

@ -0,0 +1,15 @@
export async function getServerSideProps({ params }) {
return {
props: {
id: params.id,
},
}
}
export default function DeploymentsPage(props) {
return (
<>
<p>hello from root/dashboard/deployments/[id]. ID is: {props.id}</p>
</>
)
}

View file

@ -0,0 +1,7 @@
export default function DeploymentsInfoPage(props) {
return (
<>
<p>hello from root/dashboard/deployments/info</p>
</>
)
}

View file

@ -0,0 +1,7 @@
export default function DashboardPage(props) {
return (
<>
<p>hello from root/dashboard</p>
</>
)
}

View file

@ -0,0 +1,7 @@
export default function IntegrationsPage(props) {
return (
<>
<p>hello from root/dashboard/integrations</p>
</>
)
}

View file

@ -0,0 +1,15 @@
export async function getServerSideProps({ params }) {
return {
props: {
id: params.id,
},
}
}
export default function DeploymentsPage(props) {
return (
<>
<p>hello from root/partial-match-[id]. ID is: {props.id}</p>
</>
)
}

View file

@ -0,0 +1,7 @@
export default function SharedComponentRoute() {
return (
<>
<p>hello from root/shared-component-route</p>
</>
)
}

View file

@ -0,0 +1,7 @@
export default function ShouldNotServeClientDotJs(props) {
return (
<>
<p>hello from root/should-not-serve-client</p>
</>
)
}

View file

@ -0,0 +1,7 @@
export default function ShouldNotServeServerDotJs(props) {
return (
<>
<p>hello from root/should-not-serve-server</p>
</>
)
}

View file

@ -0,0 +1,263 @@
import { createNext, FileRef } from 'e2e-utils'
import { NextInstance } from 'test/lib/next-modes/base'
import { fetchViaHTTP, renderViaHTTP } from 'next-test-utils'
import path from 'path'
import cheerio from 'cheerio'
import webdriver from 'next-webdriver'
// TODO: implementation
describe.skip('root dir', () => {
let next: NextInstance
beforeAll(async () => {
next = await createNext({
files: {
public: new FileRef(path.join(__dirname, 'app/public')),
pages: new FileRef(path.join(__dirname, 'app/pages')),
root: new FileRef(path.join(__dirname, 'app/root')),
'root.server.js': new FileRef(
path.join(__dirname, 'app/root.server.js')
),
'next.config.js': new FileRef(
path.join(__dirname, 'app/next.config.js')
),
},
dependencies: {
react: '18.0.0-rc.2',
'react-dom': '18.0.0-rc.2',
},
})
})
afterAll(() => next.destroy())
it('should serve from pages', async () => {
const html = await renderViaHTTP(next.url, '/')
expect(html).toContain('hello from pages/index')
})
it('should serve dynamic route from pages', async () => {
const html = await renderViaHTTP(next.url, '/blog/first')
expect(html).toContain('hello from pages/blog/[slug]')
})
it('should serve from public', async () => {
const html = await renderViaHTTP(next.url, '/hello.txt')
expect(html).toContain('hello world')
})
it('should serve from root', async () => {
const html = await renderViaHTTP(next.url, '/dashboard')
expect(html).toContain('hello from root/dashboard')
})
it('should include layouts when no direct parent layout', async () => {
const html = await renderViaHTTP(next.url, '/dashboard/integrations')
const $ = cheerio.load(html)
// Should not be nested in dashboard
expect($('h1').text()).toBe('Dashboard')
// Should include the page text
expect($('p').text()).toBe('hello from root/dashboard/integrations')
})
// TODO: why is this routable but /should-not-serve-server.server.js
it('should not include parent when not in parent directory with route in directory', async () => {
const html = await renderViaHTTP(next.url, '/dashboard/rootonly/hello')
const $ = cheerio.load(html)
// Should be nested in /root.js
expect($('html').hasClass('this-is-the-document-html')).toBeTruthy()
expect($('body').hasClass('this-is-the-document-body')).toBeTruthy()
// Should not be nested in dashboard
expect($('h1').text()).toBeFalsy()
// Should render the page text
expect($('p').text()).toBe('hello from root/dashboard/rootonly/hello')
})
it('should include parent document when no direct parent layout', async () => {
const html = await renderViaHTTP(next.url, '/dashboard/integrations')
const $ = cheerio.load(html)
// Root has to provide it's own document
expect($('html').hasClass('this-is-the-document-html')).toBeTruthy()
expect($('body').hasClass('this-is-the-document-body')).toBeTruthy()
})
it('should not include parent when not in parent directory', async () => {
const html = await renderViaHTTP(next.url, '/dashboard/changelog')
const $ = cheerio.load(html)
// Should not be nested in dashboard
expect($('h1').text()).toBeFalsy()
// Should include the page text
expect($('p').text()).toBe('hello from root/dashboard/changelog')
})
it('should serve nested parent', async () => {
const html = await renderViaHTTP(next.url, '/dashboard/deployments/123')
const $ = cheerio.load(html)
// Should be nested in dashboard
expect($('h1').text()).toBe('Dashboard')
// Should be nested in deployments
expect($('h2').text()).toBe('Deployments hello')
})
it('should serve dynamic parameter', async () => {
const html = await renderViaHTTP(next.url, '/dashboard/deployments/123')
const $ = cheerio.load(html)
// Should include the page text with the parameter
expect($('p').text()).toBe(
'hello from root/dashboard/deployments/[id]. ID is: 123'
)
})
it('should include document html and body', async () => {
const html = await renderViaHTTP(next.url, '/dashboard')
const $ = cheerio.load(html)
expect($('html').hasClass('this-is-the-document-html')).toBeTruthy()
expect($('body').hasClass('this-is-the-document-body')).toBeTruthy()
})
it('should not serve when layout is provided but no folder index', async () => {
const res = await fetchViaHTTP(next.url, '/dashboard/deployments')
expect(res.status).toBe(404)
expect(await res.text()).toContain('This page could not be found')
})
// TODO: do we want to make this only work for /root or is it allowed
// to work for /pages as well?
it('should match partial parameters', async () => {
const html = await renderViaHTTP(next.url, '/partial-match-123')
expect(html).toContain('hello from root/partial-match-[id]. ID is: 123')
})
describe('parallel routes', () => {
describe('conditional routes', () => {
it('should serve user page', async () => {
const html = await renderViaHTTP(next.url, '/conditional/tim')
expect(html).toContain('hello from user homepage')
})
it('should serve user teams page', async () => {
const html = await renderViaHTTP(next.url, '/conditional/tim/teams')
expect(html).toContain('hello from user/teams')
})
it('should not serve teams page to user', async () => {
const html = await renderViaHTTP(next.url, '/conditional/tim/members')
expect(html).not.toContain('hello from team/members')
})
it('should serve team page', async () => {
const html = await renderViaHTTP(next.url, '/conditional/vercel')
expect(html).toContain('hello from team homepage')
})
it('should serve team members page', async () => {
const html = await renderViaHTTP(
next.url,
'/conditional/vercel/members'
)
expect(html).toContain('hello from team/members')
})
it('should provide both matches if both paths match', async () => {
const html = await renderViaHTTP(next.url, '/conditional/both')
expect(html).toContain('hello from team homepage')
expect(html).toContain('hello from user homepage')
})
it('should 404 based on getServerSideProps', async () => {
const res = await fetchViaHTTP(next.url, '/conditional/nonexistent')
expect(res.status).toBe(404)
expect(await res.text()).toContain('This page could not be found')
})
})
})
describe('server components', () => {
// TODO: why is this not servable but /dashboard+rootonly/hello.server.js
// should be? Seems like they both either should be servable or not
it('should not serve .server.js as a path', async () => {
// Without .server.js should serve
const html = await renderViaHTTP(next.url, '/should-not-serve-server')
expect(html).toContain('hello from root/should-not-serve-server')
// Should not serve `.server`
const res = await fetchViaHTTP(
next.url,
'/should-not-serve-server.server'
)
expect(res.status).toBe(404)
expect(await res.text()).toContain('This page could not be found')
// Should not serve `.server.js`
const res2 = await fetchViaHTTP(
next.url,
'/should-not-serve-server.server.js'
)
expect(res2.status).toBe(404)
expect(await res2.text()).toContain('This page could not be found')
})
it('should not serve .client.js as a path', async () => {
// Without .client.js should serve
const html = await renderViaHTTP(next.url, '/should-not-serve-client')
expect(html).toContain('hello from root/should-not-serve-client')
// Should not serve `.client`
const res = await fetchViaHTTP(
next.url,
'/should-not-serve-client.client'
)
expect(res.status).toBe(404)
expect(await res.text()).toContain('This page could not be found')
// Should not serve `.client.js`
const res2 = await fetchViaHTTP(
next.url,
'/should-not-serve-client.client.js'
)
expect(res2.status).toBe(404)
expect(await res2.text()).toContain('This page could not be found')
})
it('should serve shared component', async () => {
// Without .client.js should serve
const html = await renderViaHTTP(next.url, '/shared-component-route')
expect(html).toContain('hello from root/shared-component-route')
})
it('should serve client component', async () => {
const html = await renderViaHTTP(next.url, '/client-component-route')
expect(html).toContain('hello from root/client-component-route. count: 0')
const browser = await webdriver(next.url, '/client-component-route')
// After hydration count should be 1
expect(await browser.elementByCss('p').text()).toBe(
'hello from root/client-component-route. count: 1'
)
})
it('should include client component layout with server component route', async () => {
const html = await renderViaHTTP(next.url, '/client-nested')
const $ = cheerio.load(html)
// Should not be nested in dashboard
expect($('h1').text()).toBe('Client Nested. Count: 0')
// Should include the page text
expect($('p').text()).toBe('hello from root/client-nested')
const browser = await webdriver(next.url, '/client-nested')
// After hydration count should be 1
expect(await browser.elementByCss('h1').text()).toBe(
'Client Nested. Count: 0'
)
// After hydration count should be 1
expect(await browser.elementByCss('h1').text()).toBe(
'hello from root/client-nested'
)
})
})
})