mirror of
https://github.com/hanzoai/next.js.git
synced 2026-10-30 14:46:38 +00:00
followup to: https://github.com/vercel/next.js/pull/60645 ### Background When prerendering the determination of whether a prerender is fully static or partially static should not be directly related to whether there is a postponed state or not. When rendering RSC it is possible to postpone because a dynamic API was used but then on the client (SSR) the postpone is never encountered. This can happen when a server component is passed to a client component and the client component conditionally renders the server component. Today if this happens the entire output would be considered static when in fact the flight data encoded into the page and used for bootstrapping the client router contains dynamic holes. Today this is blocked by an error that incorrectly assumes that this case means the user caught the postpone in the client layer but as shown above this may not be the case. ### Implementation A more capable model is to think of the outcome of a prerender as having 3 possible states 1. Dynamic HTML: The HTML produces by the prerender has dynamic holes. we save the static prelude but expect to resume the render later to complete the HTML. This means we will resume the RSC part of the render as well 2. Dynamic Data: The HTML is completely static but the RSC data encoded into the page is dynamic. We don't want to resume the render but we do need to produce new inlined RSC data during a Request. 3. Static: The HTML is completely static and so is the RSC data encoded into the page. We save the entire HTML output and there will be no dynamic continuation when this route is visited. Really 1 & 3 are the same as today (Partially static & Fully Static respectively) but case 2 which today errors in a confusing way is now supported. In addition implementing the Dynamic Data case the old warning about catching postpones is removed. The reason we don't want this is that catching postpones is potentially a valid way to do optimistic UI. We probably want a first-party API for it at some point (and maybe we'll add the warning back in once we do) but imagine you do something dynamic like look up a user but during prerender you want to render as if the user is logged out. you could call `getUser()` in a try catch and render fallback UI if it throws. In this case we'd detect a dynamic API was used but we wouldn't have a corresponding postpone state which would put us in the Dynamic Data case (2). Another item to note is that we can produce a fully static result even if there is a postponed state because users may call postpone themselves even if they are not calling dynamic APIs like headers or cookies. When this happens we don't want to statically capture a page with postponed boundaries in it. Instead we immediately resume the render and abort it with a postponed abort signal. This will cause the boundaries to immediately enter client render mode which should speed up recovery on the client. #### Technical Note Another note about the implementation is that you'll see that regardless of which case we are in, if there is a postponed state but we consider the page to be Dynamic Data meaning we want to serialize all the HTML and NOT do a resume in the dynamic continuation then we immediately resume the render with and already aborted AbortSignal. The purpose here is to mark any boundaries which have dynamic holes as being client-rendered. As a general rule if the render produces a postponed state we must do one of the following 1. save the postponed state and ensure there is a dynamic continuation that calls resume 2. immediately resume the render and save the concatenated output and ensure the dynamic continuation does NOT call resume. or said another way, every postponed state must be resumed (even if it didn't come from Next's dynamic APIs) #### Perf considerations This PR modifies a few key areas to improve perf. Reduces quantity of *Stream instances where possible as these add significant overhead Reduces extra closures to lower allocations and keep functions in monomorphic form where possible Closes NEXT-2164
574 lines
22 KiB
TypeScript
574 lines
22 KiB
TypeScript
import { createNextDescribe } from 'e2e-utils'
|
|
|
|
async function measure(stream: NodeJS.ReadableStream) {
|
|
let streamFirstChunk = 0
|
|
let streamEnd = 0
|
|
|
|
const data: {
|
|
chunk: string
|
|
emittedAt: number
|
|
}[] = []
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
stream.on('data', (chunk: Buffer | string) => {
|
|
if (!streamFirstChunk) {
|
|
streamFirstChunk = Date.now()
|
|
}
|
|
|
|
if (typeof chunk !== 'string') {
|
|
chunk = chunk.toString()
|
|
}
|
|
|
|
data.push({ chunk, emittedAt: Date.now() })
|
|
})
|
|
|
|
stream.on('end', () => {
|
|
streamEnd = Date.now()
|
|
resolve()
|
|
})
|
|
|
|
stream.on('error', reject)
|
|
})
|
|
|
|
return {
|
|
streamFirstChunk,
|
|
streamEnd,
|
|
data,
|
|
}
|
|
}
|
|
|
|
type Page = {
|
|
pathname: string
|
|
dynamic: boolean | 'force-dynamic' | 'force-static'
|
|
revalidate?: number
|
|
|
|
/**
|
|
* If true, this indicates that the test case should not expect any content
|
|
* to be sent as the static part.
|
|
*/
|
|
emptyStaticPart?: boolean
|
|
}
|
|
|
|
const pages: Page[] = [
|
|
{ pathname: '/', dynamic: true },
|
|
{ pathname: '/nested/a', dynamic: true, revalidate: 60 },
|
|
{ pathname: '/nested/b', dynamic: true, revalidate: 60 },
|
|
{ pathname: '/nested/c', dynamic: true, revalidate: 60 },
|
|
{ pathname: '/on-demand/a', dynamic: true },
|
|
{ pathname: '/on-demand/b', dynamic: true },
|
|
{ pathname: '/on-demand/c', dynamic: true },
|
|
{ pathname: '/loading/a', dynamic: true, revalidate: 60 },
|
|
{ pathname: '/loading/b', dynamic: true, revalidate: 60 },
|
|
{ pathname: '/loading/c', dynamic: true, revalidate: 60 },
|
|
{ pathname: '/static', dynamic: false },
|
|
{ pathname: '/no-suspense', dynamic: true, emptyStaticPart: true },
|
|
{ pathname: '/no-suspense/nested/a', dynamic: true, emptyStaticPart: true },
|
|
{ pathname: '/no-suspense/nested/b', dynamic: true, emptyStaticPart: true },
|
|
{ pathname: '/no-suspense/nested/c', dynamic: true, emptyStaticPart: true },
|
|
{ pathname: '/dynamic/force-dynamic', dynamic: 'force-dynamic' },
|
|
{ pathname: '/dynamic/force-dynamic/nested/a', dynamic: 'force-dynamic' },
|
|
{ pathname: '/dynamic/force-dynamic/nested/b', dynamic: 'force-dynamic' },
|
|
{ pathname: '/dynamic/force-dynamic/nested/c', dynamic: 'force-dynamic' },
|
|
{
|
|
pathname: '/dynamic/force-static',
|
|
dynamic: 'force-static',
|
|
revalidate: 60,
|
|
},
|
|
]
|
|
|
|
createNextDescribe(
|
|
'ppr-full',
|
|
{
|
|
files: __dirname,
|
|
},
|
|
({ next, isNextDev, isNextDeploy }) => {
|
|
describe('HTML Response', () => {
|
|
describe.each(pages)(
|
|
'for $pathname',
|
|
({ pathname, dynamic, revalidate, emptyStaticPart }) => {
|
|
beforeEach(async () => {
|
|
// Hit the page once to populate the cache.
|
|
const res = await next.fetch(pathname)
|
|
|
|
// Consume the response body to ensure the cache is populated.
|
|
await res.text()
|
|
})
|
|
|
|
it('should allow navigations to and from a pages/ page', async () => {
|
|
const browser = await next.browser(pathname)
|
|
|
|
try {
|
|
await browser.waitForElementByCss(`[data-pathname="${pathname}"]`)
|
|
|
|
// Add a window var so we can detect if there was a full navigation.
|
|
const now = Date.now()
|
|
await browser.eval(`window.beforeNav = ${now.toString()}`)
|
|
|
|
// Navigate to the pages page and wait for the page to load.
|
|
await browser.elementByCss(`a[href="/pages"]`).click()
|
|
await browser.waitForElementByCss('[data-pathname="/pages"]')
|
|
|
|
// Ensure we did a full page navigation, and not a client navigation.
|
|
let beforeNav = await browser.eval('window.beforeNav')
|
|
expect(beforeNav).not.toBe(now)
|
|
|
|
await browser.eval(`window.beforeNav = ${now.toString()}`)
|
|
|
|
// Navigate back and wait for the page to load.
|
|
await browser.elementByCss(`a[href="${pathname}"]`).click()
|
|
await browser.waitForElementByCss(`[data-pathname="${pathname}"]`)
|
|
|
|
// Ensure we did a full page navigation, and not a client navigation.
|
|
beforeNav = await browser.eval('window.beforeNav')
|
|
expect(beforeNav).not.toBe(now)
|
|
} finally {
|
|
await browser.close()
|
|
}
|
|
})
|
|
|
|
it('should have correct headers', async () => {
|
|
const res = await next.fetch(pathname)
|
|
expect(res.status).toEqual(200)
|
|
expect(res.headers.get('content-type')).toEqual(
|
|
'text/html; charset=utf-8'
|
|
)
|
|
|
|
const cacheControl = res.headers.get('cache-control')
|
|
if (isNextDeploy) {
|
|
expect(cacheControl).toEqual('public, max-age=0, must-revalidate')
|
|
} else if (isNextDev) {
|
|
expect(cacheControl).toEqual('no-store, must-revalidate')
|
|
} else if (dynamic === false || dynamic === 'force-static') {
|
|
expect(cacheControl).toEqual(
|
|
`s-maxage=${revalidate || '31536000'}, stale-while-revalidate`
|
|
)
|
|
} else {
|
|
expect(cacheControl).toEqual(
|
|
'private, no-cache, no-store, max-age=0, must-revalidate'
|
|
)
|
|
}
|
|
|
|
// The cache header is not relevant in development and is not
|
|
// deterministic enough for this table test to verify.
|
|
if (isNextDev) return
|
|
|
|
if (
|
|
!isNextDeploy &&
|
|
(dynamic === false || dynamic === 'force-static')
|
|
) {
|
|
expect(res.headers.get('x-nextjs-cache')).toEqual('HIT')
|
|
} else {
|
|
expect(res.headers.get('x-nextjs-cache')).toEqual(null)
|
|
}
|
|
})
|
|
|
|
if (dynamic === true) {
|
|
it('should cache the static part', async () => {
|
|
const delay = 500
|
|
|
|
const dynamicValue = `${Date.now()}:${Math.random()}`
|
|
const start = Date.now()
|
|
const res = await next.fetch(pathname, {
|
|
headers: {
|
|
'X-Delay': delay.toString(),
|
|
'X-Test-Input': dynamicValue,
|
|
},
|
|
})
|
|
expect(res.status).toBe(200)
|
|
|
|
const result = await measure(res.body)
|
|
if (emptyStaticPart) {
|
|
expect(result.streamFirstChunk - start).toBeGreaterThanOrEqual(
|
|
delay
|
|
)
|
|
} else {
|
|
expect(result.streamFirstChunk - start).toBeLessThan(delay)
|
|
}
|
|
expect(result.streamEnd - start).toBeGreaterThanOrEqual(delay)
|
|
|
|
// Find all the chunks that arrived before the delay, and split
|
|
// it into the static and dynamic parts.
|
|
const chunks = result.data.reduce(
|
|
(acc, { chunk, emittedAt }) => {
|
|
if (emittedAt < start + delay) {
|
|
acc.static.push(chunk)
|
|
} else {
|
|
acc.dynamic.push(chunk)
|
|
}
|
|
return acc
|
|
},
|
|
{ static: [], dynamic: [] }
|
|
)
|
|
|
|
const parts = {
|
|
static: chunks.static.join(''),
|
|
dynamic: chunks.dynamic.join(''),
|
|
}
|
|
|
|
// The static part should not contain the dynamic input.
|
|
expect(parts.dynamic).toContain(dynamicValue)
|
|
|
|
// Ensure static part contains what we expect.
|
|
if (emptyStaticPart) {
|
|
expect(parts.static).toBe('')
|
|
} else {
|
|
expect(parts.static).toContain('Dynamic Loading...')
|
|
expect(parts.static).not.toContain(dynamicValue)
|
|
}
|
|
})
|
|
}
|
|
|
|
if (dynamic === true || dynamic === 'force-dynamic') {
|
|
it('should resume with dynamic content', async () => {
|
|
const expected = `${Date.now()}:${Math.random()}`
|
|
const res = await next.fetch(pathname, {
|
|
headers: { 'X-Test-Input': expected },
|
|
})
|
|
expect(res.status).toEqual(200)
|
|
expect(res.headers.get('content-type')).toEqual(
|
|
'text/html; charset=utf-8'
|
|
)
|
|
const html = await res.text()
|
|
expect(html).toContain(expected)
|
|
expect(html).not.toContain('MISSING:USER-AGENT')
|
|
expect(html).toContain('</html>')
|
|
})
|
|
} else {
|
|
it('should not contain dynamic content', async () => {
|
|
const unexpected = `${Date.now()}:${Math.random()}`
|
|
const res = await next.fetch(pathname, {
|
|
headers: { 'X-Test-Input': unexpected },
|
|
})
|
|
expect(res.status).toEqual(200)
|
|
expect(res.headers.get('content-type')).toEqual(
|
|
'text/html; charset=utf-8'
|
|
)
|
|
const html = await res.text()
|
|
expect(html).not.toContain(unexpected)
|
|
if (dynamic !== false) {
|
|
expect(html).toContain('MISSING:USER-AGENT')
|
|
expect(html).toContain('MISSING:X-TEST-INPUT')
|
|
}
|
|
expect(html).toContain('</html>')
|
|
})
|
|
}
|
|
}
|
|
)
|
|
})
|
|
|
|
describe('Navigation Signals', () => {
|
|
const delay = 500
|
|
|
|
describe.each([
|
|
{
|
|
signal: 'notFound()' as const,
|
|
pathnames: ['/navigation/not-found', '/navigation/not-found/dynamic'],
|
|
},
|
|
{
|
|
signal: 'redirect()' as const,
|
|
pathnames: ['/navigation/redirect', '/navigation/redirect/dynamic'],
|
|
},
|
|
])('$signal', ({ signal, pathnames }) => {
|
|
describe.each(pathnames)('for %s', (pathname) => {
|
|
it('should have correct headers', async () => {
|
|
const res = await next.fetch(pathname, {
|
|
redirect: 'manual',
|
|
})
|
|
expect(res.status).toEqual(signal === 'redirect()' ? 307 : 404)
|
|
expect(res.headers.get('content-type')).toEqual(
|
|
'text/html; charset=utf-8'
|
|
)
|
|
|
|
if (!isNextDev) {
|
|
expect(res.headers.get('cache-control')).toEqual(
|
|
's-maxage=31536000, stale-while-revalidate'
|
|
)
|
|
}
|
|
|
|
if (signal === 'redirect()') {
|
|
const location = res.headers.get('location')
|
|
expect(typeof location).toEqual('string')
|
|
|
|
// The URL returned in `Location` is absolute, so we need to parse it
|
|
// to get the pathname.
|
|
const url = new URL(location)
|
|
expect(url.pathname).toEqual('/navigation/redirect/location')
|
|
}
|
|
})
|
|
|
|
if (pathname.endsWith('/dynamic')) {
|
|
it('should cache the static part', async () => {
|
|
const start = Date.now()
|
|
const res = await next.fetch(pathname, {
|
|
redirect: 'manual',
|
|
headers: {
|
|
'X-Delay': delay.toString(),
|
|
},
|
|
})
|
|
|
|
const result = await measure(res.body)
|
|
expect(result.streamFirstChunk - start).toBeLessThan(delay)
|
|
|
|
if (isNextDev) {
|
|
// This is because the signal should throw and interrupt the
|
|
// delay timer.
|
|
expect(result.streamEnd - start).toBeGreaterThanOrEqual(delay)
|
|
} else {
|
|
expect(result.streamEnd - start).toBeLessThan(delay)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
})
|
|
})
|
|
|
|
if (!isNextDev) {
|
|
describe('Prefetch RSC Response', () => {
|
|
describe.each(pages)('for $pathname', ({ pathname, revalidate }) => {
|
|
it('should have correct headers', async () => {
|
|
const res = await next.fetch(pathname, {
|
|
headers: { RSC: '1', 'Next-Router-Prefetch': '1' },
|
|
})
|
|
expect(res.status).toEqual(200)
|
|
expect(res.headers.get('content-type')).toEqual('text/x-component')
|
|
|
|
// cache header handling is different when in minimal mode
|
|
const cache = res.headers.get('cache-control')
|
|
if (isNextDeploy) {
|
|
expect(cache).toEqual('public, max-age=0, must-revalidate')
|
|
} else {
|
|
expect(cache).toEqual(
|
|
`s-maxage=${revalidate || '31536000'}, stale-while-revalidate`
|
|
)
|
|
}
|
|
|
|
if (!isNextDeploy) {
|
|
expect(res.headers.get('x-nextjs-cache')).toEqual('HIT')
|
|
} else {
|
|
expect(res.headers.get('x-nextjs-cache')).toEqual(null)
|
|
}
|
|
})
|
|
|
|
it('should not contain dynamic content', async () => {
|
|
const unexpected = `${Date.now()}:${Math.random()}`
|
|
const res = await next.fetch(pathname, {
|
|
headers: {
|
|
RSC: '1',
|
|
'Next-Router-Prefetch': '1',
|
|
'X-Test-Input': unexpected,
|
|
},
|
|
})
|
|
expect(res.status).toEqual(200)
|
|
expect(res.headers.get('content-type')).toEqual('text/x-component')
|
|
const text = await res.text()
|
|
expect(text).not.toContain(unexpected)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Dynamic RSC Response', () => {
|
|
describe.each(pages)('for $pathname', ({ pathname, dynamic }) => {
|
|
it('should have correct headers', async () => {
|
|
const res = await next.fetch(pathname, {
|
|
headers: { RSC: '1' },
|
|
})
|
|
expect(res.status).toEqual(200)
|
|
expect(res.headers.get('content-type')).toEqual('text/x-component')
|
|
expect(res.headers.get('cache-control')).toEqual(
|
|
'private, no-cache, no-store, max-age=0, must-revalidate'
|
|
)
|
|
expect(res.headers.get('x-nextjs-cache')).toEqual(null)
|
|
})
|
|
|
|
if (dynamic === true || dynamic === 'force-dynamic') {
|
|
it('should contain dynamic content', async () => {
|
|
const expected = `${Date.now()}:${Math.random()}`
|
|
const res = await next.fetch(pathname, {
|
|
headers: { RSC: '1', 'X-Test-Input': expected },
|
|
})
|
|
expect(res.status).toEqual(200)
|
|
expect(res.headers.get('content-type')).toEqual(
|
|
'text/x-component'
|
|
)
|
|
const text = await res.text()
|
|
expect(text).toContain(expected)
|
|
})
|
|
} else {
|
|
it('should not contain dynamic content', async () => {
|
|
const unexpected = `${Date.now()}:${Math.random()}`
|
|
const res = await next.fetch(pathname, {
|
|
headers: {
|
|
RSC: '1',
|
|
'X-Test-Input': unexpected,
|
|
},
|
|
})
|
|
expect(res.status).toEqual(200)
|
|
expect(res.headers.get('content-type')).toEqual(
|
|
'text/x-component'
|
|
)
|
|
const text = await res.text()
|
|
expect(text).not.toContain(unexpected)
|
|
})
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('Dynamic Data pages', () => {
|
|
describe('Optimistic UI', () => {
|
|
it('should initially render with optimistic UI', async () => {
|
|
const $ = await next.render$('/dynamic-data?foo=bar')
|
|
|
|
// We defined some server html let's make sure it flushed both in the head
|
|
// There may be additional flushes in the body but we want to ensure that
|
|
// server html is getting inserted in the shell correctly here
|
|
const serverHTML = $('head meta[name="server-html"]')
|
|
expect(serverHTML.length).toEqual(1)
|
|
expect($(serverHTML[0]).attr('content')).toEqual('0')
|
|
|
|
// We expect the server HTML to be the optimistic output
|
|
expect($('#foosearch').text()).toEqual('foo search: optimistic')
|
|
|
|
// We expect hydration to patch up the render with dynamic data
|
|
// from the resume
|
|
const browser = await next.browser('/dynamic-data?foo=bar')
|
|
try {
|
|
await browser.waitForElementByCss('#foosearch')
|
|
expect(
|
|
await browser.eval(
|
|
'document.getElementById("foosearch").textContent'
|
|
)
|
|
).toEqual('foo search: bar')
|
|
} finally {
|
|
await browser.close()
|
|
}
|
|
})
|
|
it('should render entirely statically with force-static', async () => {
|
|
const $ = await next.render$('/dynamic-data/force-static?foo=bar')
|
|
|
|
// We defined some server html let's make sure it flushed both in the head
|
|
// There may be additional flushes in the body but we want to ensure that
|
|
// server html is getting inserted in the shell correctly here
|
|
const serverHTML = $('head meta[name="server-html"]')
|
|
expect(serverHTML.length).toEqual(1)
|
|
expect($(serverHTML[0]).attr('content')).toEqual('0')
|
|
|
|
// We expect the server HTML to be forced static so no params
|
|
// were made available but also nothing threw and was caught for
|
|
// optimistic UI
|
|
expect($('#foosearch').text()).toEqual('foo search: ')
|
|
|
|
// There is no hydration mismatch, we continue to have empty searchParmas
|
|
const browser = await next.browser(
|
|
'/dynamic-data/force-static?foo=bar'
|
|
)
|
|
try {
|
|
await browser.waitForElementByCss('#foosearch')
|
|
expect(
|
|
await browser.eval(
|
|
'document.getElementById("foosearch").textContent'
|
|
)
|
|
).toEqual('foo search: ')
|
|
} finally {
|
|
await browser.close()
|
|
}
|
|
})
|
|
it('should render entirely dynamically when force-dynamic', async () => {
|
|
const $ = await next.render$('/dynamic-data/force-dynamic?foo=bar')
|
|
|
|
// We defined some server html let's make sure it flushed both in the head
|
|
// There may be additional flushes in the body but we want to ensure that
|
|
// server html is getting inserted in the shell correctly here
|
|
const serverHTML = $('head meta[name="server-html"]')
|
|
expect(serverHTML.length).toEqual(1)
|
|
expect($(serverHTML[0]).attr('content')).toEqual('0')
|
|
|
|
// We expect the server HTML to render dynamically
|
|
expect($('#foosearch').text()).toEqual('foo search: bar')
|
|
})
|
|
})
|
|
|
|
describe('Incidental postpones', () => {
|
|
it('should initially render with optimistic UI', async () => {
|
|
const $ = await next.render$(
|
|
'/dynamic-data/incidental-postpone?foo=bar'
|
|
)
|
|
|
|
// We defined some server html let's make sure it flushed both in the head
|
|
// There may be additional flushes in the body but we want to ensure that
|
|
// server html is getting inserted in the shell correctly here
|
|
const serverHTML = $('head meta[name="server-html"]')
|
|
expect(serverHTML.length).toEqual(1)
|
|
expect($(serverHTML[0]).attr('content')).toEqual('0')
|
|
|
|
// We expect the server HTML to be the optimistic output
|
|
expect($('#foosearch').text()).toEqual('foo search: optimistic')
|
|
|
|
// We expect hydration to patch up the render with dynamic data
|
|
// from the resume
|
|
const browser = await next.browser(
|
|
'/dynamic-data/incidental-postpone?foo=bar'
|
|
)
|
|
try {
|
|
await browser.waitForElementByCss('#foosearch')
|
|
expect(
|
|
await browser.eval(
|
|
'document.getElementById("foosearch").textContent'
|
|
)
|
|
).toEqual('foo search: bar')
|
|
} finally {
|
|
await browser.close()
|
|
}
|
|
})
|
|
it('should render entirely statically with force-static', async () => {
|
|
const $ = await next.render$(
|
|
'/dynamic-data/incidental-postpone/force-static?foo=bar'
|
|
)
|
|
|
|
// We defined some server html let's make sure it flushed both in the head
|
|
// There may be additional flushes in the body but we want to ensure that
|
|
// server html is getting inserted in the shell correctly here
|
|
const serverHTML = $('head meta[name="server-html"]')
|
|
expect(serverHTML.length).toEqual(1)
|
|
expect($(serverHTML[0]).attr('content')).toEqual('0')
|
|
|
|
// We expect the server HTML to be forced static so no params
|
|
// were made available but also nothing threw and was caught for
|
|
// optimistic UI
|
|
expect($('#foosearch').text()).toEqual('foo search: ')
|
|
|
|
// There is no hydration mismatch, we continue to have empty searchParmas
|
|
const browser = await next.browser(
|
|
'/dynamic-data/incidental-postpone/force-static?foo=bar'
|
|
)
|
|
try {
|
|
await browser.waitForElementByCss('#foosearch')
|
|
expect(
|
|
await browser.eval(
|
|
'document.getElementById("foosearch").textContent'
|
|
)
|
|
).toEqual('foo search: ')
|
|
} finally {
|
|
await browser.close()
|
|
}
|
|
})
|
|
it('should render entirely dynamically when force-dynamic', async () => {
|
|
const $ = await next.render$(
|
|
'/dynamic-data/incidental-postpone/force-dynamic?foo=bar'
|
|
)
|
|
|
|
// We defined some server html let's make sure it flushed both in the head
|
|
// There may be additional flushes in the body but we want to ensure that
|
|
// server html is getting inserted in the shell correctly here
|
|
const serverHTML = $('head meta[name="server-html"]')
|
|
expect(serverHTML.length).toEqual(1)
|
|
expect($(serverHTML[0]).attr('content')).toEqual('0')
|
|
|
|
// We expect the server HTML to render dynamically
|
|
expect($('#foosearch').text()).toEqual('foo search: bar')
|
|
})
|
|
})
|
|
})
|
|
}
|
|
}
|
|
)
|