├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── babel.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── __tests__ │ └── index.test.tsx └── index.ts ├── tsconfig.build.json └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "ecmaFeatures": { 5 | "jsx": true 6 | }, 7 | "project": "./tsconfig.json", 8 | "sourceType": "module" 9 | }, 10 | "plugins": ["@typescript-eslint", "import", "react"], 11 | "env": { 12 | "browser": true, 13 | "commonjs": true, 14 | "es2017": true, 15 | "node": true, 16 | "jest": true 17 | }, 18 | "settings": { 19 | "react": { 20 | "version": "detect" 21 | } 22 | }, 23 | "extends": [ 24 | "eslint:recommended", 25 | "plugin:react/recommended", 26 | "plugin:@typescript-eslint/eslint-recommended", 27 | "plugin:@typescript-eslint/recommended", 28 | "prettier", 29 | "prettier/@typescript-eslint" 30 | ], 31 | "rules": { 32 | "@typescript-eslint/explicit-function-return-type": [ 33 | "warn", 34 | { 35 | "allowExpressions": true 36 | } 37 | ], 38 | "@typescript-eslint/no-explicit-any": "off", 39 | "@typescript-eslint/no-floating-promises": "warn", 40 | "@typescript-eslint/no-use-before-define": [ 41 | "warn", 42 | { 43 | "functions": false, 44 | "classes": false, 45 | "variables": false, 46 | "typedefs": false 47 | } 48 | ], 49 | "@typescript-eslint/no-unused-vars": [ 50 | "warn", 51 | { 52 | "args": "none", 53 | "ignoreRestSiblings": true 54 | } 55 | ], 56 | "import/order": "warn", 57 | "no-console": "warn", 58 | "react/prop-types": "off" 59 | }, 60 | "overrides": [ 61 | { 62 | "files": ["**/*.test.ts?(x)"], 63 | "rules": { 64 | // Disable rules that are lower value in tests 65 | "@typescript-eslint/explicit-function-return-type": "off", 66 | "@typescript-eslint/no-non-null-assertion": "off" 67 | } 68 | } 69 | ] 70 | } 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "trailingComma": "es5" 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug Jest Tests", 9 | "type": "node", 10 | "request": "launch", 11 | "runtimeArgs": [ 12 | "--inspect-brk", 13 | "${workspaceRoot}/node_modules/jest/bin/jest", 14 | "--runInBand", 15 | "--watch" 16 | ], 17 | "console": "integratedTerminal", 18 | "internalConsoleOptions": "neverOpen", 19 | "disableOptimisticBPs": true, 20 | "port": 9229 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": [ 3 | "javascript", 4 | "javascriptreact", 5 | { "language": "typescript", "autoFix": true }, 6 | { "language": "typescriptreact", "autoFix": true } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Ian Schmitz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `react-lazy-with-preload` wraps the `React.lazy()` API and adds the ability to preload the component before it is rendered for the first time. 2 | 3 | ## Install 4 | 5 | ```sh 6 | npm install react-lazy-with-preload 7 | ``` 8 | 9 | ## Usage 10 | 11 | **Before:** 12 | 13 | ```js 14 | import { lazy, Suspense } from "react"; 15 | const OtherComponent = lazy(() => import("./OtherComponent")); 16 | ``` 17 | 18 | **After:** 19 | 20 | ```js 21 | import { Suspense } from "react"; 22 | import { lazyWithPreload } from "react-lazy-with-preload"; 23 | const OtherComponent = lazyWithPreload(() => import("./OtherComponent")); 24 | 25 | // ... 26 | OtherComponent.preload(); 27 | ``` 28 | 29 | To preload a component before it is rendered for the first time, the component that is returned from `lazyWithPreload()` has a `preload` function attached that you can invoke. `preload()` returns a `Promise` that you can wait on if needed. The promise is idempotent, meaning that `preload()` will return the same `Promise` instance if called multiple times. 30 | 31 | For more information about React code-splitting, `React.lazy` and `React.Suspense`, see https://reactjs.org/docs/code-splitting.html. 32 | 33 | ## Example 34 | 35 | For example, if you need to load a component when a button is pressed, you could start preloading the component when the user hovers over the button: 36 | 37 | ```js 38 | function SomeComponent() { 39 | const { showOtherComponent, setShowOtherComponent } = useState(false); 40 | 41 | return ( 42 |
43 | Loading...
}> 44 | {showOtherComponent && } 45 | 46 | 53 | 54 | ); 55 | } 56 | ``` 57 | 58 | ## Acknowledgements 59 | 60 | Inspired by the preload behavior of [react-loadable](https://github.com/jamiebuilds/react-loadable). 61 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { targets: { node: "current" } }], 4 | "@babel/preset-react", 5 | "@babel/preset-typescript", 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Automatically clear mock calls and instances between every test 3 | clearMocks: true, 4 | roots: ["/src/"], 5 | testEnvironment: "jsdom", 6 | watchPlugins: [ 7 | "jest-watch-typeahead/filename", 8 | "jest-watch-typeahead/testname", 9 | ], 10 | }; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-lazy-with-preload", 3 | "version": "2.2.1", 4 | "description": "Wraps the React.lazy API with preload functionality", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "files": [ 8 | "lib" 9 | ], 10 | "author": "Ian Schmitz ", 11 | "license": "MIT", 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/ianschmitz/react-lazy-with-preload.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/ianschmitz/react-lazy-with-preload/issues" 18 | }, 19 | "homepage": "https://github.com/ianschmitz/react-lazy-with-preload#readme", 20 | "keywords": [ 21 | "React", 22 | "Lazy", 23 | "Preload" 24 | ], 25 | "scripts": { 26 | "build": "tsc -p tsconfig.build.json", 27 | "lint": "eslint -f codeframe --ext .js,.ts,.tsx src/", 28 | "test": "jest", 29 | "test:watch": "npm run test -- --watch" 30 | }, 31 | "devDependencies": { 32 | "@babel/core": "^7.10.2", 33 | "@babel/preset-env": "^7.10.2", 34 | "@babel/preset-react": "^7.10.1", 35 | "@babel/preset-typescript": "^7.10.1", 36 | "@testing-library/react": "^13.3.0", 37 | "@types/jest": "^28.1.7", 38 | "@types/react": "^18.0.14", 39 | "@types/react-dom": "^18.0.5", 40 | "@typescript-eslint/eslint-plugin": "^3.2.0", 41 | "@typescript-eslint/parser": "^3.2.0", 42 | "babel-jest": "^28.1.3", 43 | "eslint": "^7.2.0", 44 | "eslint-config-prettier": "^6.11.0", 45 | "eslint-plugin-import": "^2.21.2", 46 | "eslint-plugin-react": "^7.20.0", 47 | "husky": "^4.2.5", 48 | "jest": "^28.1.3", 49 | "jest-environment-jsdom": "^28.1.3", 50 | "jest-watch-typeahead": "^2.0.0", 51 | "prettier": "^2.0.5", 52 | "pretty-quick": "^2.0.1", 53 | "react": "^18.2.0", 54 | "react-dom": "^18.2.0", 55 | "typescript": "~4.7.4" 56 | }, 57 | "husky": { 58 | "hooks": { 59 | "pre-commit": "pretty-quick --staged" 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useEffect } from "react"; 2 | import { 3 | act, 4 | render, 5 | RenderResult, 6 | screen, 7 | waitFor, 8 | } from "@testing-library/react"; 9 | import lazy, { lazyWithPreload as namedExport } from "../index"; 10 | 11 | function getTestComponentModule() { 12 | const TestComponent = React.forwardRef< 13 | HTMLDivElement, 14 | { foo: string; children: React.ReactNode } 15 | >(function TestComponent(props, ref) { 16 | renders++; 17 | useEffect(() => { 18 | mounts++; 19 | }, []); 20 | return
{`${props.foo} ${props.children}`}
; 21 | }); 22 | let loaded = false; 23 | let loadCalls = 0; 24 | let renders = 0; 25 | let mounts = 0; 26 | 27 | return { 28 | isLoaded: () => loaded, 29 | loadCalls: () => loadCalls, 30 | renders: () => renders, 31 | mounts: () => mounts, 32 | OriginalComponent: TestComponent, 33 | TestComponent: async () => { 34 | loaded = true; 35 | loadCalls++; 36 | return { default: TestComponent }; 37 | }, 38 | TestMemoizedComponent: async () => { 39 | loaded = true; 40 | loadCalls++; 41 | return { default: memo(TestComponent) }; 42 | }, 43 | }; 44 | } 45 | 46 | describe("lazy", () => { 47 | it("renders normally without invoking preload", async () => { 48 | const { TestComponent, isLoaded } = getTestComponentModule(); 49 | const LazyTestComponent = lazy(TestComponent); 50 | 51 | expect(isLoaded()).toBe(false); 52 | 53 | render( 54 | 55 | baz 56 | 57 | ); 58 | 59 | await waitFor(() => expect(screen.queryByText("bar baz")).toBeTruthy()); 60 | }); 61 | 62 | it("renders normally when invoking preload", async () => { 63 | const { TestComponent, isLoaded } = getTestComponentModule(); 64 | const LazyTestComponent = lazy(TestComponent); 65 | await LazyTestComponent.preload(); 66 | 67 | expect(isLoaded()).toBe(true); 68 | 69 | render( 70 | 71 | baz 72 | 73 | ); 74 | 75 | await waitFor(() => expect(screen.queryByText("bar baz")).toBeTruthy()); 76 | }); 77 | 78 | it("never renders fallback if preloaded before first render", async () => { 79 | let fallbackRendered = false; 80 | const Fallback = () => { 81 | fallbackRendered = true; 82 | return null; 83 | }; 84 | const { TestComponent } = getTestComponentModule(); 85 | const LazyTestComponent = lazy(TestComponent); 86 | await LazyTestComponent.preload(); 87 | 88 | render( 89 | }> 90 | baz 91 | 92 | ); 93 | 94 | expect(fallbackRendered).toBe(false); 95 | 96 | await LazyTestComponent.preload(); 97 | }); 98 | 99 | it("renders fallback if not preloaded", async () => { 100 | let fallbackRendered = false; 101 | const Fallback = () => { 102 | fallbackRendered = true; 103 | return null; 104 | }; 105 | const { TestComponent } = getTestComponentModule(); 106 | const LazyTestComponent = lazy(TestComponent); 107 | 108 | render( 109 | }> 110 | baz 111 | 112 | ); 113 | 114 | expect(fallbackRendered).toBe(true); 115 | 116 | await act(async () => { 117 | await LazyTestComponent.preload(); 118 | }); 119 | }); 120 | 121 | it("only preloads once when preload is invoked multiple times", async () => { 122 | const { TestComponent, loadCalls } = getTestComponentModule(); 123 | const LazyTestComponent = lazy(TestComponent); 124 | const preloadPromise1 = LazyTestComponent.preload(); 125 | const preloadPromise2 = LazyTestComponent.preload(); 126 | 127 | await Promise.all([preloadPromise1, preloadPromise2]); 128 | 129 | // If `preload()` called multiple times, it should return the same promise 130 | expect(preloadPromise1).toBe(preloadPromise2); 131 | expect(loadCalls()).toBe(1); 132 | 133 | render( 134 | 135 | baz 136 | 137 | ); 138 | 139 | await waitFor(() => expect(screen.queryByText("bar baz")).toBeTruthy()); 140 | }); 141 | 142 | it("supports ref forwarding", async () => { 143 | const { TestComponent } = getTestComponentModule(); 144 | const LazyTestComponent = lazy(TestComponent); 145 | 146 | let ref: React.RefObject | undefined; 147 | 148 | function ParentComponent() { 149 | ref = React.useRef(null); 150 | 151 | return ( 152 | 153 | baz 154 | 155 | ); 156 | } 157 | 158 | render( 159 | 160 | 161 | 162 | ); 163 | 164 | await waitFor(() => expect(screen.queryByText("bar baz")).toBeTruthy()); 165 | expect(ref?.current?.textContent).toBe("bar baz"); 166 | }); 167 | 168 | it("returns the preloaded component when the preload promise resolves", async () => { 169 | const { TestComponent, OriginalComponent } = getTestComponentModule(); 170 | const LazyTestComponent = lazy(TestComponent); 171 | 172 | const preloadedComponent = await LazyTestComponent.preload(); 173 | 174 | expect(preloadedComponent).toBe(OriginalComponent); 175 | }); 176 | 177 | it("exports named export as well", () => { 178 | expect(lazy).toBe(namedExport); 179 | }); 180 | 181 | it("does not re-render memoized base component when passed same props after preload", async () => { 182 | const { TestMemoizedComponent, renders } = getTestComponentModule(); 183 | const LazyTestComponent = lazy(TestMemoizedComponent); 184 | 185 | expect(renders()).toBe(0); 186 | 187 | let rerender: RenderResult["rerender"] | undefined; 188 | await act(async () => { 189 | const result = render( 190 | 191 | baz 192 | 193 | ); 194 | rerender = result.rerender; 195 | }); 196 | 197 | expect(renders()).toBe(1); 198 | 199 | await LazyTestComponent.preload(); 200 | 201 | await act(async () => { 202 | rerender?.( 203 | 204 | baz 205 | 206 | ); 207 | }); 208 | 209 | expect(renders()).toBe(1); 210 | }); 211 | 212 | it("does not re-mount base component after preload", async () => { 213 | const { TestComponent, mounts } = getTestComponentModule(); 214 | const LazyTestComponent = lazy(TestComponent); 215 | 216 | expect(mounts()).toBe(0); 217 | 218 | let rerender: RenderResult["rerender"] | undefined; 219 | await act(async () => { 220 | const result = render( 221 | 222 | baz 223 | 224 | ); 225 | rerender = result.rerender; 226 | }); 227 | 228 | expect(mounts()).toBe(1); 229 | 230 | await LazyTestComponent.preload(); 231 | 232 | await act(async () => { 233 | rerender?.( 234 | 235 | updated 236 | 237 | ); 238 | }); 239 | 240 | expect(mounts()).toBe(1); 241 | }); 242 | }); 243 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { ComponentType, createElement, forwardRef, lazy, useRef } from "react"; 2 | 3 | export type PreloadableComponent> = T & { 4 | preload: () => Promise; 5 | }; 6 | 7 | export function lazyWithPreload>( 8 | factory: () => Promise<{ default: T }> 9 | ): PreloadableComponent { 10 | const ReactLazyComponent = lazy(factory); 11 | let PreloadedComponent: T | undefined; 12 | let factoryPromise: Promise | undefined; 13 | 14 | const Component = forwardRef(function LazyWithPreload(props, ref) { 15 | // Once one of these is chosen, we must ensure that it continues to be 16 | // used for all subsequent renders, otherwise it can cause the 17 | // underlying component to be unmounted and remounted. 18 | const ComponentToRender = useRef( 19 | PreloadedComponent ?? ReactLazyComponent 20 | ); 21 | return createElement( 22 | ComponentToRender.current, 23 | Object.assign(ref ? { ref } : {}, props) as any 24 | ); 25 | }); 26 | 27 | const LazyWithPreload = Component as any as PreloadableComponent; 28 | 29 | LazyWithPreload.preload = () => { 30 | if (!factoryPromise) { 31 | factoryPromise = factory().then((module) => { 32 | PreloadedComponent = module.default; 33 | return PreloadedComponent; 34 | }); 35 | } 36 | 37 | return factoryPromise; 38 | }; 39 | 40 | return LazyWithPreload; 41 | } 42 | 43 | export default lazyWithPreload; 44 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "exclude": ["node_modules", "**/__tests__/*"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "esModuleInterop": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "jsx": "react", 7 | "lib": ["dom", "es5", "es2015.promise"], 8 | "module": "commonjs", 9 | "outDir": "lib", 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "target": "es5" 13 | }, 14 | "include": ["src"], 15 | "exclude": ["node_modules"] 16 | } 17 | --------------------------------------------------------------------------------