diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index da599b6..7fdf3d6 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -1,14 +1,25 @@ -import React from "react"; +import React, { + forwardRef, + ReactNode, + RefObject, + Suspense, + useRef, +} from "react"; import { act, render, screen, waitFor } from "@testing-library/react"; -import lazy, { lazyWithPreload as namedExport } from "../index"; +import lazyWithPreload, { lazyWithPreload as namedExport } from "../index"; + +interface TestComponentProps { + foo: string; + children: ReactNode; +} function getTestComponentModule() { - const TestComponent = React.forwardRef< - HTMLDivElement, - { foo: string; children: React.ReactNode } - >(function TestComponent(props, ref) { - return
{`${props.foo} ${props.children}`}
; - }); + const TestComponent = forwardRef( + function TestComponent(props, ref) { + return
{`${props.foo} ${props.children}`}
; + } + ); + let loaded = false; let loadCalls = 0; @@ -18,116 +29,141 @@ function getTestComponentModule() { OriginalComponent: TestComponent, TestComponent: async () => { loaded = true; - loadCalls++; + loadCalls += 1; return { default: TestComponent }; }, }; } -describe("lazy", () => { +describe("lazyWithPreload", () => { it("renders normally without invoking preload", async () => { + // Given const { TestComponent, isLoaded } = getTestComponentModule(); - const LazyTestComponent = lazy(TestComponent); + const LazyTestComponent = lazyWithPreload(TestComponent); + // Then expect(isLoaded()).toBe(false); + // When render( - + baz - + ); + // Then await waitFor(() => expect(screen.queryByText("bar baz")).toBeTruthy()); }); it("renders normally when invoking preload", async () => { + // Given const { TestComponent, isLoaded } = getTestComponentModule(); - const LazyTestComponent = lazy(TestComponent); + const LazyTestComponent = lazyWithPreload(TestComponent); + + // When await LazyTestComponent.preload(); + // Then expect(isLoaded()).toBe(true); + // When render( - + baz - + ); + // Then await waitFor(() => expect(screen.queryByText("bar baz")).toBeTruthy()); }); it("never renders fallback if preloaded before first render", async () => { + // Given let fallbackRendered = false; const Fallback = () => { fallbackRendered = true; return null; }; const { TestComponent } = getTestComponentModule(); - const LazyTestComponent = lazy(TestComponent); - await LazyTestComponent.preload(); + const LazyTestComponent = lazyWithPreload(TestComponent); + // When + await LazyTestComponent.preload(); render( - }> + }> baz - + ); + // Then expect(fallbackRendered).toBe(false); + // Post await LazyTestComponent.preload(); }); it("renders fallback if not preloaded", async () => { + // Given let fallbackRendered = false; const Fallback = () => { fallbackRendered = true; return null; }; const { TestComponent } = getTestComponentModule(); - const LazyTestComponent = lazy(TestComponent); + const LazyTestComponent = lazyWithPreload(TestComponent); + // When render( - }> + }> baz - + ); + // Then expect(fallbackRendered).toBe(true); + // Post await act(async () => { await LazyTestComponent.preload(); }); }); it("only preloads once when preload is invoked multiple times", async () => { + // Given const { TestComponent, loadCalls } = getTestComponentModule(); - const LazyTestComponent = lazy(TestComponent); + const LazyTestComponent = lazyWithPreload(TestComponent); + + // When const preloadPromise1 = LazyTestComponent.preload(); const preloadPromise2 = LazyTestComponent.preload(); await Promise.all([preloadPromise1, preloadPromise2]); + // Then // If `preload()` called multiple times, it should return the same promise expect(preloadPromise1).toBe(preloadPromise2); expect(loadCalls()).toBe(1); + // When render( - + baz - + ); + // Then await waitFor(() => expect(screen.queryByText("bar baz")).toBeTruthy()); }); it("supports ref forwarding", async () => { + // Given const { TestComponent } = getTestComponentModule(); - const LazyTestComponent = lazy(TestComponent); + const LazyTestComponent = lazyWithPreload(TestComponent); - let ref: React.RefObject | undefined; + let ref: RefObject | undefined; function ParentComponent() { - ref = React.useRef(null); + ref = useRef(null); return ( @@ -136,26 +172,32 @@ describe("lazy", () => { ); } + // When render( - + - + ); + // Then await waitFor(() => expect(screen.queryByText("bar baz")).toBeTruthy()); expect(ref?.current?.textContent).toBe("bar baz"); }); it("returns the preloaded component when the preload promise resolves", async () => { + // Given const { TestComponent, OriginalComponent } = getTestComponentModule(); - const LazyTestComponent = lazy(TestComponent); + const LazyTestComponent = lazyWithPreload(TestComponent); + // When const preloadedComponent = await LazyTestComponent.preload(); + // Then expect(preloadedComponent).toBe(OriginalComponent); }); it("exports named export as well", () => { - expect(lazy).toBe(namedExport); + // Then + expect(lazyWithPreload).toBe(namedExport); }); }); diff --git a/src/index.ts b/src/index.ts index d88b1dd..65486a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,24 +1,63 @@ -import { ComponentType, createElement, forwardRef, lazy } from "react"; +import { + ComponentProps, + ComponentType, + createElement, + forwardRef, + FunctionComponent, + lazy, + LazyExoticComponent, +} from "react"; -export type PreloadableComponent> = T & { - preload: () => Promise; +export type PreloadableComponent< + COMPONENT extends ComponentType> +> = LazyExoticComponent & { + preload(): Promise; }; -export function lazyWithPreload>( - factory: () => Promise<{ default: T }> -): PreloadableComponent { +/** + * Function wraps the `React.lazy()` API and adds the ability to preload the component + * before it is rendered for the first time. + * + * @example + * ```tsx + * import React, { Suspense } from 'react'; + * import { lazyWithPreload } from 'react-lazy-with-preload'; + * + * const LazyComponent = lazyWithPreload(() => import('./LazyComponent')); + * + * function SomeComponent() { + * return ( + * + * LazyComponent.preload()} + * > + * Click me to navigate to page with LazyComponent + * + * + * ); + * } + * ``` + */ +export function lazyWithPreload< + COMPONENT extends ComponentType> +>( + factory: () => Promise<{ default: COMPONENT }> +): PreloadableComponent { const LazyComponent = lazy(factory); - let factoryPromise: Promise | undefined; - let LoadedComponent: T | undefined; + let factoryPromise: Promise | undefined; + let LoadedComponent: COMPONENT | undefined; const Component = forwardRef(function LazyWithPreload(props, ref) { return createElement( - LoadedComponent ?? LazyComponent, - Object.assign(ref ? { ref } : {}, props) as any + (LoadedComponent ?? LazyComponent) as FunctionComponent, + // eslint-disable-next-line @typescript-eslint/ban-types + Object.assign<{}, {}>(ref ? { ref } : {}, props) ); - }) as any as PreloadableComponent; + }) as PreloadableComponent; - Component.preload = () => { + Component.preload = function preload() { if (!factoryPromise) { factoryPromise = factory().then((module) => { LoadedComponent = module.default;