diff --git a/src/lifecycle/components/TransitionPresence/TransitionPresence.tsx b/src/lifecycle/components/TransitionPresence/TransitionPresence.tsx index 6fbd2b28..83d787d0 100644 --- a/src/lifecycle/components/TransitionPresence/TransitionPresence.tsx +++ b/src/lifecycle/components/TransitionPresence/TransitionPresence.tsx @@ -71,24 +71,30 @@ export function TransitionPresence({ const abortController = new AbortController(); (async (): Promise => { - onPreviousChildrenUnmountingRef.current?.(previousChildren, children); + try { + onPreviousChildrenUnmountingRef.current?.(previousChildren, children); - // Defer children update for before unmount lifecycle - await beforeUnmountPreviousChildren(abortController.signal); + // Defer children update for before unmount lifecycle + await beforeUnmountPreviousChildren(abortController.signal); - setPreviousChildren(null); + setPreviousChildren(null); - // Wait a tick after removing previous children to make sure new children - // are re-initialized - await createTimeout(); + // Wait a tick after removing previous children to make sure new children + // are re-initialized + await createTimeout(0, { signal: abortController.signal }); - onPreviousChildrenUnmountedRef.current?.(previousChildren, children); + onPreviousChildrenUnmountedRef.current?.(previousChildren, children); - // Set new children - setPreviousChildren(children); - await createTimeout(); + // Set new children + setPreviousChildren(children); + await createTimeout(0, { signal: abortController.signal }); - onChildrenMountedRef.current?.(previousChildren, children); + onChildrenMountedRef.current?.(previousChildren, children); + } catch (error) { + if (error !== abortController.signal.reason) { + throw new Error(`Unexpected error in TransitionPresence transition`, { cause: error }); + } + } })(); return () => { diff --git a/src/utils/createTimeout/createTimeout.mdx b/src/utils/createTimeout/createTimeout.mdx index 4a039623..261c7568 100644 --- a/src/utils/createTimeout/createTimeout.mdx +++ b/src/utils/createTimeout/createTimeout.mdx @@ -9,12 +9,18 @@ Creates a promise that resolves after given timeout. ## Reference ```ts -createTimeout(ms = 0): Promise +createTimeout(ms = 0, options?: { signal?: AbortSignal }): Promise ``` +### Parameters + +- `ms` - The number of milliseconds to wait before resolving. Defaults to `0`. +- `options.signal` - An optional `AbortSignal` to cancel the timeout. When aborted, the promise + rejects with the signal's reason. + ### Returns -Promise that resolves after given timeout. +Promise that resolves after given timeout, or rejects if the provided `signal` is aborted. ## Usage @@ -26,6 +32,24 @@ await createTimeout(1000); console.log("I'm called after 1 second"); ``` +Using with an `AbortSignal` to cancel the timeout + +```ts +const controller = new AbortController(); + +// Abort the timeout after 500ms +setTimeout(() => { + controller.abort(); +}, 500); + +try { + await createTimeout(2000, { signal: controller.signal }); + console.log('Timeout completed'); +} catch { + console.log('Timeout was cancelled'); +} +``` + Using `Promise.then` ```ts diff --git a/src/utils/createTimeout/createTimeout.test.ts b/src/utils/createTimeout/createTimeout.test.ts index c4731a80..76dbf57d 100644 --- a/src/utils/createTimeout/createTimeout.test.ts +++ b/src/utils/createTimeout/createTimeout.test.ts @@ -27,4 +27,35 @@ describe('createTimeout', () => { expect(currentTime - startTime >= timeout).toBe(true); }); + + it('should reject immediately when signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + + await expect(createTimeout(100, { signal: controller.signal })).rejects.toThrow(); + }); + + it('should reject when signal is aborted before timeout', async () => { + const controller = new AbortController(); + + const promise = createTimeout(1000, { signal: controller.signal }); + + controller.abort(); + + await expect(promise).rejects.toThrow(); + }); + + it('should resolve normally when no signal is provided', async () => { + await expect(createTimeout(0)).resolves.toBeUndefined(); + }); + + it('should reject with the abort reason', async () => { + const controller = new AbortController(); + const reason = new Error('custom abort reason'); + controller.abort(reason); + + await expect(createTimeout(100, { signal: controller.signal })).rejects.toThrow( + 'custom abort reason', + ); + }); }); diff --git a/src/utils/createTimeout/createTimeout.ts b/src/utils/createTimeout/createTimeout.ts index 16cf73e6..8f10f00c 100644 --- a/src/utils/createTimeout/createTimeout.ts +++ b/src/utils/createTimeout/createTimeout.ts @@ -1,9 +1,33 @@ /** * Returns a promise that resolves after given amount of time. Use the default * value of `0` to wait for the next tick. + * + * @param ms - The number of milliseconds to wait before resolving. Defaults to `0`. + * @param options - Optional options object. + * @param options.signal - An optional AbortSignal to cancel the timeout. When aborted, the promise rejects with the signal's reason. */ -export function createTimeout(ms = 0): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); +export function createTimeout(ms = 0, { signal }: { signal?: AbortSignal } = {}): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason); + return; + } + + if (signal === undefined) { + setTimeout(resolve, ms); + return; + } + + const onAbort = (): void => { + clearTimeout(timeoutId); + reject(signal.reason); + }; + + const timeoutId = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + + signal.addEventListener('abort', onAbort, { once: true }); }); }