Skip to content

Commit d86cd9f

Browse files
authored
fix(gatsby): preload and prefetches check for in-browser redirects (#31366)
* fix(gatsby): preload and prefetches check for in-browser redirects * maybe json and js should not be named the same * copy json files to commonjs dir * add e2e test case * move checking for page and redirect overlap to node from client * adjust e2e assertions
1 parent c339f67 commit d86cd9f

File tree

9 files changed

+253
-85
lines changed

9 files changed

+253
-85
lines changed

e2e-tests/development-runtime/cypress/integration/navigation/redirect.js

Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,10 @@
1-
let spy
2-
Cypress.on(`window:before:load`, win => {
3-
spy = cy.spy(win.console, `error`).as(`spyWinConsoleError`)
4-
})
5-
61
const runTests = () => {
72
it(`should redirect page to index page when there is no such page`, () => {
83
cy.visit(`/redirect-without-page`, {
94
failOnStatusCode: false,
105
}).waitForRouteChange()
116

127
cy.location(`pathname`).should(`equal`, `/`)
13-
cy.then(() => {
14-
const calls = spy.getCalls()
15-
16-
const callsAboutRedirectMatchingPage = calls.filter(call =>
17-
call.args[0].includes(
18-
`matches both a page and a redirect; this is probably not intentional.`
19-
)
20-
)
21-
22-
expect(callsAboutRedirectMatchingPage.length).to.equal(0)
23-
})
248
})
259

2610
it(`should redirect page to index page even there is a such page`, () => {
@@ -29,20 +13,6 @@ const runTests = () => {
2913
}).waitForRouteChange()
3014

3115
cy.location(`pathname`).should(`equal`, `/`)
32-
cy.then(() => {
33-
const calls = spy.getCalls()
34-
35-
const callsAboutRedirectMatchingPage = calls.filter(call =>
36-
call.args[0].includes(
37-
`matches both a page and a redirect; this is probably not intentional.`
38-
)
39-
)
40-
41-
expect(callsAboutRedirectMatchingPage.length).not.to.equal(0)
42-
expect(spy).to.be.calledWith(
43-
`The route "/redirect" matches both a page and a redirect; this is probably not intentional.`
44-
)
45-
})
4616
})
4717

4818
it(`should redirect to a dynamically-created replacement page`, () => {
@@ -51,15 +21,6 @@ const runTests = () => {
5121
}).waitForRouteChange()
5222

5323
cy.location(`pathname`).should(`equal`, `/pt/redirect-me/`)
54-
cy.then(() => {
55-
expect(spy).not.to.be.calledWith(
56-
`The route "/redirect" matches both a page and a redirect; this is probably not intentional.`
57-
)
58-
59-
cy.findByText(`This should be at /pt/redirect-me/`, {
60-
exact: false,
61-
}).should(`exist`)
62-
})
6324
})
6425
}
6526

