Skip to content

Commit

Permalink
Make router able to navigate between rsc pages (#35344)
Browse files Browse the repository at this point in the history
### Bugfix
Made some changes for the data register buffer flushing in #34631 and #34475 that tried to delete the buffer or flush them only once. But turns out it will break the navigation between RSC pages.


### Enhancements
Simplify the inline response writer and inline response data for the initial render. Since they're only for the initial render, navigations will leverage the serialized data fetched from router and construct the react tree.


## Bug

Fixes #35135

- [x] Related issues linked using `fixes #number`
- [x] Integration tests added
- [x] Errors have helpful link attached, see `contributing.md`
  • Loading branch information
huozhi committed Mar 16, 2022
1 parent 33aa51a commit 60ad1de
Show file tree
Hide file tree
Showing 6 changed files with 113 additions and 70 deletions.
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
40 changes: 35 additions & 5 deletions test/integration/react-streaming-and-server-components/test/rsc.js
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

0 comments on commit 60ad1de

Please sign in to comment.