Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserProps, UserData>(
() => 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
Expand Down
2 changes: 2 additions & 0 deletions demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
YourChain,
YourUnlocked,
YourBubbles,
YourLazy,
} from './CallableScenes'
import { HeroSection } from './HeroSection'

Expand All @@ -18,6 +19,7 @@ export function App() {
<YourChain.Root />
<YourUnlocked.Root />
<YourBubbles.Root />
<YourLazy.Root />
</>
)
}
42 changes: 42 additions & 0 deletions demo/src/CallableScenes/YourLazy.tsx
Original file line number Diff line number Diff line change
@@ -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<YourLazyProps, YourLazyResponse, Record<string, never>>) {
return (
<Dialog color="violet" ended={call.ended}>
<Dialog.Title>Lazy Loaded Dialog 🚀</Dialog.Title>
<Dialog.Text>{message}</Dialog.Text>
<Dialog.Text>
<span className="text-violet-200/75 italic">
This component was loaded lazily using{' '}
<code className="text-violet-300">createLazyCallable</code>!
</span>
</Dialog.Text>
<p className="text-sm/6 text-yellow-200/75">
💡 Check the Network tab to see it was loaded on demand
</p>
<Dialog.Actions>
<Dialog.Button color="violet" onClick={() => call.end('confirmed')}>
<code>✅ confirmed</code>
</Dialog.Button>
<Dialog.Button
color="violet"
hoverColor="red"
onClick={() => call.end('cancelled')}
>
<code>❌ cancelled</code>
</Dialog.Button>
</Dialog.Actions>
</Dialog>
)
}
7 changes: 7 additions & 0 deletions demo/src/CallableScenes/YourLazyScene.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createLazyCallable } from 'react-call'
import type { YourLazyProps, YourLazyResponse } from './YourLazy'

// Create the lazy-loaded callable
export const YourLazy = createLazyCallable<YourLazyProps, YourLazyResponse>(
() => import('./YourLazy'),
)
1 change: 1 addition & 0 deletions demo/src/CallableScenes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './YourNested'
export * from './YourChain'
export * from './YourUnlocked'
export * from './YourBubbles'
export * from './YourLazyScene'
18 changes: 18 additions & 0 deletions demo/src/HeroSection/Main/Action/scenes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
YourChain,
YourUnlocked,
YourBubbles,
YourLazy,
} from '../../../CallableScenes'

export const DISABLED_COLORS = 'bg-gray-700 text-slate-100 hover:text-white'
Expand Down Expand Up @@ -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()]
Expand Down
19 changes: 19 additions & 0 deletions react-call/src/createLazyCallable/index.tsx
Original file line number Diff line number Diff line change
@@ -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<Props, Response, RootProps = {}>(
importFn: () => Promise<{
default: UserComponent<Props, Response, RootProps>
}>,
unmountingDelay?: number,
): Callable<Props, Response, RootProps> {
const LazyComponent = lazy(importFn)
return createCallable(LazyComponent, unmountingDelay)
}
1 change: 1 addition & 0 deletions react-call/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { createCallable } from './createCallable'
export { createLazyCallable } from './createLazyCallable'
export type * as ReactCall from './types.public'
49 changes: 49 additions & 0 deletions tests/src/lazy.test.tsx
Original file line number Diff line number Diff line change
@@ -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')
})
})
22 changes: 22 additions & 0 deletions tests/src/shared/ConfirmDefault.tsx
Original file line number Diff line number Diff line change
@@ -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<string, never>>) {
const a11yId = useId()
return (
// biome-ignore lint/a11y/useSemanticElements: ok for tests
<div role="dialog" aria-labelledby={a11yId}>
<p id={a11yId}>{message}</p>
<button type="button" onClick={() => call.end(true)}>
Yes
</button>
<button type="button" onClick={() => call.end(false)}>
No
</button>
</div>
)
}
Loading