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

Make router able to navigate between rsc pages #35344

Merged
merged 4 commits into from Mar 16, 2022
Merged
Show file tree
Hide file tree
Changes from 2 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
87 changes: 35 additions & 52 deletions packages/next/client/index.tsx
Expand Up @@ -678,51 +678,38 @@ if (process.env.__NEXT_RSC) {
} = require('next/dist/compiled/react-server-dom-webpack')

const encoder = new TextEncoder()
const serverDataBuffer = new Map<string, string[]>()
const serverDataWriter = new Map<string, WritableStreamDefaultWriter>()
const serverDataCacheKey = getCacheKey()
let initialServerDataBuffer: string[] | undefined = undefined
let initialServerDataWriter: WritableStreamDefaultWriter | undefined =
undefined
function nextServerDataCallback(seg: [number, string, string]) {
const key = serverDataCacheKey + ',' + seg[1]
if (seg[0] === 0) {
serverDataBuffer.set(key, [])
initialServerDataBuffer = []
} else {
const buffer = serverDataBuffer.get(key)
if (!buffer)
if (!initialServerDataBuffer)
throw new Error('Unexpected server data: missing bootstrap script.')

const writer = serverDataWriter.get(key)
if (writer) {
writer.write(encoder.encode(seg[2]))
if (initialServerDataWriter) {
initialServerDataWriter.write(encoder.encode(seg[2]))
} else {
buffer.push(seg[2])
initialServerDataBuffer.push(seg[2])
}
}
}
function nextServerDataRegisterWriter(
key: string,
writer: WritableStreamDefaultWriter
) {
const buffer = serverDataBuffer.get(key)
if (buffer) {
buffer.forEach((val) => {
function nextServerDataRegisterWriter(writer: WritableStreamDefaultWriter) {
if (initialServerDataBuffer) {
initialServerDataBuffer.forEach((val) => {
writer.write(encoder.encode(val))
})
buffer.length = 0
// Clean buffer but not deleting the key to mark bootstrap as complete.
// Then `nextServerDataCallback` will be safely skipped in the future renders.
serverDataBuffer.set(key, [])
}
serverDataWriter.set(key, writer)
initialServerDataWriter = writer
}
// When `DOMContentLoaded`, we can close all pending writers to finish hydration.
document.addEventListener(
'DOMContentLoaded',
function () {
serverDataWriter.forEach((writer) => {
if (!writer.closed) {
writer.close()
}
})
if (initialServerDataWriter && !initialServerDataWriter.closed) {
initialServerDataWriter.close()
}
},
false
)
Expand All @@ -748,27 +735,26 @@ if (process.env.__NEXT_RSC) {
}

function useServerResponse(cacheKey: string, serialized?: string) {
const id = (React as any).useId()

let response = rscCache.get(cacheKey)
if (response) return response

const bufferCacheKey = cacheKey + ',' + router.route + ',' + id
if (serverDataBuffer.has(bufferCacheKey)) {
if (initialServerDataBuffer) {
const t = new TransformStream()
const writer = t.writable.getWriter()
response = createFromFetch(Promise.resolve({ body: t.readable }))
nextServerDataRegisterWriter(bufferCacheKey, writer)
nextServerDataRegisterWriter(writer)
} else {
response = createFromFetch(
serialized
? (() => {
const t = new TransformStream()
t.writable.getWriter().write(new TextEncoder().encode(serialized))
return Promise.resolve({ body: t.readable })
})()
: fetchFlight(getCacheKey())
)
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)
Expand All @@ -778,26 +764,27 @@ if (process.env.__NEXT_RSC) {
const ServerRoot = ({
cacheKey,
serialized,
_fresh,
}: {
cacheKey: string
serialized?: string
_fresh?: boolean
}) => {
React.useEffect(() => {
rscCache.delete(cacheKey)
})
React.useEffect(() => {
initialServerDataBuffer = undefined
}, [])
const response = useServerResponse(cacheKey, serialized)
const root = response.readRoot()
return root
}

RSCComponent = (props: any) => {
const cacheKey = getCacheKey()
const { __flight_serialized__, __flight_fresh__ } = props
const { __flight_serialized__ } = props
const [, dispatch] = useState({})
const startTransition = (React as any).startTransition
const renrender = () => dispatch({})
const rerender = () => dispatch({})
// If there is no cache, or there is serialized data already
function refreshCache(nextProps: any) {
startTransition(() => {
Expand All @@ -807,17 +794,13 @@ if (process.env.__NEXT_RSC) {
)

rscCache.set(currentCacheKey, response)
renrender()
rerender()
})
}

return (
<RefreshContext.Provider value={refreshCache}>
<ServerRoot
cacheKey={cacheKey}
serialized={__flight_serialized__}
_fresh={__flight_fresh__}
/>
<ServerRoot cacheKey={cacheKey} serialized={__flight_serialized__} />
</RefreshContext.Provider>
)
}
Expand Down
@@ -0,0 +1,23 @@
import Link from 'next/link'

export default function Nav() {
return (
<>
<div>
<Link href={'/next-api/link'}>
<a id="goto-next-link">next link</a>
</Link>
</div>
<div>
<Link href={'/streaming-rsc'}>
<a id="goto-streaming-rsc">streaming rsc</a>
</Link>
</div>
<div>
<Link href={'/'}>
<a id="goto-home">home</a>
</Link>
</div>
</>
)
}
@@ -1,5 +1,5 @@
import Foo from '../components/foo.client'
import Link from 'next/link'
import Nav from '../components/nav.server'

const envVar = process.env.ENV_VAR_TEST
const headerKey = 'x-next-test-client'
Expand All @@ -14,9 +14,7 @@ export default function Index({ header, router }) {
<div>
<Foo />
</div>
<Link href={'/'}>
<a id="refresh">refresh</a>
</Link>
<Nav />
</div>
)
}
Expand Down
@@ -1,17 +1,18 @@
import Link from 'next/link'
import Nav from '../../components/nav.server'

export default function LinkPage({ router }) {
const { query } = router
const id = parseInt(query.id || '0', 10)
return (
<>
<h3 id="query">query:{id}</h3>
<Link href={`/next-api/link?id=${id + 1}`}>
<a id="next_id">next id</a>
</Link>
<Link href={`/`}>
<a>go home</a>
</Link>
<div>
<Link href={`/next-api/link?id=${id + 1}`}>
<a id="next_id">next id</a>
</Link>
</div>
<Nav />
</>
)
}
Expand Down
@@ -1,4 +1,5 @@
import { Suspense } from 'react'
import Nav from '../components/nav.server'

let result
let promise
Expand All @@ -16,9 +17,16 @@ function Data() {

export default function Page() {
return (
<Suspense fallback="next_streaming_fallback">
<Data />
</Suspense>
<div>
<div id="content">
<Suspense fallback="next_streaming_fallback">
<Data />
</Suspense>
</div>
<div>
<Nav />
</div>
</div>
)
}

Expand Down
Expand Up @@ -70,9 +70,12 @@ export default function (context, { runtime, env }) {

it('should support next/link in server components', async () => {
const linkHTML = await renderViaHTTP(context.appPort, '/next-api/link')
const linkText = getNodeBySelector(linkHTML, '#__next > a[href="/"]').text()
const linkText = getNodeBySelector(
linkHTML,
'#__next > div > a[href="/"]'
).text()

expect(linkText).toContain('go home')
expect(linkText).toContain('home')

const browser = await webdriver(context.appPort, '/next-api/link')

Expand All @@ -92,9 +95,36 @@ export default function (context, { runtime, env }) {
expect(await browser.eval('window.beforeNav')).toBe(1)
})

it('should be able to navigate between rsc pages', async () => {
let content
const browser = await webdriver(context.appPort, '/')

await browser.waitForElementByCss('#goto-next-link').click()
await new Promise((res) => setTimeout(res, 1000))
expect(await browser.url()).toBe(
`http://localhost:${context.appPort}/next-api/link`
)
await browser.waitForElementByCss('#goto-home').click()
await new Promise((res) => setTimeout(res, 1000))
expect(await browser.url()).toBe(`http://localhost:${context.appPort}/`)
content = await browser.elementByCss('#__next').text()
expect(content).toContain('component:index.server')

await browser.waitForElementByCss('#goto-streaming-rsc').click()
await new Promise((res) => setTimeout(res, 1500))
expect(await browser.url()).toBe(
`http://localhost:${context.appPort}/streaming-rsc`
)

content = await browser.elementByCss('#content').text()
expect(content).toContain('next_streaming_data')
})

it('should handle streaming server components correctly', async () => {
const browser = await webdriver(context.appPort, '/streaming-rsc')
const content = await browser.eval(`window.document.body.innerText`)
const content = await browser.eval(
`document.querySelector('#content').innerText`
)
expect(content).toMatchInlineSnapshot('"next_streaming_data"')
})

Expand All @@ -113,7 +143,7 @@ export default function (context, { runtime, env }) {

it('should refresh correctly with next/link', async () => {
// Select the button which is not hidden but rendered
const selector = '#__next #refresh'
const selector = '#__next #goto-next-link'
let hasFlightRequest = false
const browser = await webdriver(context.appPort, '/', {
beforePageLoad(page) {
Expand All @@ -138,7 +168,7 @@ export default function (context, { runtime, env }) {
expect(hasFlightRequest).toBe(true)
}
const refreshText = await browser.elementByCss(selector).text()
expect(refreshText).toBe('refresh')
expect(refreshText).toBe('next link')
})

if (env === 'dev') {
Expand Down