From 184e17ac9a5ff5b4d60fceddee522f7815ae2cfb Mon Sep 17 00:00:00 2001 From: Marshall Ku Date: Sun, 1 Feb 2026 22:42:39 +0900 Subject: [PATCH 1/8] fix(react): Fix logics for setting storage keys --- packages/react/src/lazy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/lazy.ts b/packages/react/src/lazy.ts index 25d23c8e4..a02a23cde 100644 --- a/packages/react/src/lazy.ts +++ b/packages/react/src/lazy.ts @@ -60,7 +60,7 @@ export const createLazy = defaultOptions.onError?.({ error, load }) } - const loadNoReturn = () => load().then(noop) + const loadNoReturn = Object.assign(() => load().then(noop), { toString: () => load.toString() }) return Object.assign( originalLazy(() => load().then( From bc4a258f645c274d18147f149138653355d6aae9 Mon Sep 17 00:00:00 2001 From: Marshall Ku Date: Sun, 1 Feb 2026 22:45:46 +0900 Subject: [PATCH 2/8] test(react): Verify logics for getting storage key --- packages/react/src/lazy.spec.tsx | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/react/src/lazy.spec.tsx b/packages/react/src/lazy.spec.tsx index 6e991d555..366ba00e0 100644 --- a/packages/react/src/lazy.spec.tsx +++ b/packages/react/src/lazy.spec.tsx @@ -636,6 +636,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 From 84f22bdcf2fe184f5950c4e7488a3d2848706033 Mon Sep 17 00:00:00 2001 From: Marshall Ku Date: Sun, 1 Feb 2026 22:47:52 +0900 Subject: [PATCH 3/8] fix(react): Prevent NaN to kill lazy silently --- packages/react/src/lazy.spec.tsx | 7 +++---- packages/react/src/lazy.ts | 7 +++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/react/src/lazy.spec.tsx b/packages/react/src/lazy.spec.tsx index 366ba00e0..ff38073fa 100644 --- a/packages/react/src/lazy.spec.tsx +++ b/packages/react/src/lazy.spec.tsx @@ -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 () => { diff --git a/packages/react/src/lazy.ts b/packages/react/src/lazy.ts index a02a23cde..728fc90de 100644 --- a/packages/react/src/lazy.ts +++ b/packages/react/src/lazy.ts @@ -210,8 +210,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 + } } } From da5d7b405127fe8166dc5e091c8dc3519e80ac64 Mon Sep 17 00:00:00 2001 From: Marshall Ku Date: Sun, 1 Feb 2026 22:49:10 +0900 Subject: [PATCH 4/8] fix(react): Fix return type of onError --- packages/react/src/lazy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/lazy.ts b/packages/react/src/lazy.ts index 728fc90de..b211283e3 100644 --- a/packages/react/src/lazy.ts +++ b/packages/react/src/lazy.ts @@ -4,7 +4,7 @@ import { noop } from './utils/noop' interface LazyOptions { onSuccess?: ({ load }: { load: () => Promise }) => void - onError?: ({ error, load }: { error: unknown; load: () => Promise }) => undefined + onError?: ({ error, load }: { error: unknown; load: () => Promise }) => void } /** From 9bfab0279683ee251dacc099e35d8d47b0231e63 Mon Sep 17 00:00:00 2001 From: Marshall Ku Date: Sun, 1 Feb 2026 22:49:22 +0900 Subject: [PATCH 5/8] test(react): Fix typo in tc --- packages/react/src/lazy.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/lazy.spec.tsx b/packages/react/src/lazy.spec.tsx index ff38073fa..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(() => { From 3970d6e2b6c1bd75345b7f64ebbf9799a1de176e Mon Sep 17 00:00:00 2001 From: Marshall Ku Date: Sun, 1 Feb 2026 22:50:14 +0900 Subject: [PATCH 6/8] docs(react): Remove experimental in lazy --- docs/suspensive.org/src/content/en/docs/react/lazy.mdx | 6 ------ docs/suspensive.org/src/content/ko/docs/react/lazy.mdx | 6 ------ packages/react/src/lazy.ts | 6 ------ 3 files changed, 18 deletions(-) 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.ts b/packages/react/src/lazy.ts index b211283e3..30a195156 100644 --- a/packages/react/src/lazy.ts +++ b/packages/react/src/lazy.ts @@ -12,8 +12,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 @@ -83,8 +81,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' @@ -166,8 +162,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' From 56cfd5b22668342d0c38d33f68fef9ac08b23026 Mon Sep 17 00:00:00 2001 From: Jonghyeon Ko Date: Tue, 3 Feb 2026 21:48:41 +0900 Subject: [PATCH 7/8] Create soft-fishes-warn.md --- .changeset/soft-fishes-warn.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/soft-fishes-warn.md 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 From 1733815800a59bb20230859aa7a78955a440321a Mon Sep 17 00:00:00 2001 From: Marshall Ku Date: Tue, 3 Mar 2026 08:37:32 +0900 Subject: [PATCH 8/8] fix(react): Use load itself instrad assigning toString --- packages/react/src/lazy.ts | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/react/src/lazy.ts b/packages/react/src/lazy.ts index 30a195156..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 }) => void + onSuccess?: ({ load }: { load: () => Promise<{ default: ComponentType }> }) => void + onError?: ({ error, load }: { error: unknown; load: () => Promise<{ default: ComponentType }> }) => void } /** @@ -46,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 = Object.assign(() => load().then(noop), { toString: () => load.toString() }) 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 } ) } @@ -118,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({})