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 + } } }