Skip to content

Commit

Permalink
feat(gatsby,gatsby-link): add queue to prefetch (#33530)
Browse files Browse the repository at this point in the history
  • Loading branch information
wardpeet committed Oct 18, 2021
1 parent 68fe836 commit 2975c4d
Show file tree
Hide file tree
Showing 7 changed files with 143 additions and 81 deletions.
1 change: 1 addition & 0 deletions packages/gatsby-legacy-polyfills/package.json
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/gatsby-legacy-polyfills/src/polyfills.js
Expand Up @@ -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"
Expand Down
44 changes: 22 additions & 22 deletions packages/gatsby-link/src/index.js
Expand Up @@ -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 }
}

Expand All @@ -113,6 +111,7 @@ class GatsbyLink extends React.Component {
this.state = {
IOSupported,
}
this.abortPrefetch = null
this.handleRef = this.handleRef.bind(this)
}

Expand All @@ -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() {
Expand All @@ -156,21 +143,34 @@ 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)
}

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()
}
}
})
}
}
Expand Down
49 changes: 16 additions & 33 deletions packages/gatsby/cache-dir/__tests__/dev-loader.js
Expand Up @@ -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/`,
Expand All @@ -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()
Expand All @@ -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()
)
})
})
})
26 changes: 19 additions & 7 deletions packages/gatsby/cache-dir/__tests__/loader.js
Expand Up @@ -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/`,
Expand All @@ -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()
Expand All @@ -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()
Expand Down
98 changes: 79 additions & 19 deletions packages/gatsby/cache-dir/loader.js
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down

0 comments on commit 2975c4d

Please sign in to comment.