diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index c225847ae8de..d009914cfa8a 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1408,6 +1408,9 @@ export default async function getBaseWebpackConfig( isEdgeServer ? 'edge' : 'nodejs' ), }), + 'process.env.__NEXT_MANUAL_CLIENT_BASE_PATH': JSON.stringify( + config.experimental.manualClientBasePath + ), 'process.env.__NEXT_NEW_LINK_BEHAVIOR': JSON.stringify( config.experimental.newNextLinkBehavior ), diff --git a/packages/next/client/page-loader.ts b/packages/next/client/page-loader.ts index a601d96c285c..62a06a19cf09 100644 --- a/packages/next/client/page-loader.ts +++ b/packages/next/client/page-loader.ts @@ -156,7 +156,8 @@ export default class PageLoader { '.json' ) return addBasePath( - `/_next/data/${this.buildId}${dataRoute}${ssg ? '' : search}` + `/_next/data/${this.buildId}${dataRoute}${ssg ? '' : search}`, + true ) } diff --git a/packages/next/server/config-shared.ts b/packages/next/server/config-shared.ts index b9221879b03e..126dcdb9901a 100644 --- a/packages/next/server/config-shared.ts +++ b/packages/next/server/config-shared.ts @@ -79,6 +79,7 @@ export interface NextJsWebpackConfig { } export interface ExperimentalConfig { + manualClientBasePath?: boolean newNextLinkBehavior?: boolean disablePostcssPresetEnv?: boolean swcMinify?: boolean diff --git a/packages/next/shared/lib/router/router.ts b/packages/next/shared/lib/router/router.ts index d36d38a9ff64..d0e7be8c678f 100644 --- a/packages/next/shared/lib/router/router.ts +++ b/packages/next/shared/lib/router/router.ts @@ -205,12 +205,23 @@ export function hasBasePath(path: string): boolean { return hasPathPrefix(path, basePath) } -export function addBasePath(path: string): string { +export function addBasePath(path: string, required?: boolean): string { + if (process.env.__NEXT_MANUAL_CLIENT_BASE_PATH) { + if (!required) { + return path + } + } // we only add the basepath on relative urls return addPathPrefix(path, basePath) } export function delBasePath(path: string): string { + if (process.env.__NEXT_MANUAL_CLIENT_BASE_PATH) { + if (!hasBasePath(path)) { + return path + } + } + path = path.slice(basePath.length) if (!path.startsWith('/')) path = `/${path}` return path @@ -1120,7 +1131,7 @@ export default class Router implements BaseRouter { if (process.env.__NEXT_HAS_REWRITES && as.startsWith('/')) { const rewritesResult = resolveRewrites( - addBasePath(addLocale(cleanedAs, nextState.locale)), + addBasePath(addLocale(cleanedAs, nextState.locale), true), pages, rewrites, query, @@ -1747,7 +1758,7 @@ export default class Router implements BaseRouter { ;({ __rewrites: rewrites } = await getClientBuildManifest()) const rewritesResult = resolveRewrites( - addBasePath(addLocale(asPath, this.locale)), + addBasePath(addLocale(asPath, this.locale), true), pages, rewrites, parsed.query, diff --git a/test/e2e/manual-client-base-path/app/next.config.js b/test/e2e/manual-client-base-path/app/next.config.js new file mode 100644 index 000000000000..a67cbf980f82 --- /dev/null +++ b/test/e2e/manual-client-base-path/app/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + basePath: '/docs-proxy', + experimental: { + manualClientBasePath: true, + }, +} diff --git a/test/e2e/manual-client-base-path/app/pages/another.js b/test/e2e/manual-client-base-path/app/pages/another.js new file mode 100644 index 000000000000..99d0b25f99cc --- /dev/null +++ b/test/e2e/manual-client-base-path/app/pages/another.js @@ -0,0 +1,50 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' + +export default function Page(props) { + const router = useRouter() + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + return ( + <> +

another page

+

{JSON.stringify(props)}

+

+ {JSON.stringify( + mounted + ? { + basePath: router.basePath, + pathname: router.pathname, + asPath: router.asPath, + query: router.query, + } + : {} + )} +

+ + + to /index + +
+ + + to /dynamic/first + +
+ + ) +} + +export function getServerSideProps() { + return { + props: { + hello: 'world', + now: Date.now(), + }, + } +} diff --git a/test/e2e/manual-client-base-path/app/pages/dynamic/[slug].js b/test/e2e/manual-client-base-path/app/pages/dynamic/[slug].js new file mode 100644 index 000000000000..29d34840f8f2 --- /dev/null +++ b/test/e2e/manual-client-base-path/app/pages/dynamic/[slug].js @@ -0,0 +1,58 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' + +export default function Page(props) { + const router = useRouter() + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + return ( + <> +

dynamic page

+

{JSON.stringify(props)}

+

+ {JSON.stringify( + mounted + ? { + basePath: router.basePath, + pathname: router.pathname, + asPath: router.asPath, + query: router.query, + } + : {} + )} +

+ + + to /index + +
+ + + to /dynamic/second + +
+ + ) +} + +export function getStaticPaths() { + return { + paths: ['/dynamic/first'], + fallback: true, + } +} + +export function getStaticProps({ params }) { + return { + props: { + params, + hello: 'world', + now: Date.now(), + }, + } +} diff --git a/test/e2e/manual-client-base-path/app/pages/index.js b/test/e2e/manual-client-base-path/app/pages/index.js new file mode 100644 index 000000000000..d75da658d634 --- /dev/null +++ b/test/e2e/manual-client-base-path/app/pages/index.js @@ -0,0 +1,41 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' + +export default function Page(props) { + const router = useRouter() + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + return ( + <> +

