diff --git a/.changeset/soft-fishes-warn.md b/.changeset/soft-fishes-warn.md
new file mode 100644
index 000000000..a8c71ed32
--- /dev/null
+++ b/.changeset/soft-fishes-warn.md
@@ -0,0 +1,5 @@
+---
+"@suspensive/react": minor
+---
+
+feat(react): fix bugs in `lazy` and stabilize it
diff --git a/docs/suspensive.org/src/content/en/docs/react/lazy.mdx b/docs/suspensive.org/src/content/en/docs/react/lazy.mdx
index a7784b909..4c9ffe8e2 100644
--- a/docs/suspensive.org/src/content/en/docs/react/lazy.mdx
+++ b/docs/suspensive.org/src/content/en/docs/react/lazy.mdx
@@ -6,12 +6,6 @@ import { Callout } from '@/components'
# lazy
-
-
-`lazy` is an experimental feature, so this interface may change.
-
-
-
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
diff --git a/docs/suspensive.org/src/content/ko/docs/react/lazy.mdx b/docs/suspensive.org/src/content/ko/docs/react/lazy.mdx
index af1784c22..10f4d1c9f 100644
--- a/docs/suspensive.org/src/content/ko/docs/react/lazy.mdx
+++ b/docs/suspensive.org/src/content/ko/docs/react/lazy.mdx
@@ -6,12 +6,6 @@ import { Callout } from '@/components'
# lazy
-
-
-`lazy`는 실험 기능이므로 이 인터페이스는 변경될 수 있습니다.
-
-
-
`lazy` 함수는 React의 `lazy` 함수를 래핑하여 컴포넌트 로딩 성공과 실패에 대한 콜백을 제공합니다. 컴포넌트가 성공적으로 로드되거나 실패할 때 사용자 정의 로직을 실행할 수 있어 더 나은 사용자 경험과 디버깅을 제공합니다.
### 컴포넌트 사전 로딩
diff --git a/packages/react/src/lazy.spec.tsx b/packages/react/src/lazy.spec.tsx
index 6e991d555..5e6b30de7 100644
--- a/packages/react/src/lazy.spec.tsx
+++ b/packages/react/src/lazy.spec.tsx
@@ -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(() => {
@@ -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 () => {
@@ -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(
+ error1}>
+
+
+ )
+
+ 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(
+ error2}>
+
+
+ )
+
+ 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
diff --git a/packages/react/src/lazy.ts b/packages/react/src/lazy.ts
index 25d23c8e4..7a3f8da0e 100644
--- a/packages/react/src/lazy.ts
+++ b/packages/react/src/lazy.ts
@@ -1,10 +1,9 @@
'use client'
import { type ComponentType, type LazyExoticComponent, lazy as originalLazy } from 'react'
-import { noop } from './utils/noop'
interface LazyOptions {
- onSuccess?: ({ load }: { load: () => Promise }) => void
- onError?: ({ error, load }: { error: unknown; load: () => Promise }) => undefined
+ onSuccess?: ({ load }: { load: () => Promise<{ default: ComponentType }> }) => void
+ onError?: ({ error, load }: { error: unknown; load: () => Promise<{ default: ComponentType }> }) => void
}
/**
@@ -12,8 +11,6 @@ interface LazyOptions {
*
* 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
@@ -48,33 +45,32 @@ export const createLazy =
load: () => Promise<{ default: T }>,
options?: LazyOptions
): LazyExoticComponent & {
- load: () => Promise
+ load: () => Promise<{ default: T }>
} => {
- const composedOnSuccess = ({ load }: { load: () => Promise }) => {
+ const composedOnSuccess = () => {
options?.onSuccess?.({ load })
defaultOptions.onSuccess?.({ load })
}
- const composedOnError = ({ error, load }: { error: unknown; load: () => Promise }) => {
+ 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 }
)
}
@@ -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'
@@ -122,7 +116,7 @@ export const createLazy =
* ```
*
* @returns A lazy component with additional `load` method for preloading
- * @property {() => Promise} 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({})
@@ -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'
@@ -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)) {
+ reloadStorage.removeItem(storageKey)
+ } else {
+ currentRetryCount = reloadCount
+ }
}
}