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 () => (
+
+)
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 () => (
+
+)
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 () => (
+
+)
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', () => {