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 tests for routing experiment #36618

Merged
merged 4 commits into from May 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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>
</>
)
}
@@ -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>
</>
)
}
@@ -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}
</>
)
}