diff --git a/errors/gsp-redirect-during-prerender.md b/errors/gsp-redirect-during-prerender.md new file mode 100644 index 00000000000000..964d7bf1db1ff9 --- /dev/null +++ b/errors/gsp-redirect-during-prerender.md @@ -0,0 +1,13 @@ +# Redirect During getStaticProps Prerendering + +#### Why This Error Occurred + +The `redirect` value was returned from `getStaticProps` during prerendering which is invalid. + +#### Possible Ways to Fix It + +Remove any paths that result in a redirect from being prerendered in `getStaticPaths` and enable `fallback: true` to handle redirecting for these pages. + +### Useful Links + +- [Data Fetching Documentation](https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation) diff --git a/errors/invalid-redirect-gssp.md b/errors/invalid-redirect-gssp.md new file mode 100644 index 00000000000000..05db61946d490a --- /dev/null +++ b/errors/invalid-redirect-gssp.md @@ -0,0 +1,32 @@ +# Invalid Redirect getStaticProps/getServerSideProps + +#### Why This Error Occurred + +The `redirect` value returned from your `getStaticProps` or `getServerSideProps` function had invalid values. + +#### Possible Ways to Fix It + +Make sure you return the proper values for the `redirect` value. + +```js +export const getStaticProps = ({ params }) => { + if (params.slug === 'deleted-post') { + return { + redirect: { + permanent: true // or false + destination: '/some-location' + } + } + } + + return { + props: { + // data + } + } +} +``` + +### Useful Links + +- [Data Fetching Documentation](https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation) diff --git a/packages/next/next-server/lib/router/router.ts b/packages/next/next-server/lib/router/router.ts index f77c2519a800ca..0192ace936736e 100644 --- a/packages/next/next-server/lib/router/router.ts +++ b/packages/next/next-server/lib/router/router.ts @@ -678,7 +678,37 @@ export default class Router implements BaseRouter { as, shallow ) - let { error } = routeInfo + let { error, props, __N_SSG, __N_SSP } = routeInfo + + // handle redirect on client-transition + if ( + (__N_SSG || __N_SSP) && + props && + (props as any).pageProps && + (props as any).pageProps.__N_REDIRECT + ) { + const destination = (props as any).pageProps.__N_REDIRECT + + // check if destination is internal (resolves to a page) and attempt + // client-navigation if it is falling back to hard navigation if + // it's not + if (destination.startsWith('/')) { + const parsedHref = parseRelativeUrl(destination) + this._resolveHref(parsedHref, pages) + + if (pages.includes(parsedHref.pathname)) { + return this.change( + 'replaceState', + destination, + destination, + options + ) + } + } + + window.location.href = destination + return new Promise(() => {}) + } Router.events.emit('beforeHistoryChange', as) this.changeState(method, url, as, options) @@ -869,6 +899,7 @@ export default class Router implements BaseRouter { } as any ) ) + routeInfo.props = props this.components[route] = routeInfo return routeInfo diff --git a/packages/next/next-server/server/render.tsx b/packages/next/next-server/server/render.tsx index 5172d948a07a2f..51274610c0d176 100644 --- a/packages/next/next-server/server/render.tsx +++ b/packages/next/next-server/server/render.tsx @@ -21,6 +21,8 @@ import { AMP_RENDER_TARGET, SERVER_PROPS_ID, STATIC_PROPS_ID, + PERMANENT_REDIRECT_STATUS, + TEMPORARY_REDIRECT_STATUS, } from '../lib/constants' import { defaultHead } from '../lib/head' import { HeadManagerContext } from '../lib/head-manager-context' @@ -264,6 +266,46 @@ const invalidKeysMsg = (methodName: string, invalidKeys: string[]) => { ) } +type Redirect = { + permanent: boolean + destination: string +} + +function checkRedirectValues(redirect: Redirect, req: IncomingMessage) { + const { destination, permanent } = redirect + let invalidPermanent = typeof permanent !== 'boolean' + let invalidDestination = typeof destination !== 'string' + + if (invalidPermanent || invalidDestination) { + throw new Error( + `Invalid redirect object returned from getStaticProps for ${req.url}\n` + + `Expected${ + invalidPermanent + ? ` \`permanent\` to be boolean but received ${typeof permanent}` + : '' + }${invalidPermanent && invalidDestination ? ' and' : ''}${ + invalidDestination + ? ` \`destinatino\` to be string but received ${typeof destination}` + : '' + }\n` + + `See more info here: https://err.sh/vercel/next.js/invalid-redirect-gssp` + ) + } +} + +function handleRedirect(res: ServerResponse, redirect: Redirect) { + const statusCode = redirect.permanent + ? PERMANENT_REDIRECT_STATUS + : TEMPORARY_REDIRECT_STATUS + + if (redirect.permanent) { + res.setHeader('Refresh', `0;url=${redirect.destination}`) + } + res.statusCode = statusCode + res.setHeader('Location', redirect.destination) + res.end() +} + export async function renderToHTML( req: IncomingMessage, res: ServerResponse, @@ -534,7 +576,8 @@ export async function renderToHTML( } const invalidKeys = Object.keys(data).filter( - (key) => key !== 'revalidate' && key !== 'props' + (key) => + key !== 'revalidate' && key !== 'props' && key !== 'unstable_redirect' ) if (invalidKeys.includes('unstable_revalidate')) { @@ -545,6 +588,29 @@ export async function renderToHTML( throw new Error(invalidKeysMsg('getStaticProps', invalidKeys)) } + if ( + data.unstable_redirect && + typeof data.unstable_redirect === 'object' + ) { + checkRedirectValues(data.unstable_redirect, req) + + if (isBuildTimeSSG) { + throw new Error( + `\`redirect\` can not be returned from getStaticProps during prerendering (${req.url})\n` + + `See more info here: https://err.sh/next.js/gsp-redirect-during-prerender` + ) + } + + if (isDataReq) { + data.props = { + __N_REDIRECT: data.unstable_redirect.destination, + } + } else { + handleRedirect(res, data.unstable_redirect) + return null + } + } + if ( (dev || isBuildTimeSSG) && !isSerializableProps(pathname, 'getStaticProps', data.props) @@ -623,12 +689,30 @@ export async function renderToHTML( throw new Error(GSSP_NO_RETURNED_VALUE) } - const invalidKeys = Object.keys(data).filter((key) => key !== 'props') + const invalidKeys = Object.keys(data).filter( + (key) => key !== 'props' && key !== 'unstable_redirect' + ) if (invalidKeys.length) { throw new Error(invalidKeysMsg('getServerSideProps', invalidKeys)) } + if ( + data.unstable_redirect && + typeof data.unstable_redirect === 'object' + ) { + checkRedirectValues(data.unstable_redirect, req) + + if (isDataReq) { + data.props = { + __N_REDIRECT: data.unstable_redirect.destination, + } + } else { + handleRedirect(res, data.unstable_redirect) + return null + } + } + if ( (dev || isBuildTimeSSG) && !isSerializableProps(pathname, 'getServerSideProps', data.props) diff --git a/packages/next/types/index.d.ts b/packages/next/types/index.d.ts index e749b4e8872ecf..1c047e889c81be 100644 --- a/packages/next/types/index.d.ts +++ b/packages/next/types/index.d.ts @@ -72,6 +72,11 @@ export { NextApiHandler, } +type Redirect = { + permanent: boolean + destination: string +} + export type GetStaticPropsContext = { params?: Q preview?: boolean @@ -79,8 +84,9 @@ export type GetStaticPropsContext = { } export type GetStaticPropsResult

