diff --git a/packages/gatsby-legacy-polyfills/package.json b/packages/gatsby-legacy-polyfills/package.json index fb4592c6a2364..04dc667c83bf0 100644 --- a/packages/gatsby-legacy-polyfills/package.json +++ b/packages/gatsby-legacy-polyfills/package.json @@ -31,6 +31,7 @@ "dist/" ], "devDependencies": { + "yet-another-abortcontroller-polyfill": "0.0.4", "chokidar-cli": "^3.0.0", "codegen.macro": "^4.1.0", "core-js": "3.9.0", diff --git a/packages/gatsby-legacy-polyfills/src/polyfills.js b/packages/gatsby-legacy-polyfills/src/polyfills.js index 3e48a3cc7294d..75e6129b295f0 100644 --- a/packages/gatsby-legacy-polyfills/src/polyfills.js +++ b/packages/gatsby-legacy-polyfills/src/polyfills.js @@ -6,6 +6,7 @@ codegen` module.exports = imports.map(file => 'import "core-js/' + file + '"').join("\\n") ` +import "yet-another-abortcontroller-polyfill" import "whatwg-fetch" import "url-polyfill" import assign from "object-assign" diff --git a/packages/gatsby-link/src/index.js b/packages/gatsby-link/src/index.js index c1abef043f2bb..d80fa11c6a0ab 100644 --- a/packages/gatsby-link/src/index.js +++ b/packages/gatsby-link/src/index.js @@ -80,16 +80,14 @@ const createIntersectionObserver = (el, cb) => { if (el === entry.target) { // Check if element is within viewport, remove listener, destroy observer, and run link callback. // MSEdge doesn't currently support isIntersecting, so also test for an intersectionRatio > 0 - if (entry.isIntersecting || entry.intersectionRatio > 0) { - io.unobserve(el) - io.disconnect() - cb() - } + cb(entry.isIntersecting || entry.intersectionRatio > 0) } }) }) + // Add element to the observer io.observe(el) + return { instance: io, el } } @@ -113,6 +111,7 @@ class GatsbyLink extends React.Component { this.state = { IOSupported, } + this.abortPrefetch = null this.handleRef = this.handleRef.bind(this) } @@ -132,22 +131,10 @@ class GatsbyLink extends React.Component { // Prefetch is used to speed up next navigations. When you use it on the current navigation, // there could be a race-condition where Chrome uses the stale data instead of waiting for the network to complete if (currentPath !== newPathName) { - ___loader.enqueue(newPathName) - } - } - - componentDidUpdate(prevProps, prevState) { - // Preserve non IO functionality if no support - if (this.props.to !== prevProps.to && !this.state.IOSupported) { - this._prefetch() + return ___loader.enqueue(newPathName) } - } - componentDidMount() { - // Preserve non IO functionality if no support - if (!this.state.IOSupported) { - this._prefetch() - } + return undefined } componentWillUnmount() { @@ -156,12 +143,19 @@ class GatsbyLink extends React.Component { } const { instance, el } = this.io + if (this.abortPrefetch) { + this.abortPrefetch.abort() + } + instance.unobserve(el) instance.disconnect() } handleRef(ref) { - if (this.props.innerRef && this.props.innerRef.hasOwnProperty(`current`)) { + if ( + this.props.innerRef && + Object.prototype.hasOwnProperty.call(this.props.innerRef, `current`) + ) { this.props.innerRef.current = ref } else if (this.props.innerRef) { this.props.innerRef(ref) @@ -169,8 +163,14 @@ class GatsbyLink extends React.Component { if (this.state.IOSupported && ref) { // If IO supported and element reference found, setup Observer functionality - this.io = createIntersectionObserver(ref, () => { - this._prefetch() + this.io = createIntersectionObserver(ref, inViewPort => { + if (inViewPort) { + this.abortPrefetch = this._prefetch() + } else { + if (this.abortPrefetch) { + this.abortPrefetch.abort() + } + } }) } } diff --git a/packages/gatsby/cache-dir/__tests__/dev-loader.js b/packages/gatsby/cache-dir/__tests__/dev-loader.js index 6fb75c2af052f..d58098a5d28f7 100644 --- a/packages/gatsby/cache-dir/__tests__/dev-loader.js +++ b/packages/gatsby/cache-dir/__tests__/dev-loader.js @@ -482,26 +482,32 @@ describe(`Dev loader`, () => { describe(`prefetch`, () => { const flushPromises = () => new Promise(resolve => setImmediate(resolve)) - it(`shouldn't prefetch when shouldPrefetch is false`, () => { - const devLoader = new DevLoader(asyncRequires, []) + it(`shouldn't prefetch when shouldPrefetch is false`, async () => { + jest.useFakeTimers() + const devLoader = new DevLoader(null, []) devLoader.shouldPrefetch = jest.fn(() => false) devLoader.doPrefetch = jest.fn() devLoader.apiRunner = jest.fn() + const prefetchPromise = devLoader.prefetch(`/mypath/`) + jest.runAllTimers() - expect(devLoader.prefetch(`/mypath/`)).toBe(false) + expect(await prefetchPromise).toBe(false) expect(devLoader.shouldPrefetch).toHaveBeenCalledWith(`/mypath/`) expect(devLoader.apiRunner).not.toHaveBeenCalled() expect(devLoader.doPrefetch).not.toHaveBeenCalled() }) - it(`should trigger custom prefetch logic when core is disabled`, () => { - const devLoader = new DevLoader(asyncRequires, []) + it(`should trigger custom prefetch logic when core is disabled`, async () => { + jest.useFakeTimers() + const devLoader = new DevLoader(null, []) devLoader.shouldPrefetch = jest.fn(() => true) devLoader.doPrefetch = jest.fn() devLoader.apiRunner = jest.fn() devLoader.prefetchDisabled = true - expect(devLoader.prefetch(`/mypath/`)).toBe(false) + const prefetchPromise = devLoader.prefetch(`/mypath/`) + jest.runAllTimers() + expect(await prefetchPromise).toBe(false) expect(devLoader.shouldPrefetch).toHaveBeenCalledWith(`/mypath/`) expect(devLoader.apiRunner).toHaveBeenCalledWith(`onPrefetchPathname`, { pathname: `/mypath/`, @@ -511,12 +517,14 @@ describe(`Dev loader`, () => { it(`should prefetch when not yet triggered`, async () => { jest.useFakeTimers() - const devLoader = new DevLoader(asyncRequires, []) + const devLoader = new DevLoader(null, []) devLoader.shouldPrefetch = jest.fn(() => true) devLoader.apiRunner = jest.fn() devLoader.doPrefetch = jest.fn(() => Promise.resolve({})) + const prefetchPromise = devLoader.prefetch(`/mypath/`) + jest.runAllTimers() - expect(devLoader.prefetch(`/mypath/`)).toBe(true) + expect(await prefetchPromise).toBe(true) // wait for doPrefetchPromise await flushPromises() @@ -532,30 +540,5 @@ describe(`Dev loader`, () => { } ) }) - - it(`should only run apis once`, async () => { - const devLoader = new DevLoader(asyncRequires, []) - devLoader.shouldPrefetch = jest.fn(() => true) - devLoader.apiRunner = jest.fn() - devLoader.doPrefetch = jest.fn(() => Promise.resolve({})) - - expect(devLoader.prefetch(`/mypath/`)).toBe(true) - expect(devLoader.prefetch(`/mypath/`)).toBe(true) - - // wait for doPrefetchPromise - await flushPromises() - - expect(devLoader.apiRunner).toHaveBeenCalledTimes(2) - expect(devLoader.apiRunner).toHaveBeenNthCalledWith( - 1, - `onPrefetchPathname`, - expect.anything() - ) - expect(devLoader.apiRunner).toHaveBeenNthCalledWith( - 2, - `onPostPrefetchPathname`, - expect.anything() - ) - }) }) }) diff --git a/packages/gatsby/cache-dir/__tests__/loader.js b/packages/gatsby/cache-dir/__tests__/loader.js index 0aa566deed61f..11b40db88284a 100644 --- a/packages/gatsby/cache-dir/__tests__/loader.js +++ b/packages/gatsby/cache-dir/__tests__/loader.js @@ -516,26 +516,32 @@ describe(`Production loader`, () => { describe(`prefetch`, () => { const flushPromises = () => new Promise(resolve => setImmediate(resolve)) - it(`shouldn't prefetch when shouldPrefetch is false`, () => { + it(`shouldn't prefetch when shouldPrefetch is false`, async () => { + jest.useFakeTimers() const prodLoader = new ProdLoader(null, []) prodLoader.shouldPrefetch = jest.fn(() => false) prodLoader.doPrefetch = jest.fn() prodLoader.apiRunner = jest.fn() + const prefetchPromise = prodLoader.prefetch(`/mypath/`) + jest.runAllTimers() - expect(prodLoader.prefetch(`/mypath/`)).toBe(false) + expect(await prefetchPromise).toBe(false) expect(prodLoader.shouldPrefetch).toHaveBeenCalledWith(`/mypath/`) expect(prodLoader.apiRunner).not.toHaveBeenCalled() expect(prodLoader.doPrefetch).not.toHaveBeenCalled() }) - it(`should trigger custom prefetch logic when core is disabled`, () => { + it(`should trigger custom prefetch logic when core is disabled`, async () => { + jest.useFakeTimers() const prodLoader = new ProdLoader(null, []) prodLoader.shouldPrefetch = jest.fn(() => true) prodLoader.doPrefetch = jest.fn() prodLoader.apiRunner = jest.fn() prodLoader.prefetchDisabled = true - expect(prodLoader.prefetch(`/mypath/`)).toBe(false) + const prefetchPromise = prodLoader.prefetch(`/mypath/`) + jest.runAllTimers() + expect(await prefetchPromise).toBe(false) expect(prodLoader.shouldPrefetch).toHaveBeenCalledWith(`/mypath/`) expect(prodLoader.apiRunner).toHaveBeenCalledWith(`onPrefetchPathname`, { pathname: `/mypath/`, @@ -549,8 +555,10 @@ describe(`Production loader`, () => { prodLoader.shouldPrefetch = jest.fn(() => true) prodLoader.apiRunner = jest.fn() prodLoader.doPrefetch = jest.fn(() => Promise.resolve({})) + const prefetchPromise = prodLoader.prefetch(`/mypath/`) + jest.runAllTimers() - expect(prodLoader.prefetch(`/mypath/`)).toBe(true) + expect(await prefetchPromise).toBe(true) // wait for doPrefetchPromise await flushPromises() @@ -568,13 +576,17 @@ describe(`Production loader`, () => { }) it(`should only run apis once`, async () => { + jest.useFakeTimers() const prodLoader = new ProdLoader(null, []) prodLoader.shouldPrefetch = jest.fn(() => true) prodLoader.apiRunner = jest.fn() prodLoader.doPrefetch = jest.fn(() => Promise.resolve({})) + const prefetchPromise = prodLoader.prefetch(`/mypath/`) + const prefetchPromise2 = prodLoader.prefetch(`/mypath/`) + jest.runAllTimers() - expect(prodLoader.prefetch(`/mypath/`)).toBe(true) - expect(prodLoader.prefetch(`/mypath/`)).toBe(true) + expect(await prefetchPromise).toBe(true) + expect(await prefetchPromise2).toBe(true) // wait for doPrefetchPromise await flushPromises() diff --git a/packages/gatsby/cache-dir/loader.js b/packages/gatsby/cache-dir/loader.js index 2e57ae32e7875..11e7c55fb1425 100644 --- a/packages/gatsby/cache-dir/loader.js +++ b/packages/gatsby/cache-dir/loader.js @@ -33,7 +33,7 @@ const createPageDataUrl = rawPath => { } function doFetch(url, method = `GET`) { - return new Promise((resolve, reject) => { + return new Promise(resolve => { const req = new XMLHttpRequest() req.open(method, url, true) req.onreadystatechange = () => { @@ -98,6 +98,8 @@ export class BaseLoader { this.inFlightDb = new Map() this.staticQueryDb = {} this.pageDataDb = new Map() + this.isPrefetchQueueRunning = false + this.prefetchQueued = [] this.prefetchTriggered = new Set() this.prefetchCompleted = new Set() this.loadComponent = loadComponent @@ -396,32 +398,90 @@ export class BaseLoader { prefetch(pagePath) { if (!this.shouldPrefetch(pagePath)) { - return false + return { + then: resolve => resolve(false), + abort: () => {}, + } + } + if (this.prefetchTriggered.has(pagePath)) { + return { + then: resolve => resolve(true), + abort: () => {}, + } + } + + const defer = { + resolve: null, + reject: null, + promise: null, } + defer.promise = new Promise((resolve, reject) => { + defer.resolve = resolve + defer.reject = reject + }) + this.prefetchQueued.push([pagePath, defer]) + const abortC = new AbortController() + abortC.signal.addEventListener(`abort`, () => { + const index = this.prefetchQueued.findIndex(([p]) => p === pagePath) + // remove from the queue + if (index !== -1) { + this.prefetchQueued.splice(index, 1) + } + }) - // Tell plugins with custom prefetching logic that they should start - // prefetching this path. - if (!this.prefetchTriggered.has(pagePath)) { - this.apiRunner(`onPrefetchPathname`, { pathname: pagePath }) - this.prefetchTriggered.add(pagePath) + if (!this.isPrefetchQueueRunning) { + this.isPrefetchQueueRunning = true + setTimeout(() => { + this._processNextPrefetchBatch() + }, 3000) } - // If a plugin has disabled core prefetching, stop now. - if (this.prefetchDisabled) { - return false + return { + then: (resolve, reject) => defer.promise.then(resolve, reject), + abort: abortC.abort.bind(abortC), } + } + + _processNextPrefetchBatch() { + const idleCallback = window.requestIdleCallback || (cb => setTimeout(cb, 0)) + + idleCallback(() => { + const toPrefetch = this.prefetchQueued.splice(0, 4) + const prefetches = Promise.all( + toPrefetch.map(([pagePath, dPromise]) => { + // Tell plugins with custom prefetching logic that they should start + // prefetching this path. + if (!this.prefetchTriggered.has(pagePath)) { + this.apiRunner(`onPrefetchPathname`, { pathname: pagePath }) + this.prefetchTriggered.add(pagePath) + } + + // If a plugin has disabled core prefetching, stop now. + if (this.prefetchDisabled) { + return dPromise.resolve(false) + } + + return this.doPrefetch(findPath(pagePath)).then(() => { + if (!this.prefetchCompleted.has(pagePath)) { + this.apiRunner(`onPostPrefetchPathname`, { pathname: pagePath }) + this.prefetchCompleted.add(pagePath) + } + + dPromise.resolve(true) + }) + }) + ) - const realPath = findPath(pagePath) - // Todo make doPrefetch logic cacheable - // eslint-disable-next-line consistent-return - this.doPrefetch(realPath).then(() => { - if (!this.prefetchCompleted.has(pagePath)) { - this.apiRunner(`onPostPrefetchPathname`, { pathname: pagePath }) - this.prefetchCompleted.add(pagePath) + if (this.prefetchQueued.length) { + prefetches.then(() => { + setTimeout(() => { + this._processNextPrefetchBatch() + }, 3000) + }) + } else { + this.isPrefetchQueueRunning = false } }) - - return true } doPrefetch(pagePath) { diff --git a/yarn.lock b/yarn.lock index e05e5320820c7..a6d4f2da19936 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27187,6 +27187,11 @@ yeast@0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419" +yet-another-abortcontroller-polyfill@0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/yet-another-abortcontroller-polyfill/-/yet-another-abortcontroller-polyfill-0.0.4.tgz#ce2270e564a4f2d07351812f599e10143526f98b" + integrity sha512-6do/P+mjm7FDUD+q8FrZ0ClMIbjGuKyWuWm3Vz2O7QmVf73HVLYIDV6toS7T0qoH1yeI2HPb8VgJp7w+zcVWvA== + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"