e2e-tests/production-runtime/cypress/integration/redirects.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
let spy
2+
Cypress.on(`window:before:load`, win => {
3+
spy = cy.spy(win.console, `error`).as(`spyWinConsoleError`)
4+
})
5+
16
describe(`Redirects`, () => {
27
it(`are case insensitive when ignoreCase is set to true`, () => {
38
cy.visit(`/Longue-PAGE`, { failOnStatusCode: false }).waitForRouteChange()
@@ -9,4 +14,47 @@ describe(`Redirects`, () => {
914

1015
cy.get(`h1`).invoke(`text`).should(`contain`, `NOT FOUND`)
1116
})
17+
18+
it(`use redirects when preloading page-data`, () => {
19+
const expectedLinks = [`/Longue-PAGE`, `/pagina-larga`]
20+
21+
// we should not hit those routes
22+
cy.intercept("GET", "/page-data/Longue-PAGE/page-data.json").as(
23+
"page-data-for-redirected-page-a"
24+
)
25+
cy.intercept("GET", "/page-data/pagina-larga/page-data.json").as(
26+
"page-data-for-redirected-page-b"
27+
)
28+
29+
cy.intercept("GET", "/page-data/long-page/page-data.json").as(
30+
"redirected-page-data"
31+
)
32+
33+
cy.visit(`/redirect-links/`).waitForRouteChange()
34+
35+
cy.get("a").each(($el, index, $list) => {
36+
cy.then(() => {
37+
expect($el[0].href.replace(`http://localhost:9000`, ``)).to.be.oneOf(
38+
expectedLinks
39+
)
40+
})
41+
// focus / hover links to force trigger preload
42+
cy.wrap($el).trigger("mouseover")
43+
})
44+
45+
cy.then(() => {
46+
// those requests should not happen
47+
cy.get("@page-data-for-redirected-page-a").should(networkCall => {
48+
expect(networkCall).to.be.null
49+
})
50+
cy.get("@page-data-for-redirected-page-b").should(networkCall => {
51+
expect(networkCall).to.be.null
52+
})
53+
54+
// instead we want links to use redirects
55+
cy.get("@redirected-page-data").should(networkCall => {
56+
expect(networkCall.response.statusCode).to.be.oneOf([304, 200])
57+
})
58+
})
59+
})
1260
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import * as React from "react"
2+
import { Link } from "gatsby"
3+
4+
export default function RedirectLinks() {
5+
return (
6+
<ul>
7+
<li>
8+
<Link to="/Longue-PAGE">/Longue-PAGE</Link>
9+
</li>
10+
<li>
11+
<Link to="/pagina-larga">/pagina-larga</Link>
12+
</li>
13+
</ul>
14+
)
15+
}

packages/gatsby/cache-dir/find-path.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { pick } from "@gatsbyjs/reach-router/lib/utils"
22
import stripPrefix from "./strip-prefix"
33
import normalizePagePath from "./normalize-page-path"
4+
import { maybeGetBrowserRedirect } from "./redirect-utils.js"
45

56
const pathCache = new Map()
67
let matchPaths = []
@@ -115,6 +116,11 @@ export const findPath = rawPathname => {
115116
return pathCache.get(trimmedPathname)
116117
}
117118

119+
const redirect = maybeGetBrowserRedirect(rawPathname)
120+
if (redirect) {
121+
return findPath(redirect.toPath)
122+
}
123+
118124
let foundPath = findMatchPath(trimmedPathname)
119125

120126
if (!foundPath) {

packages/gatsby/cache-dir/navigation.js

Lines changed: 4 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,18 @@
11
import React from "react"
22
import PropTypes from "prop-types"
33
import loader, { PageResourceStatus } from "./loader"
4-
import redirects from "./redirects.json"
4+
import { maybeGetBrowserRedirect } from "./redirect-utils.js"
55
import { apiRunner } from "./api-runner-browser"
66
import emitter from "./emitter"
77
import { RouteAnnouncerProps } from "./route-announcer-props"
88
import { navigate as reachNavigate } from "@gatsbyjs/reach-router"
99
import { globalHistory } from "@gatsbyjs/reach-router/lib/history"
1010
import { parsePath } from "gatsby-link"
1111

12-
// Convert to a map for faster lookup in maybeRedirect()
13-
14-
const redirectMap = new Map()
15-
const redirectIgnoreCaseMap = new Map()
16-
17-
redirects.forEach(redirect => {
18-
if (redirect.ignoreCase) {
19-
redirectIgnoreCaseMap.set(redirect.fromPath, redirect)
20-
} else {
21-
redirectMap.set(redirect.fromPath, redirect)
22-
}
23-
})
24-
2512
function maybeRedirect(pathname) {
26-
let redirect = redirectMap.get(pathname)
27-
if (!redirect) {
28-
redirect = redirectIgnoreCaseMap.get(pathname.toLowerCase())
29-
}
13+
const redirect = maybeGetBrowserRedirect(pathname)
3014

3115
if (redirect != null) {
32-
if (process.env.NODE_ENV !== `production`) {
33-
if (!loader.isPageNotFound(pathname)) {
34-
console.error(
35-
`The route "${pathname}" matches both a page and a redirect; this is probably not intentional.`
36-
)
37-
}
38-
}
39-
4016
window.___replace(redirect.toPath)
4117
return true
4218
} else {
@@ -72,10 +48,7 @@ const navigate = (to, options = {}) => {
7248
}
7349

7450
let { pathname } = parsePath(to)
75-
let redirect = redirectMap.get(pathname)
76-
if (!redirect) {
77-
redirect = redirectIgnoreCaseMap.get(pathname.toLowerCase())
78-
}
51+
const redirect = maybeGetBrowserRedirect(pathname)
7952

8053
// If we're redirecting, just replace the passed in pathname
8154
// to the one we want to redirect to.
@@ -270,4 +243,4 @@ RouteUpdates.propTypes = {
270243
location: PropTypes.object.isRequired,
271244
}
272245

273-
export { init, shouldUpdateScroll, RouteUpdates }
246+
export { init, shouldUpdateScroll, RouteUpdates, maybeGetBrowserRedirect }
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import redirects from "./redirects.json"
2+
3+
// Convert to a map for faster lookup in maybeRedirect()
4+
5+
const redirectMap = new Map()
6+
const redirectIgnoreCaseMap = new Map()
7+
8+
redirects.forEach(redirect => {
9+
if (redirect.ignoreCase) {
10+
redirectIgnoreCaseMap.set(redirect.fromPath, redirect)
11+
} else {
12+
redirectMap.set(redirect.fromPath, redirect)
13+
}
14+
})
15+
16+
export function maybeGetBrowserRedirect(pathname) {
17+
let redirect = redirectMap.get(pathname)
18+
if (!redirect) {
19+
redirect = redirectIgnoreCaseMap.get(pathname.toLowerCase())
20+
}
21+
return redirect
22+
}

packages/gatsby/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@
244244
"postbuild": "node scripts/output-api-file.js && yarn workspace gatsby-admin build",
245245
"build:internal-plugins": "copyfiles -u 1 src/internal-plugins/**/package.json dist",
246246
"build:rawfiles": "copyfiles -u 1 src/internal-plugins/**/raw_* dist",
247-
"build:cjs": "babel cache-dir --out-dir cache-dir/commonjs --ignore \"**/__tests__\" --ignore \"**/__mocks__\"",
247+
"build:cjs": "babel cache-dir --out-dir cache-dir/commonjs --ignore \"**/__tests__\" --ignore \"**/__mocks__\" && copyfiles -u 1 cache-dir/**/*.json cache-dir/commonjs",
248248
"build:src": "babel src --out-dir dist --source-maps --verbose --ignore \"**/gatsby-cli.js,src/internal-plugins/dev-404-page/raw_dev-404-page.js,**/__tests__,**/__mocks__\" --extensions \".ts,.js\"",
249249
"build:types": "tsc --emitDeclarationOnly --declaration --declarationDir dist",
250250
"clean-test-bundles": "find test/ -type f -name bundle.js* -exec rm -rf {} +",
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { writeRedirects } from "../redirects-writer"
2+
import * as fs from "fs-extra"
3+
import reporter from "gatsby-cli/lib/reporter"
4+
import { store } from "../../redux"
5+
import { actions } from "../../redux/actions"
6+
7+
jest.mock(`fs-extra`, () => {
8+
return { writeFile: jest.fn() }
9+
})
10+
11+
jest.mock(`gatsby-cli/lib/reporter`, () => {
12+
return {
13+
warn: jest.fn(),
14+
}
15+
})
16+
17+
const writeFileMock = fs.writeFile as jest.MockedFunction<typeof fs.writeFile>
18+
const reporterWarnMock = reporter.warn as jest.MockedFunction<
19+
typeof reporter.warn
20+
>
21+
22+
beforeEach(() => {
23+
writeFileMock.mockClear()
24+
reporterWarnMock.mockClear()
25+
store.dispatch({ type: `DELETE_CACHE` })
26+
})
27+
28+
describe(`redirect-writer`, () => {
29+
it(`doesn't save non browser redirects`, async () => {
30+
store.dispatch(
31+
actions.createRedirect({
32+
fromPath: `/server/`,
33+
toPath: `/server/redirect/`,
34+
})
35+
)
36+
store.dispatch(
37+
actions.createRedirect({
38+
fromPath: `/client/`,
39+
toPath: `/client/redirect/`,
40+
redirectInBrowser: true,
41+
})
42+
)
43+
44+
await writeRedirects()
45+
46+
expect(writeFileMock).toBeCalledTimes(1)
47+
48+
const clientSideRedirects = JSON.parse(writeFileMock.mock.calls[0][1])
49+
50+
expect(clientSideRedirects).toContainEqual(
51+
expect.objectContaining({ fromPath: `/client/` })
52+
)
53+
expect(clientSideRedirects).not.toContainEqual(
54+
expect.objectContaining({ fromPath: `/server/` })
55+
)
56+
57+
expect(reporterWarnMock).not.toBeCalled()
58+
})
59+
60+
it(`show warning when there is both redirect and page with same path`, async () => {
61+
store.dispatch(
62+
actions.createRedirect({
63+
fromPath: `/server-overlap/`,
64+
toPath: `/server-overlap/redirect/`,
65+
})
66+
)
67+
store.dispatch(
68+
actions.createRedirect({
69+
fromPath: `/client-overlap`, // intentionally missing trailing slash - this checks if /client-overlap/ page is discovered
70+
toPath: `/client-overlap/redirect/`,
71+
redirectInBrowser: true,
72+
})
73+
)
74+
store.dispatch(
75+
actions.createPage(
76+
{
77+
path: `/server-overlap`, // intentionally missing trailing slash - this checks if redirect for /server-overlap/ is discovered
78+
component: `/whatever/index.js`,
79+
},
80+
{ id: `test`, name: `test` }
81+
)
82+
)
83+
store.dispatch(
84+
actions.createPage(
85+
{
86+
path: `/client-overlap/`,
87+
component: `/whatever/index.js`,
88+
},
89+
{ id: `test`, name: `test` }
90+
)
91+
)
92+
93+
await writeRedirects()
94+
95+
expect(writeFileMock).toBeCalledTimes(1)
96+
97+
const clientSideRedirects = JSON.parse(writeFileMock.mock.calls[0][1])
98+
99+
expect(clientSideRedirects).toContainEqual(
100+
expect.objectContaining({ fromPath: `/client-overlap` })
101+
)
102+
103+
expect(clientSideRedirects).not.toContainEqual(
104+
expect.objectContaining({ fromPath: `/server-overlap/` })
105+
)
106+
107+
expect(reporterWarnMock).toBeCalledTimes(1)
108+
109+
const warningMessage = reporterWarnMock.mock.calls[0][0]
110+
expect(warningMessage).toMatchInlineSnapshot(`
111+
"There are routes that match both page and redirect. It will result in page not being accessible; this is probably not intentional:
112+
- page: \\"/server-overlap\\" and redirect: \\"/server-overlap/\\" -> \\"/server-overlap/redirect/\\"
113+
- page: \\"/client-overlap/\\" and redirect: \\"/client-overlap\\" -> \\"/client-overlap/redirect/\\""
114+
`)
115+
})
116+
})

0 commit comments

Comments
 (0)