= { - props: P + props?: P revalidate?: number | boolean + unstable_redirect?: Redirect } export type GetStaticProps< @@ -117,7 +123,8 @@ export type GetServerSidePropsContext< } export type GetServerSidePropsResult

= { - props: P + props?: P + unstable_redirect?: Redirect } export type GetServerSideProps< diff --git a/test/integration/build-output/test/index.test.js b/test/integration/build-output/test/index.test.js index 1ed5de712a17a8..5acf77f1072f2d 100644 --- a/test/integration/build-output/test/index.test.js +++ b/test/integration/build-output/test/index.test.js @@ -95,16 +95,16 @@ describe('Build Output', () => { expect(indexSize.endsWith('B')).toBe(true) // should be no bigger than 60.2 kb - expect(parseFloat(indexFirstLoad) - 60.3).toBeLessThanOrEqual(0) + expect(parseFloat(indexFirstLoad) - 60.4).toBeLessThanOrEqual(0) expect(indexFirstLoad.endsWith('kB')).toBe(true) expect(parseFloat(err404Size) - 3.5).toBeLessThanOrEqual(0) expect(err404Size.endsWith('kB')).toBe(true) - expect(parseFloat(err404FirstLoad) - 63.4).toBeLessThanOrEqual(0) + expect(parseFloat(err404FirstLoad) - 63.6).toBeLessThanOrEqual(0) expect(err404FirstLoad.endsWith('kB')).toBe(true) - expect(parseFloat(sharedByAll) - 60).toBeLessThanOrEqual(0) + expect(parseFloat(sharedByAll) - 60.1).toBeLessThanOrEqual(0) expect(sharedByAll.endsWith('kB')).toBe(true) if (_appSize.endsWith('kB')) { diff --git a/test/integration/gssp-redirect/pages/404.js b/test/integration/gssp-redirect/pages/404.js new file mode 100644 index 00000000000000..18a28da908b9a1 --- /dev/null +++ b/test/integration/gssp-redirect/pages/404.js @@ -0,0 +1,3 @@ +export default function NotFound() { + return

oops not found

+} diff --git a/test/integration/gssp-redirect/pages/another.js b/test/integration/gssp-redirect/pages/another.js new file mode 100644 index 00000000000000..bcafcefbda8281 --- /dev/null +++ b/test/integration/gssp-redirect/pages/another.js @@ -0,0 +1,3 @@ +export default function Another() { + return

another Page

+} diff --git a/test/integration/gssp-redirect/pages/gsp-blog/[post].js b/test/integration/gssp-redirect/pages/gsp-blog/[post].js new file mode 100644 index 00000000000000..18166abd0b48ec --- /dev/null +++ b/test/integration/gssp-redirect/pages/gsp-blog/[post].js @@ -0,0 +1,48 @@ +import { useRouter } from 'next/router' + +export default function Post(props) { + const router = useRouter() + + if (typeof window !== 'undefined' && !window.initialHref) { + window.initialHref = window.location.href + } + + if (router.isFallback) return

Loading...

+ + return ( + <> +

getStaticProps

+

{JSON.stringify(props)}

+ + ) +} + +export const getStaticProps = ({ params }) => { + if (params.post.startsWith('redir')) { + let destination = '/404' + + if (params.post.includes('dest-')) { + destination = params.post.split('dest-').pop().replace(/_/g, '/') + } + + return { + unstable_redirect: { + destination, + permanent: params.post.includes('permanent'), + }, + } + } + + return { + props: { + params, + }, + } +} + +export const getStaticPaths = () => { + return { + paths: ['first', 'second'].map((post) => ({ params: { post } })), + fallback: true, + } +} diff --git a/test/integration/gssp-redirect/pages/gssp-blog/[post].js b/test/integration/gssp-redirect/pages/gssp-blog/[post].js new file mode 100644 index 00000000000000..4c6af348a5564b --- /dev/null +++ b/test/integration/gssp-redirect/pages/gssp-blog/[post].js @@ -0,0 +1,31 @@ +export default function Post(props) { + return ( + <> +

getServerSideProps

+

{JSON.stringify(props)}

+ + ) +} + +export const getServerSideProps = ({ params }) => { + if (params.post.startsWith('redir')) { + let destination = '/404' + + if (params.post.includes('dest-')) { + destination = params.post.split('dest-').pop().replace(/_/g, '/') + } + + return { + unstable_redirect: { + destination, + permanent: params.post.includes('permanent'), + }, + } + } + + return { + props: { + params, + }, + } +} diff --git a/test/integration/gssp-redirect/pages/index.js b/test/integration/gssp-redirect/pages/index.js new file mode 100644 index 00000000000000..f204bab7471200 --- /dev/null +++ b/test/integration/gssp-redirect/pages/index.js @@ -0,0 +1,3 @@ +export default function Index() { + return

Index Page

+} diff --git a/test/integration/gssp-redirect/test/index.test.js b/test/integration/gssp-redirect/test/index.test.js new file mode 100644 index 00000000000000..bdbb842bd51c62 --- /dev/null +++ b/test/integration/gssp-redirect/test/index.test.js @@ -0,0 +1,253 @@ +/* eslint-env jest */ + +import url from 'url' +import fs from 'fs-extra' +import webdriver from 'next-webdriver' +import { join } from 'path' +import { + findPort, + launchApp, + killApp, + nextBuild, + nextStart, + fetchViaHTTP, + check, +} from 'next-test-utils' + +jest.setTimeout(1000 * 60 * 2) +const appDir = join(__dirname, '..') +const nextConfig = join(appDir, 'next.config.js') + +let app +let appPort + +const runTests = () => { + it('should apply temporary redirect when visited directly for GSSP page', async () => { + const res = await fetchViaHTTP( + appPort, + '/gssp-blog/redirect-1', + undefined, + { + redirect: 'manual', + } + ) + expect(res.status).toBe(307) + + const { pathname } = url.parse(res.headers.get('location')) + + expect(pathname).toBe('/404') + }) + + it('should apply permanent redirect when visited directly for GSSP page', async () => { + const res = await fetchViaHTTP( + appPort, + '/gssp-blog/redirect-permanent', + undefined, + { + redirect: 'manual', + } + ) + expect(res.status).toBe(308) + + const { pathname } = url.parse(res.headers.get('location')) + + expect(pathname).toBe('/404') + expect(res.headers.get('refresh')).toMatch(/url=\/404/) + }) + + it('should apply redirect when fallback GSP page is visited directly (internal dynamic)', async () => { + const browser = await webdriver( + appPort, + '/gsp-blog/redirect-dest-_gsp-blog_first' + ) + + await browser.waitForElementByCss('#gsp') + + const props = JSON.parse(await browser.elementByCss('#props').text()) + expect(props).toEqual({ + params: { + post: 'first', + }, + }) + const initialHref = await browser.eval(() => window.initialHref) + const { pathname } = url.parse(initialHref) + expect(pathname).toBe('/gsp-blog/redirect-dest-_gsp-blog_first') + }) + + it('should apply redirect when fallback GSP page is visited directly (internal normal)', async () => { + const browser = await webdriver(appPort, '/gsp-blog/redirect-dest-_') + + await browser.waitForElementByCss('#index') + + const initialHref = await browser.eval(() => window.initialHref) + const { pathname } = url.parse(initialHref) + expect(pathname).toBe('/gsp-blog/redirect-dest-_') + }) + + it('should apply redirect when fallback GSP page is visited directly (external)', async () => { + const browser = await webdriver(appPort, '/gsp-blog/redirect-dest-_missing') + + await check( + () => browser.eval(() => document.documentElement.innerHTML), + /oops not found/ + ) + + const initialHref = await browser.eval(() => window.initialHref) + expect(initialHref).toBe(null) + + const curUrl = await browser.url() + const { pathname } = url.parse(curUrl) + expect(pathname).toBe('/missing') + }) + + it('should apply redirect when GSSP page is navigated to client-side (internal dynamic)', async () => { + const browser = await webdriver( + appPort, + '/gssp-blog/redirect-dest-_gssp-blog_first' + ) + + await browser.waitForElementByCss('#gssp') + + const props = JSON.parse(await browser.elementByCss('#props').text()) + expect(props).toEqual({ + params: { + post: 'first', + }, + }) + }) + + it('should apply redirect when GSSP page is navigated to client-side (internal normal)', async () => { + const browser = await webdriver(appPort, '/') + + await browser.eval(`(function () { + window.next.router.push('/gssp-blog/redirect-dest-_another') + })()`) + await browser.waitForElementByCss('#another') + }) + + it('should apply redirect when GSSP page is navigated to client-side (external)', async () => { + const browser = await webdriver(appPort, '/') + + await browser.eval(`(function () { + window.next.router.push('/gssp-blog/redirect-dest-_gssp-blog_first') + })()`) + await browser.waitForElementByCss('#gssp') + + const props = JSON.parse(await browser.elementByCss('#props').text()) + + expect(props).toEqual({ + params: { + post: 'first', + }, + }) + }) + + it('should apply redirect when GSP page is navigated to client-side (internal)', async () => { + const browser = await webdriver(appPort, '/') + + await browser.eval(`(function () { + window.next.router.push('/gsp-blog/redirect-dest-_another') + })()`) + await browser.waitForElementByCss('#another') + }) + + it('should apply redirect when GSP page is navigated to client-side (external)', async () => { + const browser = await webdriver(appPort, '/') + + await browser.eval(`(function () { + window.next.router.push('/gsp-blog/redirect-dest-_gsp-blog_first') + })()`) + await browser.waitForElementByCss('#gsp') + + const props = JSON.parse(await browser.elementByCss('#props').text()) + + expect(props).toEqual({ + params: { + post: 'first', + }, + }) + }) +} + +describe('GS(S)P Redirect Support', () => { + describe('dev mode', () => { + beforeAll(async () => { + appPort = await findPort() + app = await launchApp(appDir, appPort) + }) + afterAll(() => killApp(app)) + + runTests() + }) + + describe('production mode', () => { + beforeAll(async () => { + await fs.remove(join(appDir, '.next')) + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort) + }) + afterAll(() => killApp(app)) + + runTests() + }) + + describe('serverless mode', () => { + beforeAll(async () => { + await fs.writeFile( + nextConfig, + `module.exports = { + target: 'experimental-serverless-trace' + }` + ) + await fs.remove(join(appDir, '.next')) + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort) + }) + afterAll(async () => { + await fs.remove(nextConfig) + await killApp(app) + }) + + runTests() + }) + + it('should error for redirect during prerendering', async () => { + await fs.mkdirp(join(appDir, 'pages/invalid')) + await fs.writeFile( + join(appDir, 'pages', 'invalid', '[slug].js'), + ` + export default function Post(props) { + return "hi" + } + + export const getStaticProps = ({ params }) => { + return { + unstable_redirect: { + permanent: true, + destination: '/another' + } + } + } + + export const getStaticPaths = () => { + return { + paths: ['first', 'second'].map((slug) => ({ params: { slug } })), + fallback: true, + } + } + ` + ) + const { stdout, stderr } = await nextBuild(appDir, undefined, { + stdout: true, + stderr: true, + }) + const output = stdout + stderr + await fs.remove(join(appDir, 'pages/invalid')) + + expect(output).toContain( + '`redirect` can not be returned from getStaticProps during prerendering' + ) + }) +}) diff --git a/test/integration/size-limit/test/index.test.js b/test/integration/size-limit/test/index.test.js index 464ad0a02be651..6bc2f88f422337 100644 --- a/test/integration/size-limit/test/index.test.js +++ b/test/integration/size-limit/test/index.test.js @@ -80,7 +80,7 @@ describe('Production response size', () => { ) // These numbers are without gzip compression! - const delta = responseSizesBytes - 278 * 1024 + const delta = responseSizesBytes - 279 * 1024 expect(delta).toBeLessThanOrEqual(1024) // don't increase size more than 1kb expect(delta).toBeGreaterThanOrEqual(-1024) // don't decrease size more than 1kb without updating target })