Skip to content

Commit

Permalink
Generate static html for bots (#35004)
Browse files Browse the repository at this point in the history
* Generate static html for bots

* fix lint

* refactor error handling against comment

* fix streaming on edge for bots

* inline doResolve
  • Loading branch information
huozhi committed Mar 4, 2022
1 parent 62c33c1 commit 7083dcf
Show file tree
Hide file tree
Showing 20 changed files with 161 additions and 204 deletions.
6 changes: 4 additions & 2 deletions packages/next/server/base-server.ts
Expand Up @@ -980,12 +980,12 @@ export default abstract class Server {
query: NextParsedUrlQuery
}
): Promise<void> {
const userAgent = partialContext.req.headers['user-agent']
const isBotRequest = isBot(partialContext.req.headers['user-agent'] || '')
const ctx = {
...partialContext,
renderOpts: {
...this.renderOpts,
supportsDynamicHTML: userAgent ? !isBot(userAgent) : false,
supportsDynamicHTML: !isBotRequest,
},
} as const
const payload = await fn(ctx)
Expand Down Expand Up @@ -1175,11 +1175,13 @@ export default abstract class Server {
}

if (opts.supportsDynamicHTML === true) {
const isBotRequest = isBot(req.headers['user-agent'] || '')
// Disable dynamic HTML in cases that we know it won't be generated,
// so that we can continue generating a cache key when possible.
opts.supportsDynamicHTML =
!isSSG &&
!isLikeServerless &&
!isBotRequest &&
!query.amp &&
typeof components.Document?.getInitialProps !== 'function'
}
Expand Down
89 changes: 44 additions & 45 deletions packages/next/server/render.tsx
Expand Up @@ -67,6 +67,7 @@ import isError from '../lib/is-error'
import { readableStreamTee } from './web/utils'
import { ImageConfigContext } from '../shared/lib/image-config-context'
import { FlushEffectsContext } from '../shared/lib/flush-effects'
import { execOnce } from '../shared/lib/utils'

let optimizeAmp: typeof import('./optimize-amp').default
let getFontDefinitionFromManifest: typeof import('./font-utils').getFontDefinitionFromManifest
Expand Down Expand Up @@ -1349,6 +1350,7 @@ export async function renderToHTML(
))}
</>
),
generateStaticHTML: true,
})

const flushed = await streamToString(flushEffectStream)
Expand All @@ -1360,6 +1362,7 @@ export async function renderToHTML(
element: content,
suffix,
dataStream: serverComponentsInlinedTransformStream?.readable,
generateStaticHTML: generateStaticHTML || !hasConcurrentFeatures,
flushEffectHandler,
})
}
Expand Down Expand Up @@ -1492,6 +1495,7 @@ export async function renderToHTML(
const documentStream = await renderToStream({
ReactDOMServer,
element: document,
generateStaticHTML: true,
})
documentHTML = await streamToString(documentStream)
} else {
Expand Down Expand Up @@ -1741,67 +1745,62 @@ function createFlushEffectStream(
})
}

