Skip to content

Commit 6bb0e91

Browse files
authored
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`
1 parent ddba1aa commit 6bb0e91

29 files changed

+711
-0
lines changed
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/* global location */
2+
import '../build/polyfills/polyfill-module'
3+
// @ts-ignore react-dom/client exists when using React 18
4+
import ReactDOMClient from 'react-dom/client'
5+
// @ts-ignore startTransition exists when using React 18
6+
import React, { useState, startTransition } from 'react'
7+
import { RefreshContext } from './streaming/refresh'
8+
import { createFromFetch } from 'next/dist/compiled/react-server-dom-webpack'
9+
10+
/// <reference types="react-dom/experimental" />
11+
12+
export const version = process.env.__NEXT_VERSION
13+
14+
const appElement: HTMLElement | Document | null = document
15+
16+
let reactRoot: any = null
17+
18+
function renderReactElement(
19+
domEl: HTMLElement | Document,
20+
fn: () => JSX.Element
21+
): void {
22+
const reactEl = fn()
23+
if (!reactRoot) {
24+
// Unlike with createRoot, you don't need a separate root.render() call here
25+
reactRoot = (ReactDOMClient as any).hydrateRoot(domEl, reactEl)
26+
} else {
27+
reactRoot.render(reactEl)
28+
}
29+
}
30+
31+
const getCacheKey = () => {
32+
const { pathname, search } = location
33+
return pathname + search
34+
}
35+
36+
const encoder = new TextEncoder()
37+
38+
let initialServerDataBuffer: string[] | undefined = undefined
39+
let initialServerDataWriter: WritableStreamDefaultWriter | undefined = undefined
40+
let initialServerDataLoaded = false
41+
let initialServerDataFlushed = false
42+
43+
function nextServerDataCallback(seg: [number, string, string]) {
44+
if (seg[0] === 0) {
45+
initialServerDataBuffer = []
46+
} else {
47+
if (!initialServerDataBuffer)
48+
throw new Error('Unexpected server data: missing bootstrap script.')
49+
50+
if (initialServerDataWriter) {
51+
initialServerDataWriter.write(encoder.encode(seg[2]))
52+
} else {
53+
initialServerDataBuffer.push(seg[2])
54+
}
55+
}
56+
}
57+
58+
// There might be race conditions between `nextServerDataRegisterWriter` and
59+
// `DOMContentLoaded`. The former will be called when React starts to hydrate
60+
// the root, the latter will be called when the DOM is fully loaded.
61+
// For streaming, the former is called first due to partial hydration.
62+
// For non-streaming, the latter can be called first.
63+
// Hence, we use two variables `initialServerDataLoaded` and
64+
// `initialServerDataFlushed` to make sure the writer will be closed and
65+
// `initialServerDataBuffer` will be cleared in the right time.
66+
function nextServerDataRegisterWriter(writer: WritableStreamDefaultWriter) {
67+
if (initialServerDataBuffer) {
68+
initialServerDataBuffer.forEach((val) => {
69+
writer.write(encoder.encode(val))
70+
})
71+
if (initialServerDataLoaded && !initialServerDataFlushed) {
72+
writer.close()
73+
initialServerDataFlushed = true
74+
initialServerDataBuffer = undefined
75+
}
76+
}
77+
78+
initialServerDataWriter = writer
79+
}
80+
81+
// When `DOMContentLoaded`, we can close all pending writers to finish hydration.
82+
const DOMContentLoaded = function () {
83+
if (initialServerDataWriter && !initialServerDataFlushed) {
84+
initialServerDataWriter.close()
85+
initialServerDataFlushed = true
86+
initialServerDataBuffer = undefined
87+
}
88+
initialServerDataLoaded = true
89+
}
90+
// It's possible that the DOM is already loaded.
91+
if (document.readyState === 'loading') {
92+
document.addEventListener('DOMContentLoaded', DOMContentLoaded, false)
93+
} else {
94+
DOMContentLoaded()
95+
}
96+
97+
const nextServerDataLoadingGlobal = ((self as any).__next_s =
98+
(self as any).__next_s || [])
99+
nextServerDataLoadingGlobal.forEach(nextServerDataCallback)
100+
nextServerDataLoadingGlobal.push = nextServerDataCallback
101+
102+
function createResponseCache() {
103+
return new Map<string, any>()
104+
}
105+
const rscCache = createResponseCache()
106+
107+
function fetchFlight(href: string, props?: any) {
108+
const url = new URL(href, location.origin)
109+
const searchParams = url.searchParams
110+
searchParams.append('__flight__', '1')
111+
if (props) {
112+
searchParams.append('__props__', JSON.stringify(props))
113+
}
114+
return fetch(url.toString())
115+
}
116+
117+
function useServerResponse(cacheKey: string, serialized?: string) {
118+
let response = rscCache.get(cacheKey)
119+
if (response) return response
120+
121+
if (initialServerDataBuffer) {
122+
const t = new TransformStream()
123+
const writer = t.writable.getWriter()
124+
response = createFromFetch(Promise.resolve({ body: t.readable }))
125+
nextServerDataRegisterWriter(writer)
126+
} else {
127+
const fetchPromise = serialized
128+
? (() => {
129+
const t = new TransformStream()
130+
const writer = t.writable.getWriter()
131+
writer.ready.then(() => {
132+
writer.write(new TextEncoder().encode(serialized))
133+
})
134+
return Promise.resolve({ body: t.readable })
135+
})()
136+
: fetchFlight(getCacheKey())
137+
response = createFromFetch(fetchPromise)
138+
}
139+
140+
rscCache.set(cacheKey, response)
141+
return response
142+
}
143+
144+
const ServerRoot = ({
145+
cacheKey,
146+
serialized,
147+
}: {
148+
cacheKey: string
149+
serialized?: string
150+
}) => {
151+
React.useEffect(() => {
152+
rscCache.delete(cacheKey)
153+
})
154+
const response = useServerResponse(cacheKey, serialized)
155+
const root = response.readRoot()
156+
return root
157+
}
158+
159+
function Root({ children }: React.PropsWithChildren<{}>): React.ReactElement {
160+
if (process.env.__NEXT_TEST_MODE) {
161+
// eslint-disable-next-line react-hooks/rules-of-hooks
162+
React.useEffect(() => {
163+
window.__NEXT_HYDRATED = true
164+
165+
if (window.__NEXT_HYDRATED_CB) {
166+
window.__NEXT_HYDRATED_CB()
167+
}
168+
}, [])
169+
}
170+
171+
return children as React.ReactElement
172+
}
173+
174+
const RSCComponent = (props: any) => {
175+
const cacheKey = getCacheKey()
176+
const { __flight_serialized__ } = props
177+
const [, dispatch] = useState({})
178+
const rerender = () => dispatch({})
179+
// If there is no cache, or there is serialized data already
180+
function refreshCache(nextProps: any) {
181+
startTransition(() => {
182+
const currentCacheKey = getCacheKey()
183+
const response = createFromFetch(fetchFlight(currentCacheKey, nextProps))
184+
185+
rscCache.set(currentCacheKey, response)
186+
rerender()
187+
})
188+
}
189+
190+
return (
191+
<RefreshContext.Provider value={refreshCache}>
192+
<ServerRoot cacheKey={cacheKey} serialized={__flight_serialized__} />
193+
</RefreshContext.Provider>
194+
)
195+
}
196+
197+
export function hydrate() {
198+
renderReactElement(appElement!, () => (
199+
<React.StrictMode>
200+
<Root>
201+
<RSCComponent />
202+
</Root>
203+
</React.StrictMode>
204+
))
205+
}

packages/next/client/root-next.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { hydrate, version } from './root-index'
2+
3+
window.next = {
4+
version,
5+
root: true,
6+
}
7+
8+
hydrate()

packages/next/server/config-shared.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export interface ExperimentalConfig {
9595
scrollRestoration?: boolean
9696
externalDir?: boolean
9797
conformance?: boolean
98+
rootDir?: boolean
9899
amp?: {
99100
optimizer?: any
100101
validator?: string
@@ -490,6 +491,7 @@ export const defaultConfig: NextConfig = {
490491
swcFileReading: true,
491492
craCompat: false,
492493
esmExternals: true,
494+
rootDir: false,
493495
// default to 50MB limit
494496
isrMemoryCacheSize: 50 * 1024 * 1024,
495497
serverComponents: false,
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
module.exports = {
2+
experimental: {
3+
rootDir: true,
4+
runtime: 'nodejs',
5+
reactRoot: true,
6+
serverComponents: true,
7+
},
8+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function Page(props) {
2+
return (
3+
<>
4+
<p>hello from pages/blog/[slug]</p>
5+
</>
6+
)
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function Page(props) {
2+
return (
3+
<>
4+
<p>hello from pages/index</p>
5+
</>
6+
)
7+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
hello world
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export default function Root({ headChildren, bodyChildren }) {
2+
return (
3+
<html className="this-is-the-document-html">
4+
<head>
5+
{headChildren}
6+
<title>Test</title>
7+
</head>
8+
<body className="this-is-the-document-body">{bodyChildren}</body>
9+
</html>
10+
)
11+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { useState, useEffect } from 'react'
2+
export default function ClientComponentRoute() {
3+
const [count, setCount] = useState(0)
4+
useEffect(() => {
5+
setCount(1)
6+
}, [count])
7+
return (
8+
<>
9+
<p>hello from root/client-component-route. count: {count}</p>
10+
</>
11+
)
12+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useState, useEffect } from 'react'
2+
3+
export default function ClientNestedLayout({ children }) {
4+
const [count, setCount] = useState(0)
5+
useEffect(() => {
6+
setCount(1)
7+
}, [])
8+
return (
9+
<>
10+
<h1>Client Nested. Count: {count}</h1>
11+
<button onClick={() => setCount(count + 1)}>{count}</button>
12+
{children}
13+
</>
14+
)
15+
}

0 commit comments

Comments
 (0)