diff --git a/README.md b/README.md index 785ece8..b899d19 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,40 @@ const accepted = await Confirm.call({ message: 'Continue?' }) Check out [the demo site](https://react-call.desko.dev/) to see some live examples of other React components being called. +# Lazy loading + +For performance optimization, you can use `createLazyCallable` to load components on demand: + +```tsx +import { createLazyCallable } from 'react-call' + +// Before: Component is included in the main bundle +const MyDialog = createCallable(lazy(() => import('./MyDialog'))) + +// After: Simplified lazy loading +const MyDialog = createLazyCallable(() => import('./MyDialog')) +``` + +The `createLazyCallable` function: + +- **Simplifies** the lazy loading syntax by automatically wrapping with `React.lazy()` +- **Reduces bundle size** by code-splitting the component into a separate chunk +- **Loads on demand** - the component is only downloaded when first called +- **Supports all features** - works with unmounting delays, Root props, etc. + +```tsx +// Example: Heavy form component loaded only when needed +const EditUserForm = createLazyCallable( + () => import('./components/EditUserForm'), + 500 // unmounting delay +) + +// The component will be downloaded only when this runs: +const userData = await EditUserForm.call({ userId: 123 }) +``` + +**Bundle evidence**: When built, lazy components are split into separate chunks (e.g., `EditUserForm-abc123.js`) instead of being included in the main bundle. + # Advanced usage ## End from caller diff --git a/demo/src/App.tsx b/demo/src/App.tsx index d99a4dc..5830b16 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -5,6 +5,7 @@ import { YourChain, YourUnlocked, YourBubbles, + YourLazy, } from './CallableScenes' import { HeroSection } from './HeroSection' @@ -18,6 +19,7 @@ export function App() { + ) } diff --git a/demo/src/CallableScenes/YourLazy.tsx b/demo/src/CallableScenes/YourLazy.tsx new file mode 100644 index 0000000..38daae2 --- /dev/null +++ b/demo/src/CallableScenes/YourLazy.tsx @@ -0,0 +1,42 @@ +import type { ReactCall } from 'react-call' +import { Dialog } from '../shared/Dialog' + +export interface YourLazyProps { + message: string +} + +export type YourLazyResponse = 'confirmed' | 'cancelled' + +// biome-ignore lint/style/noDefaultExport: React.lazy requires default export for lazy loading +export default function YourLazy({ + call, + message, +}: ReactCall.Props>) { + return ( + + Lazy Loaded Dialog 🚀 + {message} + + + This component was loaded lazily using{' '} + createLazyCallable! + + +

+ 💡 Check the Network tab to see it was loaded on demand +

+ + call.end('confirmed')}> + ✅ confirmed + + call.end('cancelled')} + > + ❌ cancelled + + +
+ ) +} diff --git a/demo/src/CallableScenes/YourLazyScene.tsx b/demo/src/CallableScenes/YourLazyScene.tsx new file mode 100644 index 0000000..220bf7c --- /dev/null +++ b/demo/src/CallableScenes/YourLazyScene.tsx @@ -0,0 +1,7 @@ +import { createLazyCallable } from 'react-call' +import type { YourLazyProps, YourLazyResponse } from './YourLazy' + +// Create the lazy-loaded callable +export const YourLazy = createLazyCallable( + () => import('./YourLazy'), +) diff --git a/demo/src/CallableScenes/index.ts b/demo/src/CallableScenes/index.ts index 0fb382c..db7ccbd 100644 --- a/demo/src/CallableScenes/index.ts +++ b/demo/src/CallableScenes/index.ts @@ -4,3 +4,4 @@ export * from './YourNested' export * from './YourChain' export * from './YourUnlocked' export * from './YourBubbles' +export * from './YourLazyScene' diff --git a/demo/src/HeroSection/Main/Action/scenes.ts b/demo/src/HeroSection/Main/Action/scenes.ts index 7888cd4..cdfcc1c 100644 --- a/demo/src/HeroSection/Main/Action/scenes.ts +++ b/demo/src/HeroSection/Main/Action/scenes.ts @@ -5,6 +5,7 @@ import { YourChain, YourUnlocked, YourBubbles, + YourLazy, } from '../../../CallableScenes' export const DISABLED_COLORS = 'bg-gray-700 text-slate-100 hover:text-white' @@ -119,6 +120,23 @@ const SCENES = new Map([ 'bg-yellow-400 hover:bg-yellow-300 hover:shadow-yellow-500/20 text-black hover:text-slate-800', }, ], + [ + 'YourLazy', + { + trigger: async () => { + console.group('YourLazy') + console.log('await YourLazy.call() ⏳ (lazy loading...)') + const res = await YourLazy.call({ + message: 'This dialog was loaded on demand!', + }) + console.log('await YourLazy.call() ✅', '→', `'${res}'`) + console.groupEnd() + return `'${res}'` + }, + buttonColors: + 'bg-purple-700 hover:bg-purple-600 hover:shadow-purple-500/20 text-slate-100 hover:text-white', + }, + ], ]) const SCENE_KEYS = [...SCENES.keys()] diff --git a/react-call/src/createLazyCallable/index.tsx b/react-call/src/createLazyCallable/index.tsx new file mode 100644 index 0000000..b3b29e6 --- /dev/null +++ b/react-call/src/createLazyCallable/index.tsx @@ -0,0 +1,19 @@ +import { lazy } from 'react' +import { createCallable } from '../createCallable' +import type { UserComponent, Callable } from '../createCallable/types' + +/** + * Creates a lazy-loaded callable component + * @param importFn - A function that returns a dynamic import promise + * @param unmountingDelay - Optional delay before unmounting (passed to createCallable) + * @returns A callable object with lazy-loaded component + */ +export function createLazyCallable( + importFn: () => Promise<{ + default: UserComponent + }>, + unmountingDelay?: number, +): Callable { + const LazyComponent = lazy(importFn) + return createCallable(LazyComponent, unmountingDelay) +} diff --git a/react-call/src/main.ts b/react-call/src/main.ts index 00619f5..aefb4db 100644 --- a/react-call/src/main.ts +++ b/react-call/src/main.ts @@ -1,2 +1,3 @@ export { createCallable } from './createCallable' +export { createLazyCallable } from './createLazyCallable' export type * as ReactCall from './types.public' diff --git a/tests/src/lazy.test.tsx b/tests/src/lazy.test.tsx new file mode 100644 index 0000000..668d7d6 --- /dev/null +++ b/tests/src/lazy.test.tsx @@ -0,0 +1,49 @@ +import { lazy } from 'react' +import { describe, expect, it } from 'vitest' +import { createCallable, createLazyCallable } from 'react-call' + +describe('createLazyCallable', () => { + it('should create a lazy-loaded callable component', async () => { + // Mock lazy-loaded component + const LazyConfirm = createLazyCallable<{ message: string }, boolean>( + () => import('./shared/ConfirmDefault'), + ) + + // Verify the callable has the expected methods + expect(LazyConfirm).toHaveProperty('call') + expect(LazyConfirm).toHaveProperty('upsert') + expect(LazyConfirm).toHaveProperty('end') + expect(LazyConfirm).toHaveProperty('update') + expect(LazyConfirm).toHaveProperty('Root') + }) + + it('should work the same as createCallable with lazy', async () => { + // Using createCallable with lazy + const ManualLazyConfirm = createCallable( + lazy(() => import('./shared/ConfirmDefault')), + ) + + // Using createLazyCallable + const AutoLazyConfirm = createLazyCallable<{ message: string }, boolean>( + () => import('./shared/ConfirmDefault'), + ) + + // Both should have the same structure + expect(Object.keys(ManualLazyConfirm).sort()).toEqual( + Object.keys(AutoLazyConfirm).sort(), + ) + }) + + it('should accept unmountingDelay parameter', async () => { + const LazyConfirmWithDelay = createLazyCallable< + { message: string }, + boolean + >( + () => import('./shared/ConfirmDefault'), + 500, // 500ms unmounting delay + ) + + expect(LazyConfirmWithDelay).toHaveProperty('call') + expect(LazyConfirmWithDelay).toHaveProperty('Root') + }) +}) diff --git a/tests/src/shared/ConfirmDefault.tsx b/tests/src/shared/ConfirmDefault.tsx new file mode 100644 index 0000000..7afdb1d --- /dev/null +++ b/tests/src/shared/ConfirmDefault.tsx @@ -0,0 +1,22 @@ +import { useId } from 'react' +import type { ReactCall } from 'react-call' + +// biome-ignore lint/style/noDefaultExport: React.lazy requires default export for lazy loading +export default function ConfirmDefault({ + call, + message, +}: ReactCall.Props<{ message: string }, boolean, Record>) { + const a11yId = useId() + return ( + // biome-ignore lint/a11y/useSemanticElements: ok for tests +
+

{message}

+ + +
+ ) +}