Skip to content

Commit 8d3af3f

Browse files
authored
feat(gatsby-plugin-offline): replace no-cache detection with dynamic path whitelist (#9907)
* Remove all no-cache code * Remove references to no-cache in offline plugin * Initial work on hybrid navigation handler * Refactor whitelist code to allow it to support onPostPrefetchPathname * Fix service worker detection * Fix IndexedDB race condition * Prevent race conditions + reset whitelist on SW update * Remove unnecessary API handler (onPostPrefetchPathname is called anyway) * Add debugging statements + fix some minor problems * Fix back/forward not working after 404 * Remove unneeded debugging statements * Bundle idb-keyval instead of using an external CDN * Update README * Backport fixes from #9907 * minor fixes for things I copy-pasted wrong * Refactor prefetching so we can detect success
1 parent 7bfecf6 commit 8d3af3f

12 files changed

+260
-222
lines changed

packages/gatsby-plugin-offline/README.md

+2-13
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ plugins: [`gatsby-plugin-offline`]
2424
When adding this plugin to your `gatsby-config.js`, you can pass in options to
2525
override the default [Workbox](https://developers.google.com/web/tools/workbox/modules/workbox-build) config.
2626

27-
The default config is as follows. Warning, you can break the offline support
28-
and AppCache setup by changing these options so tread carefully.
27+
The default config is as follows. Warning: you can break the offline support by
28+
changing these options, so tread carefully.
2929

3030
```javascript
3131
const options = {
@@ -37,17 +37,6 @@ const options = {
3737
// the default prefix with `pathPrefix`.
3838
"/": `${pathPrefix}/`,
3939
},
40-
navigateFallback: `${pathPrefix}/offline-plugin-app-shell-fallback/index.html`,
41-
// Only match URLs without extensions or the query `no-cache=1`.
42-
// So example.com/about/ will pass but
43-
// example.com/about/?no-cache=1 and
44-
// example.com/cheeseburger.jpg will not.
45-
// We only want the service worker to handle our "clean"
46-
// URLs and not any files hosted on the site.
47-
//
48-
// Regex based on http://stackoverflow.com/a/18017805
49-
navigateFallbackWhitelist: [/^([^.?]*|[^?]*\.([^.?]{5,}|html))(\?.*)?$/],
50-
navigateFallbackBlacklist: [/\?(.+&)?no-cache=1$/],
5140
cacheId: `gatsby-plugin-offline`,
5241
// Don't cache-bust JS or CSS files, and anything in the static directory,
5342
// since these files have unique URLs and their contents will never change

packages/gatsby-plugin-offline/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"dependencies": {
1010
"@babel/runtime": "^7.0.0",
1111
"cheerio": "^1.0.0-rc.2",
12+
"idb-keyval": "^3.1.0",
1213
"lodash": "^4.17.10",
1314
"workbox-build": "^3.6.3"
1415
},

packages/gatsby-plugin-offline/src/gatsby-browser.js

+37-12
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,12 @@
11
exports.registerServiceWorker = () => true
22

3-
let swNotInstalled = true
43
const prefetchedPathnames = []
5-
6-
exports.onPostPrefetchPathname = ({ pathname }) => {
7-
// if SW is not installed, we need to record any prefetches
8-
// that happen so we can then add them to SW cache once installed
9-
if (swNotInstalled && `serviceWorker` in navigator) {
10-
prefetchedPathnames.push(pathname)
11-
}
12-
}
4+
const whitelistedPathnames = []
135

146
exports.onServiceWorkerActive = ({
157
getResourceURLsForPathname,
168
serviceWorker,
179
}) => {
18-
// stop recording prefetch events
19-
swNotInstalled = false
20-
2110
// grab nodes from head of document
2211
const nodes = document.querySelectorAll(`
2312
head > script[src],
@@ -51,4 +40,40 @@ exports.onServiceWorkerActive = ({
5140

5241
document.head.appendChild(link)
5342
})
43+
44+
serviceWorker.active.postMessage({
45+
gatsbyApi: `whitelistPathnames`,
46+
pathnames: whitelistedPathnames,
47+
})
48+
}
49+
50+
function whitelistPathname(pathname, includesPrefix) {
51+
if (`serviceWorker` in navigator) {
52+
const { serviceWorker } = navigator
53+
54+
if (serviceWorker.controller !== null) {
55+
serviceWorker.controller.postMessage({
56+
gatsbyApi: `whitelistPathnames`,
57+
pathnames: [{ pathname, includesPrefix }],
58+
})
59+
} else {
60+
whitelistedPathnames.push({ pathname, includesPrefix })
61+
}
62+
}
63+
}
64+
65+
exports.onPostPrefetchPathname = ({ pathname }) => {
66+
whitelistPathname(pathname, false)
67+
68+
// if SW is not installed, we need to record any prefetches
69+
// that happen so we can then add them to SW cache once installed
70+
if (
71+
`serviceWorker` in navigator &&
72+
!(
73+
navigator.serviceWorker.controller !== null &&
74+
navigator.serviceWorker.controller.state === `activated`
75+
)
76+
) {
77+
prefetchedPathnames.push(pathname)
78+
}
5479
}

packages/gatsby-plugin-offline/src/gatsby-node.js

+9-13
Original file line numberDiff line numberDiff line change
@@ -79,17 +79,6 @@ exports.onPostBuild = (args, pluginOptions) => {
7979
// the default prefix with `pathPrefix`.
8080
"/": `${pathPrefix}/`,
8181
},
82-
navigateFallback: `${pathPrefix}/offline-plugin-app-shell-fallback/index.html`,
83-
// Only match URLs without extensions or the query `no-cache=1`.
84-
// So example.com/about/ will pass but
85-
// example.com/about/?no-cache=1 and
86-
// example.com/cheeseburger.jpg will not.
87-
// We only want the service worker to handle our "clean"
88-
// URLs and not any files hosted on the site.
89-
//
90-
// Regex based on http://stackoverflow.com/a/18017805
91-
navigateFallbackWhitelist: [/^([^.?]*|[^?]*\.([^.?]{5,}|html))(\?.*)?$/],
92-
navigateFallbackBlacklist: [/\?(.+&)?no-cache=1$/],
9382
cacheId: `gatsby-plugin-offline`,
9483
// Don't cache-bust JS or CSS files, and anything in the static directory,
9584
// since these files have unique URLs and their contents will never change
@@ -122,15 +111,22 @@ exports.onPostBuild = (args, pluginOptions) => {
122111
delete pluginOptions.plugins
123112
const combinedOptions = _.defaults(pluginOptions, options)
124113

114+
const idbKeyvalFile = `idb-keyval-iife.min.js`
115+
const idbKeyvalSource = require.resolve(`idb-keyval/dist/${idbKeyvalFile}`)
116+
const idbKeyvalDest = `public/${idbKeyvalFile}`
117+
fs.createReadStream(idbKeyvalSource).pipe(fs.createWriteStream(idbKeyvalDest))
118+
125119
const swDest = `public/sw.js`
126120
return workboxBuild
127121
.generateSW({ swDest, ...combinedOptions })
128122
.then(({ count, size, warnings }) => {
129123
if (warnings) warnings.forEach(warning => console.warn(warning))
130124

131-
const swAppend = fs.readFileSync(`${__dirname}/sw-append.js`)
132-
fs.appendFileSync(`public/sw.js`, swAppend)
125+
const swAppend = fs
126+
.readFileSync(`${__dirname}/sw-append.js`, `utf8`)
127+
.replace(/%pathPrefix%/g, pathPrefix)
133128

129+
fs.appendFileSync(`public/sw.js`, swAppend)
134130
console.log(
135131
`Generated ${swDest}, which will precache ${count} files, totaling ${size} bytes.`
136132
)
Original file line numberDiff line numberDiff line change
@@ -1 +1,83 @@
1-
// noop
1+
/* global importScripts, workbox, idbKeyval */
2+
3+
importScripts(`idb-keyval-iife.min.js`)
4+
const WHITELIST_KEY = `custom-navigation-whitelist`
5+
6+
const navigationRoute = new workbox.routing.NavigationRoute(({ event }) => {
7+
const { pathname } = new URL(event.request.url)
8+
9+
return idbKeyval.get(WHITELIST_KEY).then((customWhitelist = []) => {
10+
// Respond with the offline shell if we match the custom whitelist
11+
if (customWhitelist.includes(pathname)) {
12+
const offlineShell = `%pathPrefix%/offline-plugin-app-shell-fallback/index.html`
13+
const cacheName = workbox.core.cacheNames.precache
14+
15+
return caches.match(offlineShell, { cacheName })
16+
}
17+
18+
return fetch(event.request)
19+
})
20+
})
21+
22+
workbox.routing.registerRoute(navigationRoute)
23+
24+
let updatingWhitelist = null
25+
26+
function rawWhitelistPathnames(pathnames) {
27+
if (updatingWhitelist !== null) {
28+
// Prevent the whitelist from being updated twice at the same time
29+
return updatingWhitelist.then(() => rawWhitelistPathnames(pathnames))
30+
}
31+
32+
updatingWhitelist = idbKeyval
33+
.get(WHITELIST_KEY)
34+
.then((customWhitelist = []) => {
35+
pathnames.forEach(pathname => {
36+
if (!customWhitelist.includes(pathname)) customWhitelist.push(pathname)
37+
})
38+
39+
return idbKeyval.set(WHITELIST_KEY, customWhitelist)
40+
})
41+
.then(() => {
42+
updatingWhitelist = null
43+
})
44+
45+
return updatingWhitelist
46+
}
47+
48+
function rawResetWhitelist() {
49+
if (updatingWhitelist !== null) {
50+
return updatingWhitelist.then(() => rawResetWhitelist())
51+
}
52+
53+
updatingWhitelist = idbKeyval.set(WHITELIST_KEY, []).then(() => {
54+
updatingWhitelist = null
55+
})
56+
57+
return updatingWhitelist
58+
}
59+
60+
const messageApi = {
61+
whitelistPathnames(event) {
62+
let { pathnames } = event.data
63+
64+
pathnames = pathnames.map(({ pathname, includesPrefix }) => {
65+
if (!includesPrefix) {
66+
return `%pathPrefix%${pathname}`
67+
} else {
68+
return pathname
69+
}
70+
})
71+
72+
event.waitUntil(rawWhitelistPathnames(pathnames))
73+
},
74+
75+
resetWhitelist(event) {
76+
event.waitUntil(rawResetWhitelist())
77+
},
78+
}
79+
80+
self.addEventListener(`message`, event => {
81+
const { gatsbyApi } = event.data
82+
if (gatsbyApi) messageApi[gatsbyApi](event)
83+
})

packages/gatsby/cache-dir/ensure-resources.js

+9-6
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import React from "react"
22
import PropTypes from "prop-types"
33
import loader from "./loader"
44
import shallowCompare from "shallow-compare"
5-
import { getRedirectUrl } from "./load-directly-or-404"
65

76
// Pass pathname in as prop.
87
// component will try fetching resources. If they exist,
@@ -94,15 +93,19 @@ class EnsureResources extends React.Component {
9493
}
9594

9695
render() {
96+
// This should only occur if the network is offline, or if the
97+
// path is nonexistent and there's no custom 404 page.
9798
if (
9899
process.env.NODE_ENV === `production` &&
99100
!(this.state.pageResources && this.state.pageResources.json)
100101
) {
101-
// This should only occur if there's no custom 404 page
102-
const url = getRedirectUrl(this.state.location.href)
103-
if (url) {
104-
window.location.replace(url)
105-
}
102+
// Do this, rather than simply `window.location.reload()`, so that
103+
// pressing the back/forward buttons work - otherwise Reach Router will
104+
// try to handle back/forward navigation, causing the URL to change but
105+
// the page displayed to stay the same.
106+
const originalUrl = new URL(location.href)
107+
window.history.replaceState({}, `404`, `${location.pathname}?gatsby-404`)
108+
window.location.replace(originalUrl)
106109

107110
return null
108111
}

packages/gatsby/cache-dir/load-directly-or-404.js

-72
This file was deleted.

0 commit comments

Comments
 (0)