diff --git a/packages/next-server/server/normalize-page-path.ts b/packages/next-server/server/normalize-page-path.ts index 1102a4ec91aa8..cefded6db67c4 100644 --- a/packages/next-server/server/normalize-page-path.ts +++ b/packages/next-server/server/normalize-page-path.ts @@ -8,6 +8,8 @@ export function normalizePagePath(page: string): string { if (page[0] !== '/') { page = `/${page}` } + // Strip trailing slash + page = page.replace(/\/$/, '') // Throw when using ../ etc in the pathname const resolvedPage = posix.normalize(page) if (page !== resolvedPage) { diff --git a/packages/next-server/server/require.ts b/packages/next-server/server/require.ts index 5c6e46e48431a..543e88a99dcf4 100644 --- a/packages/next-server/server/require.ts +++ b/packages/next-server/server/require.ts @@ -20,11 +20,13 @@ export function getPagePath(page: string, distDir: string): string { throw pageNotFoundError(page) } - if (!pagesManifest[page]) { + const buildPath = pagesManifest[page] || pagesManifest[page.replace(/\/index$/, '')] + + if (!buildPath) { throw pageNotFoundError(page) } - return join(serverBuildPath, pagesManifest[page]) + return join(serverBuildPath, buildPath) } export function requirePage(page: string, distDir: string): any { diff --git a/packages/next/pages/_document.js b/packages/next/pages/_document.js index 8638e76f86afa..c4968a22279cf 100644 --- a/packages/next/pages/_document.js +++ b/packages/next/pages/_document.js @@ -447,5 +447,5 @@ function getPagePathname(page) { return '/index.js' } - return `${page}.js` + return `${page.replace(/\/$/, '')}.js` } diff --git a/packages/next/server/on-demand-entry-handler.js b/packages/next/server/on-demand-entry-handler.js index c03ce45eaa842..767448e15b5b9 100644 --- a/packages/next/server/on-demand-entry-handler.js +++ b/packages/next/server/on-demand-entry-handler.js @@ -366,7 +366,7 @@ export function normalizePage (page) { if (unixPagePath === '/index' || unixPagePath === '/') { return '/' } - return unixPagePath.replace(/\/index$/, '') + return unixPagePath.replace(/\/(?:index)?$/, '') } // Make sure only one invalidation happens at a time diff --git a/test/integration/basic/pages/nav/trailing-slash-link.js b/test/integration/basic/pages/nav/trailing-slash-link.js new file mode 100644 index 0000000000000..df3292973e947 --- /dev/null +++ b/test/integration/basic/pages/nav/trailing-slash-link.js @@ -0,0 +1,11 @@ +import Link from 'next/link' + +export default () => ( +
+ + Go Back + + +

This page has a link to an index.js page, called with a trailing /.

+
+) diff --git a/test/integration/basic/pages/resolution/index.js b/test/integration/basic/pages/resolution/index.js new file mode 100644 index 0000000000000..8b430f6d94593 --- /dev/null +++ b/test/integration/basic/pages/resolution/index.js @@ -0,0 +1,24 @@ +import Link from 'next/link' + +export default () => ( +
+ + subfolder 1 + + + subfolder 1 with slash + + + subfolder 1 with index + + + subfolder 2 + + + subfolder 2 with slash + + + subfolder 2 with index + +
+) diff --git a/test/integration/basic/pages/resolution/subfolder1.js b/test/integration/basic/pages/resolution/subfolder1.js new file mode 100644 index 0000000000000..0cc98991622cb --- /dev/null +++ b/test/integration/basic/pages/resolution/subfolder1.js @@ -0,0 +1,5 @@ +export default () => ( +
+

This is subfolder1.js

+
+) diff --git a/test/integration/basic/pages/resolution/subfolder1/index.js b/test/integration/basic/pages/resolution/subfolder1/index.js new file mode 100644 index 0000000000000..4c7bbe50e5cbc --- /dev/null +++ b/test/integration/basic/pages/resolution/subfolder1/index.js @@ -0,0 +1,5 @@ +export default () => ( +
+

This is subfolder1/index.js

+
+) diff --git a/test/integration/basic/pages/resolution/subfolder2/index.js b/test/integration/basic/pages/resolution/subfolder2/index.js new file mode 100644 index 0000000000000..e26c310a20144 --- /dev/null +++ b/test/integration/basic/pages/resolution/subfolder2/index.js @@ -0,0 +1,5 @@ +export default () => ( +
+

This is subfolder2/index.js

+
+) diff --git a/test/integration/basic/test/client-navigation.js b/test/integration/basic/test/client-navigation.js index 1da6162e2c45a..c31f7a8e29ae8 100644 --- a/test/integration/basic/test/client-navigation.js +++ b/test/integration/basic/test/client-navigation.js @@ -70,6 +70,17 @@ export default (context) => { expect(counterText).toBe('Counter: 1') browser.close() }) + + it('should navigate the page when href has trailing slash', async () => { + const browser = await webdriver(context.appPort, '/nav/trailing-slash-link') + const text = await browser + .elementByCss('#home-link').click() + .waitForElementByCss('.nav-home') + .elementByCss('p').text() + + expect(text).toBe('This is the home.') + browser.quit() + }) }) describe('with unexpected nested tag', () => { @@ -719,10 +730,13 @@ export default (context) => { browser.close() }) - it('should 404 for /', async () => { - const browser = await webdriver(context.appPort, '/nav/about/') - expect(await browser.elementByCss('h1').text()).toBe('404') - expect(await browser.elementByCss('h2').text()).toBe('This page could not be found.') + it('should not 404 for /', async () => { + const browser = await webdriver(context.appPort, '/nav/') + const text = await browser + .waitForElementByCss('.nav-home') + .elementByCss('p').text() + + expect(text).toBe('This is the home.') browser.close() }) diff --git a/test/integration/basic/test/index.test.js b/test/integration/basic/test/index.test.js index 0c01551989182..d7b25166ab6f3 100644 --- a/test/integration/basic/test/index.test.js +++ b/test/integration/basic/test/index.test.js @@ -16,6 +16,7 @@ import hmr from './hmr' import errorRecovery from './error-recovery' import dynamic from './dynamic' import processEnv from './process-env' +import resolution from './resolution' const context = {} jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 5 @@ -72,4 +73,5 @@ describe('Basic Features', () => { hmr(context, (p, q) => renderViaHTTP(context.appPort, p, q)) errorRecovery(context, (p, q) => renderViaHTTP(context.appPort, p, q)) processEnv(context) + resolution(context) }) diff --git a/test/integration/basic/test/rendering.js b/test/integration/basic/test/rendering.js index 43cbf11878210..84cf9d8233843 100644 --- a/test/integration/basic/test/rendering.js +++ b/test/integration/basic/test/rendering.js @@ -259,13 +259,14 @@ export default function ({ app }, suiteName, render, fetch) { expect($('h2').text()).toBe('This page could not be found.') }) - it('should 404 for /', async () => { + it('should not 404 for /', async () => { const $ = await get$('/nav/about/') - expect($('h1').text()).toBe('404') - expect($('h2').text()).toBe('This page could not be found.') + expect($('p').text()).toBe('This is the about page.') + // Make sure "about.js" got included and not "about/.js" + expect($('script[src*="/nav/about.js"]').length).toBe(1) }) - it('should should not contain a page script in a 404 page', async () => { + it('should not contain a page script in a 404 page', async () => { const $ = await get$('/non-existent') $('script[src]').each((index, element) => { const src = $(element).attr('src') diff --git a/test/integration/basic/test/resolution.js b/test/integration/basic/test/resolution.js new file mode 100644 index 0000000000000..2af6fcef260c9 --- /dev/null +++ b/test/integration/basic/test/resolution.js @@ -0,0 +1,137 @@ +/* eslint-env jest */ + +import webdriver from 'next-webdriver' + +export default (context) => { + describe('page resolution', () => { + describe('client side', () => { + it('should resolve to js file when no slash', async () => { + const browser = await webdriver(context.appPort, '/resolution') + const text = await browser + .elementByCss('#subfolder1').click() + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder1.js') + browser.quit() + }) + + it('should resolve to folder index when slash', async () => { + const browser = await webdriver(context.appPort, '/resolution') + const text = await browser + .elementByCss('#subfolder1-slash').click() + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder1/index.js') + browser.quit() + }) + + it('should resolve to folder index when index', async () => { + const browser = await webdriver(context.appPort, '/resolution') + const text = await browser + .elementByCss('#subfolder1-index').click() + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder1/index.js') + browser.quit() + }) + + it('should resolve to folder when no slash and no js file', async () => { + const browser = await webdriver(context.appPort, '/resolution') + const text = await browser + .elementByCss('#subfolder2').click() + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder2/index.js') + browser.quit() + }) + + it('should resolve to folder index when slash and no js file', async () => { + const browser = await webdriver(context.appPort, '/resolution') + const text = await browser + .elementByCss('#subfolder2-slash').click() + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder2/index.js') + browser.quit() + }) + + it('should resolve to folder index when index and no js file', async () => { + const browser = await webdriver(context.appPort, '/resolution') + const text = await browser + .elementByCss('#subfolder2-index').click() + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder2/index.js') + browser.quit() + }) + }) + + describe('server side ', () => { + it('should resolve to js file when no slash', async () => { + const browser = await webdriver(context.appPort, '/resolution/subfolder1') + const text = await browser + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder1.js') + browser.close() + }) + + it('should resolve folder index when slash', async () => { + const browser = await webdriver(context.appPort, '/resolution/subfolder1/') + const text = await browser + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder1/index.js') + browser.close() + }) + + it('should resolve folder index when index', async () => { + const browser = await webdriver(context.appPort, '/resolution/subfolder1/index') + const text = await browser + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder1/index.js') + browser.close() + }) + + it('should resolve to js file when no slash and no js file', async () => { + const browser = await webdriver(context.appPort, '/resolution/subfolder2') + const text = await browser + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder2/index.js') + browser.close() + }) + + it('should resolve to js file when slash and no js file', async () => { + const browser = await webdriver(context.appPort, '/resolution/subfolder2/') + const text = await browser + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder2/index.js') + browser.close() + }) + + it('should resolve folder index when index and no js file', async () => { + const browser = await webdriver(context.appPort, '/resolution/subfolder2/index') + const text = await browser + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder2/index.js') + browser.close() + }) + }) + }) +} diff --git a/test/integration/production/pages/resolution/index.js b/test/integration/production/pages/resolution/index.js new file mode 100644 index 0000000000000..8b430f6d94593 --- /dev/null +++ b/test/integration/production/pages/resolution/index.js @@ -0,0 +1,24 @@ +import Link from 'next/link' + +export default () => ( +
+ + subfolder 1 + + + subfolder 1 with slash + + + subfolder 1 with index + + + subfolder 2 + + + subfolder 2 with slash + + + subfolder 2 with index + +
+) diff --git a/test/integration/production/pages/resolution/subfolder1.js b/test/integration/production/pages/resolution/subfolder1.js new file mode 100644 index 0000000000000..0cc98991622cb --- /dev/null +++ b/test/integration/production/pages/resolution/subfolder1.js @@ -0,0 +1,5 @@ +export default () => ( +
+

This is subfolder1.js

+
+) diff --git a/test/integration/production/pages/resolution/subfolder1/index.js b/test/integration/production/pages/resolution/subfolder1/index.js new file mode 100644 index 0000000000000..4c7bbe50e5cbc --- /dev/null +++ b/test/integration/production/pages/resolution/subfolder1/index.js @@ -0,0 +1,5 @@ +export default () => ( +
+

This is subfolder1/index.js

+
+) diff --git a/test/integration/production/pages/resolution/subfolder2/index.js b/test/integration/production/pages/resolution/subfolder2/index.js new file mode 100644 index 0000000000000..e26c310a20144 --- /dev/null +++ b/test/integration/production/pages/resolution/subfolder2/index.js @@ -0,0 +1,5 @@ +export default () => ( +
+

This is subfolder2/index.js

+
+) diff --git a/test/integration/production/pages/subfolder/index.js b/test/integration/production/pages/subfolder/index.js new file mode 100644 index 0000000000000..02543df6e74eb --- /dev/null +++ b/test/integration/production/pages/subfolder/index.js @@ -0,0 +1,5 @@ +export default () => ( +
+

Hello World

+
+) diff --git a/test/integration/production/pages/trailing-slash-link.js b/test/integration/production/pages/trailing-slash-link.js new file mode 100644 index 0000000000000..4fc446c2d1cfe --- /dev/null +++ b/test/integration/production/pages/trailing-slash-link.js @@ -0,0 +1,11 @@ +import Link from 'next/link' + +export default () => ( +
+ + Go Back + + +

This page has a link to an index.js page, called with a trailing /.

+
+) diff --git a/test/integration/production/test/index.test.js b/test/integration/production/test/index.test.js index 9b0a72b84e49b..b31b17237e942 100644 --- a/test/integration/production/test/index.test.js +++ b/test/integration/production/test/index.test.js @@ -16,6 +16,7 @@ import fetch from 'node-fetch' import dynamicImportTests from './dynamic' import processEnv from './process-env' import security from './security' +import resolution from './resolution' import { BUILD_MANIFEST, REACT_LOADABLE_MANIFEST, PAGES_MANIFEST } from 'next-server/constants' import cheerio from 'cheerio' const appDir = join(__dirname, '../') @@ -162,6 +163,27 @@ describe('Production Usage', () => { expect(text).toBe('Hello World') browser.close() }) + + it('should navigate trailing slash via client side', async () => { + const browser = await webdriver(appPort, '/trailing-slash-link') + const text = await browser + .elementByCss('#home-link').click() + .waitForElementByCss('.subfolder-index-page') + .elementByCss('p').text() + + expect(text).toBe('Hello World') + browser.close() + }) + + it('should navigate trailing slash via server side', async () => { + const browser = await webdriver(context.appPort, '/subfolder/') + const text = await browser + .waitForElementByCss('.subfolder-index-page') + .elementByCss('p').text() + + expect(text).toBe('Hello World') + browser.close() + }) }) describe('Runtime errors', () => { @@ -382,4 +404,5 @@ describe('Production Usage', () => { processEnv(context) security(context) + resolution(context) }) diff --git a/test/integration/production/test/resolution.js b/test/integration/production/test/resolution.js new file mode 100644 index 0000000000000..2af6fcef260c9 --- /dev/null +++ b/test/integration/production/test/resolution.js @@ -0,0 +1,137 @@ +/* eslint-env jest */ + +import webdriver from 'next-webdriver' + +export default (context) => { + describe('page resolution', () => { + describe('client side', () => { + it('should resolve to js file when no slash', async () => { + const browser = await webdriver(context.appPort, '/resolution') + const text = await browser + .elementByCss('#subfolder1').click() + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder1.js') + browser.quit() + }) + + it('should resolve to folder index when slash', async () => { + const browser = await webdriver(context.appPort, '/resolution') + const text = await browser + .elementByCss('#subfolder1-slash').click() + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder1/index.js') + browser.quit() + }) + + it('should resolve to folder index when index', async () => { + const browser = await webdriver(context.appPort, '/resolution') + const text = await browser + .elementByCss('#subfolder1-index').click() + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder1/index.js') + browser.quit() + }) + + it('should resolve to folder when no slash and no js file', async () => { + const browser = await webdriver(context.appPort, '/resolution') + const text = await browser + .elementByCss('#subfolder2').click() + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder2/index.js') + browser.quit() + }) + + it('should resolve to folder index when slash and no js file', async () => { + const browser = await webdriver(context.appPort, '/resolution') + const text = await browser + .elementByCss('#subfolder2-slash').click() + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder2/index.js') + browser.quit() + }) + + it('should resolve to folder index when index and no js file', async () => { + const browser = await webdriver(context.appPort, '/resolution') + const text = await browser + .elementByCss('#subfolder2-index').click() + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder2/index.js') + browser.quit() + }) + }) + + describe('server side ', () => { + it('should resolve to js file when no slash', async () => { + const browser = await webdriver(context.appPort, '/resolution/subfolder1') + const text = await browser + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder1.js') + browser.close() + }) + + it('should resolve folder index when slash', async () => { + const browser = await webdriver(context.appPort, '/resolution/subfolder1/') + const text = await browser + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder1/index.js') + browser.close() + }) + + it('should resolve folder index when index', async () => { + const browser = await webdriver(context.appPort, '/resolution/subfolder1/index') + const text = await browser + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder1/index.js') + browser.close() + }) + + it('should resolve to js file when no slash and no js file', async () => { + const browser = await webdriver(context.appPort, '/resolution/subfolder2') + const text = await browser + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder2/index.js') + browser.close() + }) + + it('should resolve to js file when slash and no js file', async () => { + const browser = await webdriver(context.appPort, '/resolution/subfolder2/') + const text = await browser + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder2/index.js') + browser.close() + }) + + it('should resolve folder index when index and no js file', async () => { + const browser = await webdriver(context.appPort, '/resolution/subfolder2/index') + const text = await browser + .waitForElementByCss('.marker') + .elementByCss('.marker').text() + + expect(text).toBe('This is subfolder2/index.js') + browser.close() + }) + }) + }) +} diff --git a/test/isolated/require-page.test.js b/test/isolated/require-page.test.js index 934f8cd3fc8e8..7d17f7f768bd5 100644 --- a/test/isolated/require-page.test.js +++ b/test/isolated/require-page.test.js @@ -39,6 +39,10 @@ describe('normalizePagePath', () => { it('Should throw on /../../test.js', () => { expect(() => normalizePagePath('/../../test.js')).toThrow() }) + + it('Should turn /abc/ into /abc', () => { + expect(normalizePagePath('/abc/')).toBe(`${sep}abc`) + }) }) describe('getPagePath', () => {