index page

+

{JSON.stringify(props)}

+

+ {JSON.stringify( + mounted + ? { + basePath: router.basePath, + pathname: router.pathname, + asPath: router.asPath, + query: router.query, + } + : {} + )} +

+ + + to /another + +
+ + + to /dynamic/first + +
+ + ) +} diff --git a/test/e2e/manual-client-base-path/index.test.ts b/test/e2e/manual-client-base-path/index.test.ts new file mode 100644 index 000000000000..212c6842099f --- /dev/null +++ b/test/e2e/manual-client-base-path/index.test.ts @@ -0,0 +1,193 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import httpProxy from 'http-proxy' +import { join } from 'path' +import http from 'http' +import webdriver from 'next-webdriver' +import assert from 'assert' +import { check, waitFor } from 'next-test-utils' + +describe('manual-client-base-path', () => { + let next: NextInstance + let server: http.Server + let appPort: string + const basePath = '/docs-proxy' + const responses = new Set() + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, 'app/pages')), + 'next.config.js': new FileRef(join(__dirname, 'app/next.config.js')), + }, + dependencies: {}, + }) + const getProxyTarget = (req) => { + const destination = new URL(next.url) + const reqUrl = new URL(req.url, 'http://localhost') + // force IPv4 for testing in node 17+ as the default + // switched to favor IPv6 over IPv4 + destination.hostname = '127.0.0.1' + + if (req.url.startsWith(basePath)) { + destination.pathname = reqUrl.pathname || '/' + } else { + destination.pathname = `${basePath}${ + reqUrl.pathname === '/' ? '' : reqUrl.pathname + }` + } + reqUrl.searchParams.forEach((value, key) => { + destination.searchParams.set(key, value) + }) + + console.log('proxying', req.url, 'to:', destination.toString()) + return destination + } + + server = http + .createServer((req, res) => { + responses.add(res) + res.on('close', () => responses.delete(res)) + + const destination = getProxyTarget(req) + const proxy = httpProxy.createProxy({ + changeOrigin: true, + ignorePath: true, + xfwd: true, + proxyTimeout: 30_000, + target: destination.toString(), + }) + + proxy.on('error', (err) => console.error(err)) + proxy.web(req, res) + }) + .listen(0) + + server.on('upgrade', (req, socket, head) => { + responses.add(socket) + socket.on('close', () => responses.delete(socket)) + + const destination = getProxyTarget(req) + const proxy = httpProxy.createProxy({ + changeOrigin: true, + ignorePath: true, + xfwd: true, + proxyTimeout: 30_000, + target: destination.toString(), + }) + + proxy.on('error', (err) => console.error(err)) + proxy.ws(req, socket, head) + }) + + // @ts-ignore type is incorrect + appPort = server.address().port + }) + afterAll(async () => { + await next.destroy() + try { + server.close() + responses.forEach((res: any) => res.end?.() || res.close?.()) + } catch (err) { + console.error(err) + } + }) + + for (const [asPath, pathname, query] of [ + ['/'], + ['/another'], + ['/dynamic/first', '/dynamic/[slug]', { slug: 'first' }], + ['/dynamic/second', '/dynamic/[slug]', { slug: 'second' }], + ]) { + // eslint-disable-next-line + it(`should not update with basePath on mount ${asPath}`, async () => { + const fullAsPath = (asPath as string) + '?update=1' + const browser = await webdriver(appPort, fullAsPath) + await browser.eval('window.beforeNav = 1') + + expect(await browser.eval('window.location.pathname')).toBe(asPath) + expect(await browser.eval('window.location.search')).toBe('?update=1') + + await check(async () => { + assert.deepEqual( + JSON.parse(await browser.elementByCss('#router').text()), + { + asPath: fullAsPath, + pathname: pathname || asPath, + query: { + update: '1', + ...((query as any) || {}), + }, + basePath, + } + ) + return 'success' + }, 'success') + + await waitFor(5 * 1000) + expect(await browser.eval('window.beforeNav')).toBe(1) + }) + } + + it('should navigate correctly from index', async () => { + const browser = await webdriver(appPort, '/') + await browser.eval('window.beforeNav = 1') + + await browser.elementByCss('#to-another').click() + await check(() => browser.elementByCss('#page').text(), 'another page') + expect(await browser.eval('window.location.pathname')).toBe('/another') + + await browser.back() + await check(() => browser.elementByCss('#page').text(), 'index page') + expect(await browser.eval('window.location.pathname')).toBe('/') + + await browser.forward() + await check(() => browser.elementByCss('#page').text(), 'another page') + expect(await browser.eval('window.location.pathname')).toBe('/another') + + await browser.back() + await check(() => browser.elementByCss('#page').text(), 'index page') + expect(await browser.eval('window.location.pathname')).toBe('/') + + await browser.elementByCss('#to-dynamic').click() + await check(() => browser.elementByCss('#page').text(), 'dynamic page') + expect(await browser.eval('window.location.pathname')).toBe( + '/dynamic/first' + ) + + await browser.back() + await check(() => browser.elementByCss('#page').text(), 'index page') + expect(await browser.eval('window.location.pathname')).toBe('/') + + await browser.forward() + await check(() => browser.elementByCss('#page').text(), 'dynamic page') + expect(await browser.eval('window.location.pathname')).toBe( + '/dynamic/first' + ) + + expect(await browser.eval('window.beforeNav')).toBe(1) + }) + + it('should navigate correctly from another', async () => { + const browser = await webdriver(appPort, '/another') + await browser.eval('window.beforeNav = 1') + + await browser.elementByCss('#to-index').click() + await check(() => browser.elementByCss('#page').text(), 'index page') + expect(await browser.eval('window.location.pathname')).toBe('/') + + await browser.elementByCss('#to-dynamic').click() + await check(() => browser.elementByCss('#page').text(), 'dynamic page') + expect(await browser.eval('window.location.pathname')).toBe( + '/dynamic/first' + ) + + await browser.elementByCss('#to-dynamic').click() + await check( + () => browser.eval('window.location.pathname'), + '/dynamic/second' + ) + + expect(await browser.eval('window.beforeNav')).toBe(1) + }) +})