mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
## Summary Client-handled Server Action redirects currently return a `303` without a `Location` header and may include a streamed RSC response body. Some HTTP intermediaries reject this response or strip its body because it looks like an HTTP redirect. Return `200` for client-handled Server Action redirects while continuing to use `x-action-redirect` and the RSC response. Progressive-enhancement form submissions still return a standards-compliant `303` with `Location`. Fixes #92882 Fixes #74026 ## Verification - `HEADLESS=true pnpm test-dev-turbo test/e2e/app-dir/actions/app-action.test.ts test/e2e/app-dir/app-basepath/index.test.ts test/e2e/app-dir/actions/app-action-progressive-enhancement.test.ts` - `HEADLESS=true pnpm test-start-turbo test/e2e/app-dir/actions/app-action.test.ts test/e2e/app-dir/app-basepath/index.test.ts test/e2e/app-dir/actions/app-action-progressive-enhancement.test.ts` - `pnpm --filter=next types` <!-- NEXT_JS_LLM -->
221 lines
7.2 KiB
TypeScript
221 lines
7.2 KiB
TypeScript
import { nextTestSetup } from 'e2e-utils'
|
|
import { retry } from 'next-test-utils'
|
|
import type { Request, Response } from 'playwright'
|
|
|
|
describe('app dir - basepath', () => {
|
|
const { next, isNextDev } = nextTestSetup({
|
|
files: __dirname,
|
|
dependencies: {
|
|
sass: 'latest',
|
|
},
|
|
})
|
|
|
|
it('should successfully hard navigate from pages -> app', async () => {
|
|
const browser = await next.browser('/base/pages-path')
|
|
await browser.elementByCss('#to-another').click()
|
|
// Hard nav from pages -> app triggers on-demand compilation in dev; allow extra time
|
|
await browser.waitForElementByCss('#page-2', 30000)
|
|
})
|
|
|
|
it('should support `basePath`', async () => {
|
|
const html = await next.render('/base')
|
|
expect(html).toContain('<h1>Test Page</h1>')
|
|
})
|
|
|
|
it('should support Link with basePath prefixed', async () => {
|
|
const browser = await next.browser('/base')
|
|
expect(
|
|
await browser
|
|
.elementByCss('a[href="/base/another"]')
|
|
.click()
|
|
.waitForElementByCss('#page-2')
|
|
.text()
|
|
).toBe(`Page 2`)
|
|
})
|
|
|
|
it('should prefix segment metadata og image with basePath and pathname', async () => {
|
|
const $ = await next.render$('/base/metadata')
|
|
const ogImageHref = $('meta[property="og:image"]').attr('content')
|
|
expect(ogImageHref).toContain('/base/metadata/opengraph-image.png')
|
|
})
|
|
|
|
it('should prefix manifest with basePath', async () => {
|
|
const $ = await next.render$('/base/metadata')
|
|
const manifestHref = $('link[rel="manifest"]').attr('href')
|
|
expect(manifestHref).toContain('/base/manifest.webmanifest')
|
|
})
|
|
|
|
it('should prefix redirect() with basePath', async () => {
|
|
const browser = await next.browser('/base/redirect')
|
|
await retry(async () => {
|
|
expect(await browser.url()).toBe(`${next.url}/base/another`)
|
|
})
|
|
})
|
|
|
|
it('should render usePathname without the basePath', async () => {
|
|
const pathnames = ['/use-pathname', '/use-pathname-another']
|
|
const validatorPromises = pathnames.map(async (pathname) => {
|
|
const $ = await next.render$('/base' + pathname)
|
|
expect($('#pathname').data('pathname')).toBe(pathname)
|
|
})
|
|
await Promise.all(validatorPromises)
|
|
})
|
|
|
|
it('should handle redirect in dynamic in suspense boundary routes with basePath', async () => {
|
|
const browser = await next.browser('/base/dynamic/source')
|
|
await retry(async () => {
|
|
// Check content is loaded first to avoid flakiness
|
|
expect(await browser.elementByCss('p').text()).toBe(`id:dest`)
|
|
expect(await browser.url()).toBe(`${next.url}/base/dynamic/dest`)
|
|
})
|
|
})
|
|
|
|
it.each(['/base/refresh', '/base/refresh?foo=bar'])(
|
|
`should only make a single RSC call to the current page (%s)`,
|
|
async (path) => {
|
|
let rscRequests = []
|
|
const browser = await next.browser(path, {
|
|
beforePageLoad(page) {
|
|
page.on('request', (request) => {
|
|
const headers = request.headers()
|
|
if (
|
|
headers['rsc'] === '1' &&
|
|
// Prefetches also include `rsc`
|
|
headers['next-router-prefetch'] !== '1'
|
|
) {
|
|
rscRequests.push(request.url())
|
|
}
|
|
})
|
|
},
|
|
})
|
|
|
|
await browser.elementByCss('button').click()
|
|
await browser.waitForIdleNetwork()
|
|
await retry(async () => {
|
|
expect(rscRequests).toEqual([
|
|
expect.stringContaining(`${next.url}${path}`),
|
|
])
|
|
})
|
|
}
|
|
)
|
|
|
|
it.each([
|
|
{ redirectType: 'relative', buttonId: 'redirect-relative' },
|
|
{ redirectType: 'absolute', buttonId: 'redirect-absolute-internal' },
|
|
])(
|
|
`should properly stream an internal server action redirect() with a $redirectType URL`,
|
|
async ({ buttonId }) => {
|
|
const initialPagePath = '/base/client'
|
|
const destinationPagePath = '/base/another'
|
|
|
|
const requests: Request[] = []
|
|
const responses: Response[] = []
|
|
|
|
const browser = await next.browser(initialPagePath)
|
|
|
|
browser.on('request', (req) => {
|
|
const url = req.url()
|
|
|
|
if (
|
|
url.includes(initialPagePath) ||
|
|
url.includes(destinationPagePath)
|
|
) {
|
|
requests.push(req)
|
|
}
|
|
})
|
|
|
|
browser.on('response', (res) => {
|
|
const url = res.url()
|
|
|
|
if (
|
|
url.includes(initialPagePath) ||
|
|
url.includes(destinationPagePath)
|
|
) {
|
|
responses.push(res)
|
|
}
|
|
})
|
|
|
|
await browser.elementById(buttonId).click()
|
|
await retry(async () =>
|
|
expect(await browser.url()).toContain('/base/another')
|
|
)
|
|
|
|
expect(await browser.waitForElementByCss('#page-2').text()).toBe(`Page 2`)
|
|
|
|
// This verifies the redirect & server response happens in a single roundtrip,
|
|
// if the redirect resource was static. In development, these responses are always
|
|
// dynamically generated, so we only expect a single request for build/deploy.
|
|
// TODO(client-segment-cache): re-enable when this optimization is added back
|
|
if (!isNextDev && !process.env.__NEXT_CACHE_COMPONENTS) {
|
|
expect(requests).toHaveLength(1)
|
|
expect(responses).toHaveLength(1)
|
|
}
|
|
|
|
const request = requests[0]
|
|
const response = responses[0]
|
|
|
|
expect(request.url()).toEqual(`${next.url}${initialPagePath}`)
|
|
expect(request.method()).toEqual('POST')
|
|
expect(response.status()).toEqual(200)
|
|
|
|
const headers = await response.allHeaders()
|
|
expect(headers['x-action-redirect']).toBeDefined()
|
|
expect(headers.location).toBeUndefined()
|
|
expect(headers['content-type']).toContain('text/x-component')
|
|
}
|
|
)
|
|
|
|
it('should redirect externally when encountering absolute URLs on the same host outside the basePath', async () => {
|
|
const initialPagePath = '/base/client'
|
|
const destinationPagePath = '/outsideBasePath'
|
|
|
|
const requests: Request[] = []
|
|
const responses: Response[] = []
|
|
|
|
const browser = await next.browser(initialPagePath)
|
|
|
|
browser.on('request', (req) => {
|
|
const url = req.url()
|
|
|
|
if (!url.includes('_next')) {
|
|
requests.push(req)
|
|
}
|
|
})
|
|
|
|
browser.on('response', (res) => {
|
|
const url = res.url()
|
|
|
|
if (!url.includes('_next')) {
|
|
responses.push(res)
|
|
}
|
|
})
|
|
|
|
await browser.elementById('redirect-absolute-external').click()
|
|
await retry(async () =>
|
|
expect(await browser.url()).toContain('/outsideBasePath')
|
|
)
|
|
|
|
// We expect to see two requests, first a POST invoking the server
|
|
// action. And second a GET request resolving the redirect.
|
|
expect(requests).toHaveLength(2)
|
|
expect(responses).toHaveLength(2)
|
|
|
|
const [firstRequest, secondRequest] = requests
|
|
const [firstResponse, secondResponse] = responses
|
|
|
|
expect(firstRequest.url()).toEqual(`${next.url}${initialPagePath}`)
|
|
expect(firstRequest.method()).toEqual('POST')
|
|
|
|
expect(secondRequest.url()).toEqual(`${next.url}${destinationPagePath}`)
|
|
expect(secondRequest.method()).toEqual('GET')
|
|
|
|
expect(firstResponse.status()).toEqual(200)
|
|
const headers = await firstResponse.allHeaders()
|
|
expect(headers['x-action-redirect']).toBeDefined()
|
|
expect(headers.location).toBeUndefined()
|
|
// Since this is an external request to a resource outside of NextJS
|
|
// we expect to see a separate request resolving the external URL.
|
|
expect(secondResponse.status()).toEqual(200)
|
|
})
|
|
})
|