Skip to content

Commit

Permalink
Add tests for routing experiment (#36618)
Browse files Browse the repository at this point in the history
## 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`
  • Loading branch information
timneutkens committed May 2, 2022
1 parent ddba1aa commit 6bb0e91
Show file tree
Hide file tree
Showing 29 changed files with 711 additions and 0 deletions.
205 changes: 205 additions & 0 deletions packages/next/client/root-index.tsx
@@ -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>
))
}
8 changes: 8 additions & 0 deletions packages/next/client/root-next.js
@@ -0,0 +1,8 @@
import { hydrate, version } from './root-index'

window.next = {
version,
root: true,
}

hydrate()
2 changes: 2 additions & 0 deletions packages/next/server/config-shared.ts
Expand Up @@ -95,6 +95,7 @@ export interface ExperimentalConfig {
scrollRestoration?: boolean
externalDir?: boolean
conformance?: boolean
rootDir?: boolean
amp?: {
optimizer?: any
validator?: string
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions test/e2e/root-dir/app/next.config.js
@@ -0,0 +1,8 @@
module.exports = {
experimental: {
rootDir: true,
runtime: 'nodejs',
reactRoot: true,
serverComponents: true,
},
}
7 changes: 7 additions & 0 deletions test/e2e/root-dir/app/pages/blog/[slug].js
@@ -0,0 +1,7 @@
export default function Page(props) {
return (
<>
<p>hello from pages/blog/[slug]</p>
</>
)
}
7 changes: 7 additions & 0 deletions test/e2e/root-dir/app/pages/index.js
@@ -0,0 +1,7 @@
export default function Page(props) {
return (
<>
<p>hello from pages/index</p>
</>
)
}
1 change: 1 addition & 0 deletions test/e2e/root-dir/app/public/hello.txt
@@ -0,0 +1 @@
hello world
11 changes: 11 additions & 0 deletions test/e2e/root-dir/app/root.server.js
@@ -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>
)
}
12 changes: 12 additions & 0 deletions test/e2e/root-dir/app/root/client-component-route.client.js
@@ -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>
</>
)
}
15 changes: 15 additions & 0 deletions test/e2e/root-dir/app/root/client-nested.client.js
@@ -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}
</>
)
}
7 changes: 7 additions & 0 deletions test/e2e/root-dir/app/root/client-nested/index.server.js
@@ -0,0 +1,7 @@
export default function ClientPage() {
return (
<>
<p>hello from root/client-nested</p>
</>
)
}
27 changes: 27 additions & 0 deletions test/e2e/root-dir/app/root/conditional/[slug].server.js
@@ -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}
</>
)
}
7 changes: 7 additions & 0 deletions test/e2e/root-dir/app/root/conditional/[slug]@team/index.js
@@ -0,0 +1,7 @@
export default function TeamHomePage(props) {
return (
<>
<p>hello from team homepage</p>
</>
)
}
7 changes: 7 additions & 0 deletions test/e2e/root-dir/app/root/conditional/[slug]@team/members.js
@@ -0,0 +1,7 @@
export default function TeamMembersPage(props) {
return (
<>
<p>hello from team/members</p>
</>
)
}
7 changes: 7 additions & 0 deletions test/e2e/root-dir/app/root/conditional/[slug]@user/index.js
@@ -0,0 +1,7 @@
export default function UserHomePage(props) {
return (
<>
<p>hello from user homepage</p>
</>
)
}
7 changes: 7 additions & 0 deletions test/e2e/root-dir/app/root/conditional/[slug]@user/teams.js
@@ -0,0 +1,7 @@
export default function UserTeamsPage(props) {
return (
<>
<p>hello from user/teams</p>
</>
)
}
7 changes: 7 additions & 0 deletions test/e2e/root-dir/app/root/dashboard+changelog.server.js
@@ -0,0 +1,7 @@
export default function ChangelogPage(props) {
return (
<>
<p>hello from root/dashboard/changelog</p>
</>
)
}
7 changes: 7 additions & 0 deletions test/e2e/root-dir/app/root/dashboard+rootonly/hello.server.js
@@ -0,0 +1,7 @@
export default function HelloPage(props) {
return (
<>
<p>hello from root/dashboard/rootonly/hello</p>
</>
)
}
8 changes: 8 additions & 0 deletions test/e2e/root-dir/app/root/dashboard.server.js
@@ -0,0 +1,8 @@
export default function DashboardLayout({ children }) {
return (
<>
<h1>Dashboard</h1>
{children}
</>
)
}

0 comments on commit 6bb0e91

Please sign in to comment.