Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { getSentinelValue } from './sentinel'

export async function LastModified({ params }) {
const { slug } = await params

return (
<p data-testid={`page-${slug}`}>
Page /{slug} last modified: {new Date().toISOString()}
<p id="last-modified">
Page /{slug} last modified: {new Date().toISOString()} (
{getSentinelValue()})
</p>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ export default async function Layout({ children }) {
return (
<html>
<body>
<div data-testid={`layout-${getSentinelValue()}`}>
Layout: {new Date().toISOString()}
<div id="layout">
Layout: {new Date().toISOString()} ({getSentinelValue()})
</div>
<Suspense fallback={<p>Loading...</p>}>{children}</Suspense>
</body>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ export default async function Layout({ children }) {
return (
<html>
<body>
<div data-testid={`layout-${getSentinelValue()}`}>
Layout: {new Date().toISOString()}
<div id="layout">
Layout: {new Date().toISOString()} ({getSentinelValue()})
</div>
{children}
</body>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export default async function Page({ params }) {
const { slug } = await params
return <div data-testid={`hello-${slug}`}>Hello /{slug}</div>
return <div id="slug">Hello /{slug}</div>
}

export async function generateStaticParams() {
Expand Down
139 changes: 78 additions & 61 deletions test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,93 +7,95 @@ describe('empty-fallback-shells', () => {

describe('without IO', () => {
it('should start and not postpone the response', async () => {
const res = await next.fetch('/without-io/world')
const html = await res.text()
expect(html).toContain('hello-world')
const { browser, response } =
await next.browserWithResponse('/without-io/world')

expect(await browser.elementById('slug').text()).toBe('Hello /world')
const headers = response.headers()

if (isNextDeploy) {
expect(res.headers.get('x-matched-path')).toBe('/without-io/[slug]')
expect(headers['x-matched-path']).toBe('/without-io/[slug]')
}

// If we didn't use the fallback shell, then we didn't postpone the
// response and therefore shouldn't have sent the postponed header.
expect(res.headers.get('x-nextjs-postponed')).not.toBe('1')
expect(headers['x-nextjs-postponed']).not.toBe('1')
})
})

describe('with cached IO', () => {
describe('and the page wrapped in Suspense', () => {
describe('and the params accessed in the cached page', () => {
it('resumes a postponed fallback shell', async () => {
const res = await next.fetch(
const { browser, response } = await next.browserWithResponse(
'/with-cached-io/with-suspense/params-in-page/bar'
)

const html = await res.text()
expect(html).toContain('page-bar')
const lastModified = await browser.elementById('last-modified').text()
expect(lastModified).toInclude('Page /bar')
expect(lastModified).toInclude('runtime')

if (isNextDev) {
expect(html).toContain('layout-runtime')
} else {
expect(html).toContain('layout-buildtime')
}
const layout = await browser.elementById('layout').text()
expect(layout).toInclude(isNextDev ? 'runtime' : 'buildtime')

const headers = response.headers()

if (isNextDeploy) {
expect(res.headers.get('x-matched-path')).toBe(
expect(headers['x-matched-path']).toBe(
'/with-cached-io/with-suspense/params-in-page/[slug]'
)
} else if (isNextStart) {
expect(res.headers.get('x-nextjs-postponed')).toBe('1')
expect(headers['x-nextjs-postponed']).toBe('1')
}
})
})

describe('and the params accessed in cached non-page function', () => {
it('resumes a postponed fallback shell', async () => {
const res = await next.fetch(
const { browser, response } = await next.browserWithResponse(
'/with-cached-io/with-suspense/params-not-in-page/bar'
)

const html = await res.text()
expect(html).toContain('page-bar')
const lastModified = await browser.elementById('last-modified').text()
expect(lastModified).toInclude('Page /bar')
expect(lastModified).toInclude('runtime')

if (isNextDev) {
expect(html).toContain('layout-runtime')
} else {
expect(html).toContain('layout-buildtime')
}
const layout = await browser.elementById('layout').text()
expect(layout).toInclude(isNextDev ? 'runtime' : 'buildtime')

const headers = response.headers()

if (isNextDeploy) {
expect(res.headers.get('x-matched-path')).toBe(
expect(headers['x-matched-path']).toBe(
'/with-cached-io/with-suspense/params-not-in-page/[slug]'
)
} else if (isNextStart) {
expect(res.headers.get('x-nextjs-postponed')).toBe('1')
expect(headers['x-nextjs-postponed']).toBe('1')
}
})
})

describe('and params.then/catch/finally passed to a cached function', () => {
it('resumes a postponed fallback shell', async () => {
const res = await next.fetch(
const { browser, response } = await next.browserWithResponse(
'/with-cached-io/with-suspense/params-then-in-page/bar'
)

const html = await res.text()
expect(html).toIncludeRepeated('data-testid="page-bar"', 4)
const lastModified = await browser.elementById('last-modified').text()
expect(lastModified).toInclude('Page /bar')
expect(lastModified).toInclude('runtime')

if (isNextDev) {
expect(html).toContain('layout-runtime')
} else {
expect(html).toContain('layout-buildtime')
}
const layout = await browser.elementById('layout').text()
expect(layout).toInclude(isNextDev ? 'runtime' : 'buildtime')

const headers = response.headers()

if (isNextDeploy) {
expect(res.headers.get('x-matched-path')).toBe(
expect(headers['x-matched-path']).toBe(
'/with-cached-io/with-suspense/params-then-in-page/[slug]'
)
} else if (isNextStart) {
expect(res.headers.get('x-nextjs-postponed')).toBe('1')
expect(headers['x-nextjs-postponed']).toBe('1')
}
})
})
Expand All @@ -102,77 +104,92 @@ describe('empty-fallback-shells', () => {
describe('and the page not wrapped in Suspense', () => {
describe('and the params accessed in the cached page', () => {
it('does not resume a postponed fallback shell', async () => {
const res = await next.fetch(
const { browser, response } = await next.browserWithResponse(
'/with-cached-io/without-suspense/params-in-page/bar'
)

const html = await res.text()
expect(html).toContain('page-bar')
expect(html).toContain('layout-runtime')
const lastModified = await browser.elementById('last-modified').text()
expect(lastModified).toInclude('Page /bar')
expect(lastModified).toInclude('runtime')

const layout = await browser.elementById('layout').text()
expect(layout).toInclude('runtime')

const headers = response.headers()

if (isNextDeploy) {
expect(res.headers.get('x-matched-path')).toBe(
expect(headers['x-matched-path']).toBe(
'/with-cached-io/without-suspense/params-in-page/[slug]'
)
} else {
expect(res.headers.get('x-nextjs-postponed')).not.toBe('1')
} else if (isNextStart) {
expect(headers['x-nextjs-postponed']).not.toBe('1')
}
})

it('does not render a fallback shell when using a params placeholder', async () => {
// This should trigger a blocking prerender of the route shell.
const res = await next.fetch(
const { browser, response } = await next.browserWithResponse(
'/with-cached-io/without-suspense/params-in-page/[slug]'
)

expect(res.status).toBe(200)

const html = await res.text()
expect(response.status()).toBe(200)

// This should render the encoded param in the route shell, and not
// interpret the param as a fallback param, and subsequently try to
// render the fallback shell instead, which would fail because of the
// missing parent suspense boundary.
expect(html).toContain('page-%5Bslug%5D')
const lastModified = await browser.elementById('last-modified').text()
expect(lastModified).toInclude('Page /%5Bslug%5D')
expect(lastModified).toInclude('runtime')
})
})

describe('and the params accessed in a cached non-page function', () => {
it('does not resume a postponed fallback shell', async () => {
const res = await next.fetch(
const { browser, response } = await next.browserWithResponse(
'/with-cached-io/without-suspense/params-not-in-page/bar'
)

const html = await res.text()
expect(html).toContain('page-bar')
expect(html).toContain('layout-runtime')
const lastModified = await browser.elementById('last-modified').text()
expect(lastModified).toInclude('Page /bar')
expect(lastModified).toInclude('runtime')

const layout = await browser.elementById('layout').text()
expect(layout).toInclude('runtime')

const headers = response.headers()

if (isNextDeploy) {
expect(res.headers.get('x-matched-path')).toBe(
expect(headers['x-matched-path']).toBe(
'/with-cached-io/without-suspense/params-not-in-page/[slug]'
)
} else {
expect(res.headers.get('x-nextjs-postponed')).not.toBe('1')
} else if (isNextStart) {
expect(headers['x-nextjs-postponed']).not.toBe('1')
}
})
})

describe('and params.then/catch/finally passed to a cached function', () => {
it('does not resume a postponed fallback shell', async () => {
const res = await next.fetch(
const { browser, response } = await next.browserWithResponse(
'/with-cached-io/without-suspense/params-then-in-page/bar'
)

const html = await res.text()
expect(html).toIncludeRepeated('data-testid="page-bar"', 4)
expect(html).toContain('layout-runtime')
const lastModified = await browser.elementById('last-modified').text()
expect(lastModified).toInclude('Page /bar')
expect(lastModified).toInclude('runtime')

const layout = await browser.elementById('layout').text()
expect(layout).toInclude('runtime')

const headers = response.headers()

if (isNextDeploy) {
expect(res.headers.get('x-matched-path')).toBe(
expect(headers['x-matched-path']).toBe(
'/with-cached-io/without-suspense/params-then-in-page/[slug]'
)
} else {
expect(res.headers.get('x-nextjs-postponed')).not.toBe('1')
} else if (isNextStart) {
expect(headers['x-nextjs-postponed']).not.toBe('1')
}
})
})
Expand Down
47 changes: 46 additions & 1 deletion test/lib/next-modes/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import cheerio from 'cheerio'
import { once } from 'events'
import { Playwright } from 'next-webdriver'
import escapeStringRegexp from 'escape-string-regexp'
import { Page, Response } from 'playwright'

type Event = 'stdout' | 'stderr' | 'error' | 'destroy'
export type InstallCommand =
Expand Down Expand Up @@ -664,14 +665,58 @@ export class NextInstance {
}

/**
* Create new browser window for the Next.js app.
* Create a new browser window for the Next.js app.
*/
public async browser(
...args: Parameters<OmitFirstArgument<typeof webdriver>>
): Promise<Playwright> {
return webdriver(this.url, ...args)
}

/**
* Create a new browser window for the Next.js app, and also return the page's
* response.
*/
public async browserWithResponse(
...args: Parameters<OmitFirstArgument<typeof webdriver>>
): Promise<{ browser: Playwright; response: Response }> {
console.log('browserWithResponse', args)
Comment thread
unstubbable marked this conversation as resolved.
Outdated
const [url, options = {}] = args

let resolveResponse: (response: Response) => void

const responsePromise = new Promise<Response>((resolve, reject) => {
const timer = setTimeout(() => {
reject(`Timed out waiting for the response of ${url}`)
}, 10_000)

resolveResponse = (response: Response) => {
clearTimeout(timer)
resolve(response)
}
})

const absoluteUrl = new URL(url, this.url).href

const [browser, response] = await Promise.all([
webdriver(this.url, url, {
...options,
beforePageLoad(page: Page) {
options.beforePageLoad?.(page)

page.on('response', async (response) => {
if (response.url() === absoluteUrl) {
resolveResponse(response)
}
})
},
}),
responsePromise,
])

return { browser, response }
}

/**
* Fetch the HTML for the provided page. This is a shortcut for `renderViaHTTP().then(html => cheerio.load(html))`.
*/
Expand Down