Skip to content

Commit

Permalink
Avoid Route Component re-render by preventing unused state update (is…
Browse files Browse the repository at this point in the history
…PendingEntry)
  • Loading branch information
santino committed Feb 24, 2024
1 parent f497419 commit d28bc92
Show file tree
Hide file tree
Showing 12 changed files with 1,811 additions and 1,460 deletions.
44 changes: 22 additions & 22 deletions package.json
@@ -1,6 +1,6 @@
{
"name": "react-concurrent-router",
"version": "1.6.3",
"version": "1.7.0",
"description": "Performant routing embracing React concurrent UI patterns",
"author": "Santino Puleio",
"license": "MIT",
Expand Down Expand Up @@ -52,12 +52,12 @@
"files": [
{
"path": "./dist/cjs/index.min.js",
"maxSize": "8.5 Kb",
"maxSize": "8.6 Kb",
"compression": "none"
},
{
"path": "./dist/esm/index.min.js",
"maxSize": "8 Kb",
"maxSize": "8.1 Kb",
"compression": "none"
},
{
Expand Down Expand Up @@ -95,45 +95,45 @@
},
"devDependencies": {
"@ampproject/rollup-plugin-closure-compiler": "^0.27.0",
"@babel/core": "^7.22.8",
"@babel/eslint-parser": "^7.22.7",
"@babel/core": "^7.23.9",
"@babel/eslint-parser": "^7.23.10",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/plugin-proposal-object-rest-spread": "^7.20.7",
"@babel/plugin-proposal-optional-chaining": "^7.21.0",
"@babel/plugin-transform-object-assign": "^7.22.5",
"@babel/plugin-transform-react-constant-elements": "^7.22.5",
"@babel/plugin-transform-runtime": "^7.22.7",
"@babel/preset-env": "^7.22.7",
"@babel/preset-react": "^7.22.5",
"@babel/runtime": "^7.22.6",
"@babel/plugin-transform-object-assign": "^7.23.3",
"@babel/plugin-transform-react-constant-elements": "^7.23.3",
"@babel/plugin-transform-runtime": "^7.23.9",
"@babel/preset-env": "^7.23.9",
"@babel/preset-react": "^7.23.3",
"@babel/runtime": "^7.23.9",
"@rollup/plugin-babel": "^5.3.1",
"@rollup/plugin-commonjs": "^22.0.2",
"@rollup/plugin-node-resolve": "^13.3.0",
"@testing-library/dom": "^9.3.1",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@types/react": "^18.2.14",
"babel-jest": "^29.6.1",
"@testing-library/dom": "^9.3.4",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@types/react": "^18.2.58",
"babel-jest": "^29.7.0",
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
"bundlewatch": "^0.3.3",
"cherry-pick": "^0.5.0",
"coveralls": "^3.1.1",
"cross-env": "^7.0.3",
"doctoc": "^2.2.1",
"dtslint": "^4.2.1",
"jest": "^29.6.1",
"jest-environment-jsdom": "^29.6.1",
"lint-staged": "^13.2.3",
"open-cli": "^7.2.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"lint-staged": "^15.2.2",
"open-cli": "^8.0.0",
"prettier-standard": "^16.4.1",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"rimraf": "^4.4.1",
"rimraf": "^5.0.5",
"rollup": "^2.79.1",
"rollup-plugin-terser": "^7.0.2",
"standard": "^17.1.0",
"typescript": "^4.9.5"
"typescript": "^5.3.3"
},
"peerDependencies": {
"@babel/runtime": "^7.11.0",
Expand Down
64 changes: 45 additions & 19 deletions src/RouteRenderer.js
Expand Up @@ -43,28 +43,39 @@ const RouteRenderer = ({ pendingIndicator }) => {
// This is they key part that ensures we 'suspend' rendering until component.read() is able to resolve
const Component = useMemo(() => routeEntry.component.read(), [routeEntry])

// This function is invoked in assist-prefetch mode when receiving a new route entry. It will check
// the fetch entities within the route prefetch object and 'await' the ones that cannot be deferred.
// Eventually it will then set the new route entry and composed prefetched object.
// NOTE: this is similar to 'computeInitialEntry' above but we cannot converge the two because here
// we need async/await that won't work above since initial setState value cannot be a promise.
const computePendingEntry = useCallback(async pendingEntry => {
// This function is invoked to process prefetched entities in assist-prefetch mode. It will split
// the fetch entities within the route prefetch object that need to be "await"ed from the ones that don't.
const processFetchEntities = useCallback(pendingEntry => {
if (!pendingEntry.assistedPrefetch) {
return { prefetched: pendingEntry.prefetched, toBePrefetched: [] }
}

const prefetched = {}
const toBePrefetched = []

for (const [property, value] of pendingEntry.prefetched.entries()) {
if (!value.defer) await value.data.load()
prefetched[property] = value.data
if (value.defer === false && value.data.isLoaded() === false) {
toBePrefetched.push({ key: property, data: value.data })
} else prefetched[property] = value.data
}

return { ...pendingEntry, prefetched }
return { prefetched, toBePrefetched }
}, [])

// On mount subscribe for route changes
useEffect(() => {
const dispose = subscribe(async nextEntry => {
if (nextEntry.skipRender) return

setIsPendingEntry(true)
const { prefetched, toBePrefetched } = processFetchEntities(nextEntry)
// Updating pending indicator changes state, which causes a rerender of an entire page component tree. Avoid if not necessary
const shouldUpdatePendingIndicator = Boolean(
pendingIndicator &&
((awaitComponent && !nextEntry.component.isLoaded()) ||
(nextEntry.assistedPrefetch && toBePrefetched.length))
)

if (shouldUpdatePendingIndicator) setIsPendingEntry(true)

// In case of awaitComponent we want to keep user on existing route until the new component
// code has been loaded. Obviously in this case we wouldn't fallback to the Suspense boundary.
Expand All @@ -75,19 +86,34 @@ const RouteRenderer = ({ pendingIndicator }) => {
// request as well as code chunks requests have already been initialised and are in progress.
if (awaitComponent) await nextEntry.component.load()

// In assist-prefetch mode we need to compute the prefetch entities to check if we can/cannot
// defer them. When we can't defer prefetch entities we will continue to show the current
// route entry whilst we wait for a response; otherwise we render the new route immediately and
// let the component deal with loading states while prefetching.
const routeEntry = nextEntry.assistedPrefetch
? await computePendingEntry(nextEntry)
: nextEntry
// In assist-prefetch mode we need to "await" the prefetch entities that cannot be deferred.
// When encountering these, we continue to show the current route entry whilst we "await".
// Otherwise we render the new route immediately and let the component deal with loading states while prefetching.
const newlyPrefetched = toBePrefetched.length
? await toBePrefetched.reduce(
async (newlyPrefetched, { key, data }) => {
await data.load() // wait for prefetch entity to resolve
return { ...newlyPrefetched, [key]: data }
},
{}
)
: {}
const routeEntry = {
...nextEntry,
prefetched: { ...prefetched, ...newlyPrefetched }
}

setRouteEntry(routeEntry)
setIsPendingEntry(false)
if (shouldUpdatePendingIndicator) setIsPendingEntry(false)
})
return () => dispose() // cleanup/unsubscribe function
}, [assistPrefetch, awaitComponent, computePendingEntry, subscribe])
}, [
assistPrefetch,
awaitComponent,
processFetchEntities,
pendingIndicator,
subscribe
])

return (
<>
Expand Down
4 changes: 4 additions & 0 deletions src/SuspendableResource.js
Expand Up @@ -28,6 +28,7 @@ class SuspendableResource {
// if a js module has default export, we return that
const returnValue = this._isModule ? result.default || result : result
this._result = returnValue
return this._result
})
.catch(error => {
this._error = error
Expand All @@ -36,6 +37,9 @@ class SuspendableResource {
return this._promise
}

// tells if the component has already been loaded; hence is available
isLoaded = () => Boolean(this._result)

/**
* This is the key method for integrating with React Suspense. Read will:
* - "Suspend" if the resource loading has not been triggered or is still pending
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/Link.test.js
Expand Up @@ -2,7 +2,7 @@
* @jest-environment jsdom
*/

import '@testing-library/jest-dom/extend-expect'
import '@testing-library/jest-dom/jest-globals'
import React from 'react'
import { render, fireEvent } from '@testing-library/react'

Expand Down Expand Up @@ -233,7 +233,7 @@ describe('Link', () => {
expect(router.warmRoute).not.toHaveBeenCalled()
const link = wrap().getByRole('link')
fireEvent.keyDown(link, { key: ' ', code: 'Space' })
fireEvent.keyDown(link, { key: 'Shft', code: 'ShiftLeft' })
fireEvent.keyDown(link, { key: 'Shift', code: 'ShiftLeft' })
fireEvent.keyDown(link, { key: 'a', code: 'KeyA' })
fireEvent.keyDown(link, { key: '1', code: 'Digit1' })

Expand Down

0 comments on commit d28bc92

Please sign in to comment.