function renderToStream({
async function renderToStream({
ReactDOMServer,
element,
suffix,
dataStream,
generateStaticHTML,
flushEffectHandler,
}: {
ReactDOMServer: typeof import('react-dom/server')
element: React.ReactElement
suffix?: string
dataStream?: ReadableStream<Uint8Array>
generateStaticHTML: boolean
flushEffectHandler?: () => Promise<string>
}): Promise<ReadableStream<Uint8Array>> {
return new Promise(async (resolve, reject) => {
let resolved = false

const closeTag = '</body></html>'
const suffixUnclosed = suffix ? suffix.split(closeTag)[0] : null

const doResolve = (renderStream: ReadableStream<Uint8Array>) => {
if (!resolved) {
resolved = true

// React will call our callbacks synchronously, so we need to
// defer to a microtask to ensure `stream` is set.
resolve(
Promise.resolve().then(() => {
const transforms: Array<TransformStream<Uint8Array, Uint8Array>> = [
createBufferedTransformStream(),
flushEffectHandler
? createFlushEffectStream(flushEffectHandler)
: null,
suffixUnclosed != null
? createPrefixStream(suffixUnclosed)
: null,
dataStream ? createInlineDataStream(dataStream) : null,
suffixUnclosed != null ? createSuffixStream(closeTag) : null,
].filter(Boolean) as any

return transforms.reduce(
(readable, transform) => pipeThrough(readable, transform),
renderStream
)
})
)
const closeTag = '</body></html>'
const suffixUnclosed = suffix ? suffix.split(closeTag)[0] : null

let completeCallback: (value?: unknown) => void
const allComplete = new Promise((resolveError, rejectError) => {
completeCallback = execOnce((err: unknown) => {
if (err) {
rejectError(err)
} else {
resolveError(null)
}
}

const renderStream: ReadableStream<Uint8Array> = await (
ReactDOMServer as any
).renderToReadableStream(element, {
onError(err: Error) {
if (!resolved) {
resolved = true
reject(err)
}
},
})
})

doResolve(renderStream)
const renderStream: ReadableStream<Uint8Array> = await (
ReactDOMServer as any
).renderToReadableStream(element, {
onError(err: Error) {
completeCallback(err)
},
onCompleteAll() {
completeCallback()
},
})

if (generateStaticHTML) {
await allComplete
}

const transforms: Array<TransformStream<Uint8Array, Uint8Array>> = [
createBufferedTransformStream(),
flushEffectHandler ? createFlushEffectStream(flushEffectHandler) : null,
suffixUnclosed != null ? createPrefixStream(suffixUnclosed) : null,
dataStream ? createInlineDataStream(dataStream) : null,
suffixUnclosed != null ? createSuffixStream(closeTag) : null,
].filter(Boolean) as any

return transforms.reduce(
(readable, transform) => pipeThrough(readable, transform),
renderStream
)
}

function encodeText(input: string) {
Expand Down
6 changes: 4 additions & 2 deletions packages/next/server/web-server.ts
Expand Up @@ -131,7 +131,7 @@ export default class NextWebServer extends BaseServer {
query,
{
...renderOpts,
supportsDynamicHTML: true,
// supportsDynamicHTML: true,
disableOptimizedLoading: true,
runtime: 'edge',
}
Expand Down Expand Up @@ -175,7 +175,9 @@ export default class NextWebServer extends BaseServer {
// Not implemented: on/removeListener
} as any)
} else {
res.body(await options.result.toUnchunkedString())
// TODO: generate Etag
const payload = await options.result.toUnchunkedString()
res.body(payload)
}

res.send()
Expand Down
1 change: 0 additions & 1 deletion packages/next/server/web/render.ts

This file was deleted.

35 changes: 0 additions & 35 deletions test/integration/react-18/test/index.test.js
Expand Up @@ -10,7 +10,6 @@ import {
nextBuild,
nextStart,
renderViaHTTP,
fetchViaHTTP,
hasRedbox,
getRedboxHeader,
} from 'next-test-utils'
Expand Down Expand Up @@ -145,40 +144,6 @@ function runTestsAgainstRuntime(runtime) {
)
})
}

it('should stream to users', async () => {
const res = await fetchViaHTTP(context.appPort, '/ssr')
expect(res.headers.get('etag')).toBeNull()
})

it('should not stream to bots', async () => {
const res = await fetchViaHTTP(
context.appPort,
'/ssr',
{},
{
headers: {
'user-agent': 'Googlebot',
},
}
)
expect(res.headers.get('etag')).toBeDefined()
})

it('should not stream to google pagerender bot', async () => {
const res = await fetchViaHTTP(
context.appPort,
'/ssr',
{},
{
headers: {
'user-agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36 Google-PageRenderer Google (+https://developers.google.com/+/web/snippet/)',
},
}
)
expect(res.headers.get('etag')).toBeDefined()
})
},
{
beforeAll: (env) => {
Expand Down

This file was deleted.

This file was deleted.

This file was deleted.

@@ -0,0 +1,5 @@
export function Named() {
return 'named.client'
}

export default () => 'default-export-arrow.client'

This file was deleted.

This file was deleted.

This file was deleted.

Expand Up @@ -4,7 +4,7 @@ let did = false
function Error() {
if (!did && typeof window === 'undefined') {
did = true
throw new Error('broken page')
throw new Error('oops')
}
}

Expand Down
@@ -1,6 +1,4 @@
import Foo from '../components/foo.client'
import { Named } from '../components/named.client'

import Link from 'next/link'

const envVar = process.env.ENV_VAR_TEST
Expand All @@ -13,9 +11,6 @@ export default function Index({ header, router }) {
<div>{'path:' + router.pathname}</div>
<div>{'env:' + envVar}</div>
<div>{'header:' + header}</div>
<div>
<Named />
</div>
<div>
<Foo />
</div>
Expand Down
@@ -0,0 +1,26 @@
// shared named exports
import { a, b, c, d, e } from '../components/shared-exports'
// client default, named exports
import DefaultArrow, {
Named as ClientNamed,
} from '../components/client-exports.client'

export default function Page() {
return (
<div>
<div>
{a}
{b}
{c}
{d}
{e[0]}
</div>
<div>
<DefaultArrow />
</div>
<div>
<ClientNamed />
</div>
</div>
)
}
Expand Up @@ -37,13 +37,12 @@ export default async function basic(context, { env }) {
})

it('should render 500 error correctly', async () => {
const path500HTML = await renderViaHTTP(context.appPort, '/err')

const html = await renderViaHTTP(context.appPort, '/err')
if (env === 'dev') {
// In dev mode it should show the error popup.
expect(path500HTML).toContain('Error: oops')
expect(html).toContain('Error: oops')
} else {
expect(path500HTML).toContain('custom-500-page')
expect(html).toContain('custom-500-page')
}
})
}

0 comments on commit 7083dcf

Please sign in to comment.