├── .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 |
--------------------------------------------------------------------------------