Skip to content

Commit c7e667b

Browse files
eric-parsonsSimenBEric Parsons
authored
fix: prevent remount of underlying component (#22)
Co-authored-by: Simen Bekkhus <[email protected]> Co-authored-by: Eric Parsons <[email protected]>
1 parent b7ba86c commit c7e667b

File tree

2 files changed

+100
-11
lines changed

2 files changed

+100
-11
lines changed

src/__tests__/index.test.tsx

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,45 @@
1-
import React from "react";
2-
import { act, render, screen, waitFor } from "@testing-library/react";
1+
import React, { memo, useEffect } from "react";
2+
import {
3+
act,
4+
render,
5+
RenderResult,
6+
screen,
7+
waitFor,
8+
} from "@testing-library/react";
39
import lazy, { lazyWithPreload as namedExport } from "../index";
410

511
function getTestComponentModule() {
612
const TestComponent = React.forwardRef<
713
HTMLDivElement,
814
{ foo: string; children: React.ReactNode }
915
>(function TestComponent(props, ref) {
16+
renders++;
17+
useEffect(() => {
18+
mounts++;
19+
}, []);
1020
return <div ref={ref}>{`${props.foo} ${props.children}`}</div>;
1121
});
1222
let loaded = false;
1323
let loadCalls = 0;
24+
let renders = 0;
25+
let mounts = 0;
1426

1527
return {
1628
isLoaded: () => loaded,
1729
loadCalls: () => loadCalls,
30+
renders: () => renders,
31+
mounts: () => mounts,
1832
OriginalComponent: TestComponent,
1933
TestComponent: async () => {
2034
loaded = true;
2135
loadCalls++;
2236
return { default: TestComponent };
2337
},
38+
TestMemoizedComponent: async () => {
39+
loaded = true;
40+
loadCalls++;
41+
return { default: memo(TestComponent) };
42+
},
2443
};
2544
}
2645

@@ -158,4 +177,66 @@ describe("lazy", () => {
158177
it("exports named export as well", () => {
159178
expect(lazy).toBe(namedExport);
160179
});
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+
<React.Suspense fallback={null}>
191+
<LazyTestComponent foo="bar">baz</LazyTestComponent>
192+
</React.Suspense>
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+
<React.Suspense fallback={null}>
204+
<LazyTestComponent foo="bar">baz</LazyTestComponent>
205+
</React.Suspense>
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+
<React.Suspense fallback={null}>
222+
<LazyTestComponent foo="bar">baz</LazyTestComponent>
223+
</React.Suspense>
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+
<React.Suspense fallback={null}>
235+
<LazyTestComponent foo="updated">updated</LazyTestComponent>
236+
</React.Suspense>
237+
);
238+
});
239+
240+
expect(mounts()).toBe(1);
241+
});
161242
});

src/index.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ComponentType, createElement, forwardRef, lazy } from "react";
1+
import { ComponentType, createElement, forwardRef, lazy, useRef } from "react";
22

33
export type PreloadableComponent<T extends ComponentType<any>> = T & {
44
preload: () => Promise<T>;
@@ -7,29 +7,37 @@ export type PreloadableComponent<T extends ComponentType<any>> = T & {
77
export function lazyWithPreload<T extends ComponentType<any>>(
88
factory: () => Promise<{ default: T }>
99
): PreloadableComponent<T> {
10-
const LazyComponent = lazy(factory);
10+
const ReactLazyComponent = lazy(factory);
11+
let PreloadedComponent: T | undefined;
1112
let factoryPromise: Promise<T> | undefined;
12-
let LoadedComponent: T | undefined;
1313

1414
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+
);
1521
return createElement(
16-
LoadedComponent ?? LazyComponent,
22+
ComponentToRender.current,
1723
Object.assign(ref ? { ref } : {}, props) as any
1824
);
19-
}) as any as PreloadableComponent<T>;
25+
});
26+
27+
const LazyWithPreload = Component as any as PreloadableComponent<T>;
2028

21-
Component.preload = () => {
29+
LazyWithPreload.preload = () => {
2230
if (!factoryPromise) {
2331
factoryPromise = factory().then((module) => {
24-
LoadedComponent = module.default;
25-
return LoadedComponent;
32+
PreloadedComponent = module.default;
33+
return PreloadedComponent;
2634
});
2735
}
2836

2937
return factoryPromise;
3038
};
3139

32-
return Component;
40+
return LazyWithPreload;
3341
}
3442

3543
export default lazyWithPreload;

0 commit comments

Comments
 (0)