Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/soft-fishes-warn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@suspensive/react": minor
---

feat(react): fix bugs in `lazy` and stabilize it
6 changes: 0 additions & 6 deletions docs/suspensive.org/src/content/en/docs/react/lazy.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,6 @@ import { Callout } from '@/components'

# lazy

<Callout type='experimental'>

`lazy` is an experimental feature, so this interface may change.

</Callout>

The `lazy` function is a wrapper around React's `lazy` function that provides callbacks for component loading success and failure. It allows you to execute custom logic when a component loads successfully or fails, providing better user experience and debugging capabilities.

### Preloading Components
Expand Down
6 changes: 0 additions & 6 deletions docs/suspensive.org/src/content/ko/docs/react/lazy.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,6 @@ import { Callout } from '@/components'

# lazy

<Callout type='experimental'>

`lazy`는 실험 기능이므로 이 인터페이스는 변경될 수 있습니다.

</Callout>

`lazy` 함수는 React의 `lazy` 함수를 래핑하여 컴포넌트 로딩 성공과 실패에 대한 콜백을 제공합니다. 컴포넌트가 성공적으로 로드되거나 실패할 때 사용자 정의 로직을 실행할 수 있어 더 나은 사용자 경험과 디버깅을 제공합니다.

### 컴포넌트 사전 로딩
Expand Down
42 changes: 37 additions & 5 deletions packages/react/src/lazy.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ describe('lazy', () => {
expect(callOrder).toEqual(['individual', 'factory'])
})

it('should execute default onSuccess first, then component onSuccess', async () => {
it('should execute component onSuccess first, then default onSuccess', async () => {
const mockImport = importCache.createImport({ failureCount: 0, failureDelay: 50, successDelay: 100 })
const callOrder: string[] = []
const defaultOnSuccess = vi.fn().mockImplementation(() => {
Expand Down Expand Up @@ -604,11 +604,10 @@ describe('lazy', () => {
await act(() => vi.advanceTimersByTimeAsync(100))
expect(screen.getByText('error')).toBeInTheDocument()

// Should remove invalid value, but currentRetryCount becomes NaN, so it won't retry
// This is the actual behavior - when NaN is found, it's removed but currentRetryCount is still NaN
expect(storage.getItem(loadFunction.toString())).toBeNull()
// Should remove invalid value and reset retry count to 0, so it retries normally
expect(storage.getItem(loadFunction.toString())).toBe('1')
await act(() => vi.advanceTimersByTimeAsync(1))
expect(mockReload).toHaveBeenCalledTimes(0)
expect(mockReload).toHaveBeenCalledTimes(1)
})

it('should not reload when retry count exceeds limit', async () => {
Expand Down Expand Up @@ -636,6 +635,39 @@ describe('lazy', () => {
expect(mockReload).toHaveBeenCalledTimes(0)
})

it('should use separate storage keys for different lazy components', async () => {
const lazy = createLazy(reloadOnError({ storage, reload: mockReload, retry: 1 }))
const mockImport = importCache.createImport({ failureCount: 10, failureDelay: 50, successDelay: 50 })

const Component1 = lazy(() => mockImport('/component-1'))
const Component2 = lazy(() => mockImport('/component-2'))

render(
<ErrorBoundary fallback={<div>error1</div>}>
<Component1 />
</ErrorBoundary>
)

await act(() => vi.advanceTimersByTimeAsync(50))
expect(screen.getByText('error1')).toBeInTheDocument()

await act(() => vi.advanceTimersByTimeAsync(1))
expect(mockReload).toHaveBeenCalledTimes(1)

// Component2 should still be able to retry (not affected by Component1's retry count)
render(
<ErrorBoundary fallback={<div>error2</div>}>
<Component2 />
</ErrorBoundary>
)

await act(() => vi.advanceTimersByTimeAsync(50))
expect(screen.getByText('error2')).toBeInTheDocument()

await act(() => vi.advanceTimersByTimeAsync(1))
expect(mockReload).toHaveBeenCalledTimes(2)
})

it('should throw error when storage is not provided and window.sessionStorage does not exist', () => {
const originalWindow = global.window
// @ts-expect-error - intentionally removing window
Expand Down
33 changes: 14 additions & 19 deletions packages/react/src/lazy.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
'use client'
import { type ComponentType, type LazyExoticComponent, lazy as originalLazy } from 'react'
import { noop } from './utils/noop'

interface LazyOptions {
onSuccess?: ({ load }: { load: () => Promise<void> }) => void
onError?: ({ error, load }: { error: unknown; load: () => Promise<void> }) => undefined
onSuccess?: ({ load }: { load: () => Promise<{ default: ComponentType<any> }> }) => void
onError?: ({ error, load }: { error: unknown; load: () => Promise<{ default: ComponentType<any> }> }) => void
}

/**
* Creates a lazy function with custom default options
*
* The default `lazy` export is equivalent to `createLazy({})`.
*
* @experimental This is experimental feature.
*
* @description
* The created lazy function will execute individual callbacks first, then default callbacks.
* For onSuccess: individual onSuccess → default onSuccess
Expand Down Expand Up @@ -48,33 +45,32 @@ export const createLazy =
load: () => Promise<{ default: T }>,
options?: LazyOptions
): LazyExoticComponent<T> & {
load: () => Promise<void>
load: () => Promise<{ default: T }>
} => {
const composedOnSuccess = ({ load }: { load: () => Promise<void> }) => {
const composedOnSuccess = () => {
options?.onSuccess?.({ load })
defaultOptions.onSuccess?.({ load })
}

const composedOnError = ({ error, load }: { error: unknown; load: () => Promise<void> }) => {
const composedOnError = (error: unknown) => {
options?.onError?.({ error, load })
defaultOptions.onError?.({ error, load })
}

const loadNoReturn = () => load().then(noop)
return Object.assign(
originalLazy(() =>
load().then(
(loaded) => {
composedOnSuccess({ load: loadNoReturn })
composedOnSuccess()
return loaded
},
(error: unknown) => {
composedOnError({ error: error, load: loadNoReturn })
composedOnError(error)
throw error
}
)
),
{ load: loadNoReturn }
{ load }
)
}

Expand All @@ -83,8 +79,6 @@ export const createLazy =
*
* This is equivalent to `createLazy({})` - a lazy function with no default options.
*
* @experimental This is experimental feature.
*
* @example
* ```tsx
* import { lazy, Suspense } from '@suspensive/react'
Expand Down Expand Up @@ -122,7 +116,7 @@ export const createLazy =
* ```
*
* @returns A lazy component with additional `load` method for preloading
* @property {() => Promise<void>} load - Preloads the component without rendering it. Useful for prefetching components in the background.
* @property load - Preloads the component without rendering it. Useful for prefetching components in the background.
*/
export const lazy = createLazy({})

Expand Down Expand Up @@ -166,8 +160,6 @@ interface ReloadOnErrorOptions extends LazyOptions {
/**
* Options for reloading page if the component fails to load.
*
* @experimental This is experimental feature.
*
* @example
* ```tsx
* import { createLazy, reloadOnError } from '@suspensive/react'
Expand Down Expand Up @@ -210,8 +202,11 @@ export const reloadOnError = ({
const storedValue = reloadStorage.getItem(storageKey)
if (storedValue) {
const reloadCount = parseInt(storedValue, 10)
if (Number.isNaN(reloadCount)) reloadStorage.removeItem(storageKey)
currentRetryCount = reloadCount
if (Number.isNaN(reloadCount)) {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NaN was silently killing lazy

reloadStorage.removeItem(storageKey)
} else {
currentRetryCount = reloadCount
}
}
}

Expand Down
Loading