feat(store): initial release (#279)

This commit is contained in:
rahim
2026-01-02 14:41:58 +11:00
committed by GitHub
parent c9a216dd2d
commit d74e4e6701
54 changed files with 6047 additions and 66 deletions
+212
View File
@@ -0,0 +1,212 @@
import { describe, expect, it, vi } from 'vitest';
import { Disposer } from './disposer';
describe('disposer', () => {
describe('constructor', () => {
it('creates a disposer with size 0', () => {
const disposer = new Disposer();
expect(disposer.size).toBe(0);
});
it('tracks size as cleanups are added', () => {
const disposer = new Disposer();
disposer.add(() => {});
expect(disposer.size).toBe(1);
disposer.add(() => {});
expect(disposer.size).toBe(2);
});
});
describe('add', () => {
it('adds cleanup functions', () => {
const disposer = new Disposer();
const cleanup1 = vi.fn();
const cleanup2 = vi.fn();
disposer.add(cleanup1);
disposer.add(cleanup2);
expect(disposer.size).toBe(2);
});
it('does not call cleanups when adding', () => {
const disposer = new Disposer();
const cleanup = vi.fn();
disposer.add(cleanup);
expect(cleanup).not.toHaveBeenCalled();
});
});
describe('dispose', () => {
it('calls all cleanup functions', () => {
const disposer = new Disposer();
const cleanup1 = vi.fn();
const cleanup2 = vi.fn();
const cleanup3 = vi.fn();
disposer.add(cleanup1);
disposer.add(cleanup2);
disposer.add(cleanup3);
disposer.dispose();
expect(cleanup1).toHaveBeenCalledOnce();
expect(cleanup2).toHaveBeenCalledOnce();
expect(cleanup3).toHaveBeenCalledOnce();
});
it('clears the disposer after dispose', () => {
const disposer = new Disposer();
disposer.add(() => {});
disposer.add(() => {});
expect(disposer.size).toBe(2);
disposer.dispose();
expect(disposer.size).toBe(0);
});
it('can be called multiple times safely', () => {
const disposer = new Disposer();
const cleanup = vi.fn();
disposer.add(cleanup);
disposer.dispose();
disposer.dispose();
expect(cleanup).toHaveBeenCalledOnce();
});
it('allows adding new cleanups after dispose', () => {
const disposer = new Disposer();
const cleanup1 = vi.fn();
const cleanup2 = vi.fn();
disposer.add(cleanup1);
disposer.dispose();
disposer.add(cleanup2);
disposer.dispose();
expect(cleanup1).toHaveBeenCalledOnce();
expect(cleanup2).toHaveBeenCalledOnce();
});
});
describe('disposeAsync', () => {
it('calls all cleanup functions', async () => {
const disposer = new Disposer();
const cleanup1 = vi.fn();
const cleanup2 = vi.fn();
disposer.add(cleanup1);
disposer.add(cleanup2);
await disposer.disposeAsync();
expect(cleanup1).toHaveBeenCalledOnce();
expect(cleanup2).toHaveBeenCalledOnce();
});
it('awaits async cleanup functions', async () => {
const disposer = new Disposer();
const order: string[] = [];
disposer.add(async () => {
await Promise.resolve();
order.push('async1');
});
disposer.add(() => {
order.push('sync');
});
disposer.add(async () => {
await Promise.resolve();
order.push('async2');
});
await disposer.disposeAsync();
expect(order).toContain('async1');
expect(order).toContain('sync');
expect(order).toContain('async2');
});
it('clears the disposer after disposeAsync', async () => {
const disposer = new Disposer();
disposer.add(async () => {});
expect(disposer.size).toBe(1);
await disposer.disposeAsync();
expect(disposer.size).toBe(0);
});
it('handles mixed sync and async cleanups', async () => {
const disposer = new Disposer();
const results: number[] = [];
disposer.add(() => {
results.push(1);
});
disposer.add(async () => {
await new Promise(r => setTimeout(r, 10));
results.push(2);
});
disposer.add(() => {
results.push(3);
});
await disposer.disposeAsync();
expect(results).toHaveLength(3);
expect(results).toContain(1);
expect(results).toContain(2);
expect(results).toContain(3);
});
});
describe('integration', () => {
it('works with real-world cleanup patterns', () => {
const disposer = new Disposer();
// Simulating event listener cleanup
const listeners = new Map<string, () => void>();
const addEventListener = (type: string, handler: () => void) => {
listeners.set(type, handler);
return () => {
listeners.delete(type);
};
};
disposer.add(addEventListener('click', () => {}));
disposer.add(addEventListener('keydown', () => {}));
expect(listeners.size).toBe(2);
disposer.dispose();
expect(listeners.size).toBe(0);
});
it('works with timer cleanup patterns', () => {
vi.useFakeTimers();
const disposer = new Disposer();
let timerFired = false;
const id = setTimeout(() => {
timerFired = true;
}, 1000);
disposer.add(() => clearTimeout(id));
disposer.dispose();
vi.advanceTimersByTime(2000);
expect(timerFired).toBe(false);
vi.useRealTimers();
});
});
});
+62
View File
@@ -0,0 +1,62 @@
/**
* A cleanup function that may be sync or async.
*/
export type CleanupFn = () => void | Promise<void>;
/**
* A collector for cleanup functions.
*
* Allows registering multiple cleanup functions and disposing them all at once.
*
* @example
* ```ts
* const disposer = new Disposer();
*
* disposer.add(listen(video, 'play', handlePlay));
* disposer.add(listen(video, 'pause', handlePause));
* disposer.add(animationFrame(render));
*
* // Later, clean up everything at once
* disposer.dispose();
* // or for async cleanups:
* await disposer.disposeAsync();
* ```
*/
export class Disposer {
#cleanups = new Set<CleanupFn>();
/**
* Number of registered cleanup functions.
*/
get size(): number {
return this.#cleanups.size;
}
/**
* Add a cleanup function to the collection.
*/
add(cleanup: CleanupFn): void {
this.#cleanups.add(cleanup);
}
/**
* Run all cleanup functions synchronously.
*
* Note: If any cleanup functions return promises, they will not be awaited.
* Use `disposeAsync()` if you have async cleanup functions.
*/
dispose(): void {
for (const cleanup of this.#cleanups) {
cleanup();
}
this.#cleanups.clear();
}
/**
* Run all cleanup functions, awaiting any promises.
*/
async disposeAsync(): Promise<void> {
await Promise.all([...this.#cleanups].map(cleanup => cleanup()));
this.#cleanups.clear();
}
}
@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { animationFrame } from './animation-frame';
describe('animationFrame', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('calls the callback on next animation frame', async () => {
const callback = vi.fn();
animationFrame(callback);
expect(callback).not.toHaveBeenCalled();
await vi.runAllTimersAsync();
expect(callback).toHaveBeenCalledOnce();
});
it('passes timestamp to callback', async () => {
const callback = vi.fn();
animationFrame(callback);
await vi.runAllTimersAsync();
expect(callback).toHaveBeenCalledWith(expect.any(Number));
});
it('returns a cleanup function', () => {
const callback = vi.fn();
const cancel = animationFrame(callback);
expect(cancel).toBeTypeOf('function');
});
it('cancel prevents callback from being called', async () => {
const callback = vi.fn();
const cancel = animationFrame(callback);
cancel();
await vi.runAllTimersAsync();
expect(callback).not.toHaveBeenCalled();
});
it('cancel can be called multiple times safely', async () => {
const callback = vi.fn();
const cancel = animationFrame(callback);
cancel();
cancel();
cancel();
await vi.runAllTimersAsync();
expect(callback).not.toHaveBeenCalled();
});
it('allows multiple independent animation frames', async () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
const callback3 = vi.fn();
animationFrame(callback1);
animationFrame(callback2);
const cancel3 = animationFrame(callback3);
cancel3();
await vi.runAllTimersAsync();
expect(callback1).toHaveBeenCalledOnce();
expect(callback2).toHaveBeenCalledOnce();
expect(callback3).not.toHaveBeenCalled();
});
});
+20
View File
@@ -0,0 +1,20 @@
/**
* Request an animation frame and return a cleanup function to cancel it.
*
* @param callback - The callback to invoke on the next animation frame
* @returns A cleanup function that cancels the animation frame request
*
* @example
* ```ts
* const cancel = animationFrame((time) => {
* console.log('Frame at', time);
* });
*
* // Later, cancel if needed
* cancel();
* ```
*/
export function animationFrame(callback: FrameRequestCallback): () => void {
const id = requestAnimationFrame(callback);
return () => cancelAnimationFrame(id);
}
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from 'vitest';
import { onEvent } from './event';
describe('onEvent', () => {
it('returns a promise', () => {
const target = new EventTarget();
const promise = onEvent(target, 'click');
expect(promise).toBeInstanceOf(Promise);
// Dispatch to resolve
target.dispatchEvent(new Event('click'));
});
it('resolves with the event when it fires', async () => {
const target = new EventTarget();
const event = new Event('click');
const promise = onEvent(target, 'click');
target.dispatchEvent(event);
await expect(promise).resolves.toBe(event);
});
it('only listens for the first occurrence (once)', async () => {
const target = new EventTarget();
const promise = onEvent(target, 'click');
target.dispatchEvent(new Event('click'));
target.dispatchEvent(new Event('click'));
const result = await promise;
expect(result).toBeInstanceOf(Event);
});
it('rejects when signal is already aborted', async () => {
const target = new EventTarget();
const controller = new AbortController();
controller.abort();
const promise = onEvent(target, 'click', { signal: controller.signal });
await expect(promise).rejects.toThrow();
});
it('rejects with AbortError when signal is aborted', async () => {
const target = new EventTarget();
const controller = new AbortController();
const promise = onEvent(target, 'click', { signal: controller.signal });
controller.abort();
await expect(promise).rejects.toMatchObject({
name: 'AbortError',
});
});
it('rejects with custom abort reason', async () => {
const target = new EventTarget();
const controller = new AbortController();
const reason = new Error('Custom reason');
const promise = onEvent(target, 'click', { signal: controller.signal });
controller.abort(reason);
await expect(promise).rejects.toBe(reason);
});
it('cleans up abort listener when event fires', async () => {
const target = new EventTarget();
const controller = new AbortController();
const promise = onEvent(target, 'click', { signal: controller.signal });
target.dispatchEvent(new Event('click'));
await promise;
// Aborting after resolution should not cause issues
controller.abort();
});
it('passes options to addEventListener', async () => {
const target = new EventTarget();
const addSpy = vi.spyOn(target, 'addEventListener');
const promise = onEvent(target, 'click', { passive: true, capture: true });
target.dispatchEvent(new Event('click'));
await promise;
expect(addSpy).toHaveBeenCalledWith(
'click',
expect.any(Function),
expect.objectContaining({
passive: true,
capture: true,
once: true,
}),
);
});
it('works with HTMLMediaElement events', async () => {
const video = document.createElement('video');
const promise = onEvent(video, 'play');
video.dispatchEvent(new Event('play'));
const event = await promise;
expect(event.type).toBe('play');
});
it('works with Window events', async () => {
const promise = onEvent(window, 'resize');
window.dispatchEvent(new Event('resize'));
const event = await promise;
expect(event.type).toBe('resize');
});
it('works with Document events', async () => {
const promise = onEvent(document, 'visibilitychange');
document.dispatchEvent(new Event('visibilitychange'));
const event = await promise;
expect(event.type).toBe('visibilitychange');
});
});
+124
View File
@@ -0,0 +1,124 @@
export interface OnEventOptions extends AddEventListenerOptions {
/**
* An AbortSignal to cancel waiting for the event.
*
* If aborted, the returned promise will reject with an `AbortError`.
*/
signal?: AbortSignal;
}
/**
* Wait for an event to occur on a target.
*
* @param target - The event target (HTMLMediaElement)
* @param type - The event type to wait for
* @param options - Optional event options including AbortSignal
* @returns A promise that resolves with the event
*/
export function onEvent<K extends keyof HTMLMediaElementEventMap>(
target: HTMLMediaElement,
type: K,
options?: OnEventOptions,
): Promise<HTMLMediaElementEventMap[K]>;
/**
* Wait for an event to occur on a target.
*
* @param target - The event target (HTMLElement)
* @param type - The event type to wait for
* @param options - Optional event options including AbortSignal
* @returns A promise that resolves with the event
*/
export function onEvent<K extends keyof HTMLElementEventMap>(
target: HTMLElement,
type: K,
options?: OnEventOptions,
): Promise<HTMLElementEventMap[K]>;
/**
* Wait for an event to occur on a target.
*
* @param target - The event target (Window)
* @param type - The event type to wait for
* @param options - Optional event options including AbortSignal
* @returns A promise that resolves with the event
*/
export function onEvent<K extends keyof WindowEventMap>(
target: Window,
type: K,
options?: OnEventOptions,
): Promise<WindowEventMap[K]>;
/**
* Wait for an event to occur on a target.
*
* @param target - The event target (Document)
* @param type - The event type to wait for
* @param options - Optional event options including AbortSignal
* @returns A promise that resolves with the event
*/
export function onEvent<K extends keyof DocumentEventMap>(
target: Document,
type: K,
options?: OnEventOptions,
): Promise<DocumentEventMap[K]>;
/**
* Wait for an event to occur on a target.
*
* @param target - The event target
* @param type - The event type to wait for
* @param options - Optional event options including AbortSignal
* @returns A promise that resolves with the event
*
* @example
* ```ts
* // Wait for video to be seeked
* const event = await onEvent(video, 'seeked');
* ```
*
* @example
* ```ts
* // With AbortSignal for cancellation
* const controller = new AbortController();
*
* try {
* const event = await onEvent(video, 'seeked', { signal: controller.signal });
* } catch (e) {
* if (e.name === 'AbortError') {
* console.log('Cancelled waiting for event');
* }
* }
*
* // Cancel from elsewhere
* controller.abort();
* ```
*/
export function onEvent(target: EventTarget, type: string, options?: OnEventOptions): Promise<Event>;
export function onEvent(target: EventTarget, type: string, options?: OnEventOptions): Promise<Event> {
return new Promise((resolve, reject) => {
const handleAbort = () => {
reject(options?.signal?.reason ?? new DOMException('Aborted', 'AbortError'));
};
// If already aborted, reject immediately
if (options?.signal?.aborted) {
handleAbort();
return;
}
// Listen for abort
options?.signal?.addEventListener('abort', handleAbort, { once: true });
// Listen for the event
target.addEventListener(
type,
(event) => {
options?.signal?.removeEventListener('abort', handleAbort);
resolve(event);
},
{ ...options, once: true },
);
});
}
@@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { idleCallback } from './idle-callback';
describe('idleCallback', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('calls the callback when idle', async () => {
const callback = vi.fn();
idleCallback(callback);
expect(callback).not.toHaveBeenCalled();
await vi.runAllTimersAsync();
expect(callback).toHaveBeenCalledOnce();
});
it('passes deadline object to callback', async () => {
const callback = vi.fn();
idleCallback(callback);
await vi.runAllTimersAsync();
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
didTimeout: expect.any(Boolean),
timeRemaining: expect.any(Function),
}),
);
});
it('returns a cleanup function', () => {
const callback = vi.fn();
const cancel = idleCallback(callback);
expect(cancel).toBeTypeOf('function');
});
it('cancel prevents callback from being called', async () => {
const callback = vi.fn();
const cancel = idleCallback(callback);
cancel();
await vi.runAllTimersAsync();
expect(callback).not.toHaveBeenCalled();
});
it('cancel can be called multiple times safely', async () => {
const callback = vi.fn();
const cancel = idleCallback(callback);
cancel();
cancel();
cancel();
await vi.runAllTimersAsync();
expect(callback).not.toHaveBeenCalled();
});
it('allows multiple independent idle callbacks', async () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
const callback3 = vi.fn();
idleCallback(callback1);
idleCallback(callback2);
const cancel3 = idleCallback(callback3);
cancel3();
await vi.runAllTimersAsync();
expect(callback1).toHaveBeenCalledOnce();
expect(callback2).toHaveBeenCalledOnce();
expect(callback3).not.toHaveBeenCalled();
});
it('accepts options parameter', async () => {
const callback = vi.fn();
idleCallback(callback, { timeout: 1000 });
await vi.runAllTimersAsync();
expect(callback).toHaveBeenCalledOnce();
});
});
+44
View File
@@ -0,0 +1,44 @@
import { supportsIdleCallback } from './supports';
/**
* Request an idle callback and return a cleanup function to cancel it.
*
* Falls back to `setTimeout` with 1ms delay in environments that don't
* support `requestIdleCallback` (e.g., Safari).
*
* @param callback - The callback to invoke when the browser is idle
* @param options - Optional idle callback options (timeout, etc.)
* @returns A cleanup function that cancels the idle callback request
*
* @example
* ```ts
* const cancel = idleCallback((deadline) => {
* console.log('Time remaining:', deadline.timeRemaining());
* });
*
* // Later, cancel if needed
* cancel();
* ```
*
* @example
* ```ts
* // With timeout option
* const cancel = idleCallback(doWork, { timeout: 1000 });
* ```
*/
export function idleCallback(callback: IdleRequestCallback, options?: IdleRequestOptions): () => void {
if (supportsIdleCallback()) {
const id = requestIdleCallback(callback, options);
return () => cancelIdleCallback(id);
}
// Fallback for Safari and other browsers without requestIdleCallback
const id = setTimeout(() => {
callback({
didTimeout: false,
timeRemaining: () => 50, // Approximate idle time
});
}, 1);
return () => clearTimeout(id);
}
+5
View File
@@ -0,0 +1,5 @@
export { animationFrame } from './animation-frame';
export { onEvent, type OnEventOptions } from './event';
export { idleCallback } from './idle-callback';
export { listen } from './listen';
export { supportsAnimationFrame, supportsIdleCallback } from './supports';
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, it, vi } from 'vitest';
import { listen } from './listen';
describe('listen', () => {
it('adds an event listener', () => {
const target = new EventTarget();
const handler = vi.fn();
listen(target, 'click', handler);
target.dispatchEvent(new Event('click'));
expect(handler).toHaveBeenCalledOnce();
});
it('returns a cleanup function', () => {
const target = new EventTarget();
const handler = vi.fn();
const cleanup = listen(target, 'click', handler);
expect(cleanup).toBeTypeOf('function');
});
it('cleanup removes the listener', () => {
const target = new EventTarget();
const handler = vi.fn();
const cleanup = listen(target, 'click', handler);
cleanup();
target.dispatchEvent(new Event('click'));
expect(handler).not.toHaveBeenCalled();
});
it('passes options to addEventListener', () => {
const target = new EventTarget();
const handler = vi.fn();
listen(target, 'click', handler, { once: true });
target.dispatchEvent(new Event('click'));
target.dispatchEvent(new Event('click'));
expect(handler).toHaveBeenCalledOnce();
});
it('passes capture option correctly', () => {
const parent = document.createElement('div');
const child = document.createElement('span');
parent.appendChild(child);
const order: string[] = [];
listen(parent, 'click', () => order.push('parent-capture'), { capture: true });
listen(child, 'click', () => order.push('child'));
listen(parent, 'click', () => order.push('parent-bubble'));
child.dispatchEvent(new Event('click', { bubbles: true }));
expect(order).toEqual(['parent-capture', 'child', 'parent-bubble']);
});
it('cleanup works with options', () => {
const target = new EventTarget();
const handler = vi.fn();
const cleanup = listen(target, 'click', handler, { passive: true });
cleanup();
target.dispatchEvent(new Event('click'));
expect(handler).not.toHaveBeenCalled();
});
it('works with AbortSignal in options', () => {
const target = new EventTarget();
const handler = vi.fn();
const controller = new AbortController();
listen(target, 'click', handler, { signal: controller.signal });
target.dispatchEvent(new Event('click'));
expect(handler).toHaveBeenCalledOnce();
controller.abort();
target.dispatchEvent(new Event('click'));
expect(handler).toHaveBeenCalledOnce();
});
it('provides correct event type for typed targets', () => {
const video = document.createElement('video');
const handler = vi.fn((event: Event) => {
expect(event.type).toBe('play');
});
listen(video, 'play', handler);
video.dispatchEvent(new Event('play'));
expect(handler).toHaveBeenCalledOnce();
});
it('works with Window', () => {
const handler = vi.fn();
const cleanup = listen(window, 'resize', handler);
window.dispatchEvent(new Event('resize'));
expect(handler).toHaveBeenCalledOnce();
cleanup();
});
it('works with Document', () => {
const handler = vi.fn();
const cleanup = listen(document, 'visibilitychange', handler);
document.dispatchEvent(new Event('visibilitychange'));
expect(handler).toHaveBeenCalledOnce();
cleanup();
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* Add an event listener and return a cleanup function to remove it.
*
* @param target - The event target (HTMLMediaElement)
* @param type - The event type
* @param listener - The event listener
* @param options - Optional event listener options
* @returns A cleanup function that removes the event listener
*/
export function listen<K extends keyof HTMLMediaElementEventMap>(
target: HTMLMediaElement,
type: K,
listener: (event: HTMLMediaElementEventMap[K]) => void,
options?: AddEventListenerOptions,
): () => void;
/**
* Add an event listener and return a cleanup function to remove it.
*
* @param target - The event target (HTMLElement)
* @param type - The event type
* @param listener - The event listener
* @param options - Optional event listener options
* @returns A cleanup function that removes the event listener
*/
export function listen<K extends keyof HTMLElementEventMap>(
target: HTMLElement,
type: K,
listener: (event: HTMLElementEventMap[K]) => void,
options?: AddEventListenerOptions,
): () => void;
/**
* Add an event listener and return a cleanup function to remove it.
*
* @param target - The event target (Window)
* @param type - The event type
* @param listener - The event listener
* @param options - Optional event listener options
* @returns A cleanup function that removes the event listener
*/
export function listen<K extends keyof WindowEventMap>(
target: Window,
type: K,
listener: (event: WindowEventMap[K]) => void,
options?: AddEventListenerOptions,
): () => void;
/**
* Add an event listener and return a cleanup function to remove it.
*
* @param target - The event target (Document)
* @param type - The event type
* @param listener - The event listener
* @param options - Optional event listener options
* @returns A cleanup function that removes the event listener
*/
export function listen<K extends keyof DocumentEventMap>(
target: Document,
type: K,
listener: (event: DocumentEventMap[K]) => void,
options?: AddEventListenerOptions,
): () => void;
/**
* Add an event listener and return a cleanup function to remove it.
*
* @param target - The event target
* @param type - The event type
* @param listener - The event listener
* @param options - Optional event listener options
* @returns A cleanup function that removes the event listener
*
* @example
* ```ts
* const cleanup = listen(video, 'play', () => console.log('playing'));
*
* // Later, remove the listener
* cleanup();
* ```
*
* @example
* ```ts
* // With options
* const cleanup = listen(video, 'play', handler, { once: true, passive: true });
* ```
*
* @example
* ```ts
* // With AbortSignal (native browser support)
* const controller = new AbortController();
* listen(video, 'play', handler, { signal: controller.signal });
*
* // Later, abort to remove the listener
* controller.abort();
* ```
*/
export function listen(
target: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions,
): () => void;
export function listen(
target: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions,
): () => void {
target.addEventListener(type, listener, options);
return () => target.removeEventListener(type, listener, options);
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { supportsAnimationFrame, supportsIdleCallback } from './supports';
describe('supports', () => {
describe('supportsAnimationFrame', () => {
it('returns a boolean', () => {
const result = supportsAnimationFrame();
expect(typeof result).toBe('boolean');
});
it('returns true in browser environment', () => {
expect(supportsAnimationFrame()).toBe(true);
});
});
describe('supportsIdleCallback', () => {
it('returns a boolean', () => {
const result = supportsIdleCallback();
// Note: requestIdleCallback may or may not be available in jsdom
// depending on the version, so we just check it returns a boolean
expect(typeof result).toBe('boolean');
});
});
});
+17
View File
@@ -0,0 +1,17 @@
/**
* Check if `requestIdleCallback` is supported.
*
* @returns `true` if `requestIdleCallback` is available
*/
export function supportsIdleCallback(): boolean {
return typeof requestIdleCallback === 'function';
}
/**
* Check if `requestAnimationFrame` is supported.
*
* @returns `true` if `requestAnimationFrame` is available
*/
export function supportsAnimationFrame(): boolean {
return typeof requestAnimationFrame === 'function';
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import { isEventLike } from './event-like';
describe('event-like', () => {
describe('isEventLike', () => {
it('returns true for objects with type and timeStamp', () => {
expect(isEventLike({ type: 'click', timeStamp: 123 })).toBe(true);
expect(isEventLike({ type: 'play', timeStamp: 0 })).toBe(true);
expect(isEventLike({ type: '', timeStamp: Date.now() })).toBe(true);
});
it('returns true for objects with additional properties', () => {
expect(
isEventLike({
type: 'click',
timeStamp: 123,
isTrusted: true,
target: null,
}),
).toBe(true);
});
it('returns true for DOM Events', () => {
const event = new Event('click');
expect(isEventLike(event)).toBe(true);
});
it('returns true for CustomEvent', () => {
const event = new CustomEvent('custom', { detail: { foo: 'bar' } });
expect(isEventLike(event)).toBe(true);
});
it('returns false for missing type', () => {
expect(isEventLike({ timeStamp: 123 })).toBe(false);
});
it('returns false for missing timeStamp', () => {
expect(isEventLike({ type: 'click' })).toBe(false);
});
it('returns false for non-string type', () => {
expect(isEventLike({ type: 123, timeStamp: 123 })).toBe(false);
expect(isEventLike({ type: null, timeStamp: 123 })).toBe(false);
expect(isEventLike({ type: undefined, timeStamp: 123 })).toBe(false);
});
it('returns false for non-number timeStamp', () => {
expect(isEventLike({ type: 'click', timeStamp: '123' })).toBe(false);
expect(isEventLike({ type: 'click', timeStamp: null })).toBe(false);
expect(isEventLike({ type: 'click', timeStamp: undefined })).toBe(false);
});
it('returns false for null', () => {
expect(isEventLike(null)).toBe(false);
});
it('returns false for undefined', () => {
expect(isEventLike(undefined)).toBe(false);
});
it('returns false for primitives', () => {
expect(isEventLike('event')).toBe(false);
expect(isEventLike(123)).toBe(false);
expect(isEventLike(true)).toBe(false);
});
it('returns false for arrays', () => {
expect(isEventLike([])).toBe(false);
expect(isEventLike(['click', 123])).toBe(false);
});
it('returns false for functions', () => {
expect(isEventLike(() => {})).toBe(false);
});
});
});
+22
View File
@@ -0,0 +1,22 @@
import { isNumber, isObject, isString } from './predicate';
export interface EventLike {
type: string;
timeStamp: number;
isTrusted?: boolean;
}
/**
* Check if a value looks like an Event (has type and timeStamp).
*
* Works with DOM Events, React SyntheticEvents, and RN events.
*/
export function isEventLike(value: unknown): value is EventLike {
return (
isObject(value)
&& 'type' in value
&& isString(value.type)
&& 'timeStamp' in value
&& isNumber(value.timeStamp)
);
}
+3
View File
@@ -0,0 +1,3 @@
export * from './disposer';
export * from './event-like';
export * from './predicate';
+203
View File
@@ -0,0 +1,203 @@
import { describe, expect, it } from 'vitest';
import {
isAbortError,
isBoolean,
isFunction,
isNil,
isNull,
isNumber,
isObject,
isPromise,
isString,
isUndefined,
} from './predicate';
describe('predicate', () => {
describe('isString', () => {
it('returns true for strings', () => {
expect(isString('')).toBe(true);
expect(isString('hello')).toBe(true);
expect(isString('123')).toBe(true);
});
it('returns false for non-strings', () => {
expect(isString(123)).toBe(false);
expect(isString(null)).toBe(false);
expect(isString(undefined)).toBe(false);
expect(isString({})).toBe(false);
expect(isString([])).toBe(false);
expect(isString(true)).toBe(false);
});
});
describe('isNumber', () => {
it('returns true for numbers', () => {
expect(isNumber(0)).toBe(true);
expect(isNumber(123)).toBe(true);
expect(isNumber(-1)).toBe(true);
expect(isNumber(3.14)).toBe(true);
expect(isNumber(Number.NaN)).toBe(true);
expect(isNumber(Infinity)).toBe(true);
});
it('returns false for non-numbers', () => {
expect(isNumber('123')).toBe(false);
expect(isNumber(null)).toBe(false);
expect(isNumber(undefined)).toBe(false);
expect(isNumber({})).toBe(false);
expect(isNumber([])).toBe(false);
expect(isNumber(true)).toBe(false);
});
});
describe('isBoolean', () => {
it('returns true for booleans', () => {
expect(isBoolean(true)).toBe(true);
expect(isBoolean(false)).toBe(true);
});
it('returns false for non-booleans', () => {
expect(isBoolean(0)).toBe(false);
expect(isBoolean(1)).toBe(false);
expect(isBoolean('true')).toBe(false);
expect(isBoolean(null)).toBe(false);
expect(isBoolean(undefined)).toBe(false);
expect(isBoolean({})).toBe(false);
});
});
describe('isFunction', () => {
it('returns true for functions', () => {
expect(isFunction(() => {})).toBe(true);
expect(isFunction(() => {})).toBe(true);
expect(isFunction(async () => {})).toBe(true);
expect(isFunction(class {})).toBe(true);
expect(isFunction(Array.isArray)).toBe(true);
});
it('returns false for non-functions', () => {
expect(isFunction({})).toBe(false);
expect(isFunction([])).toBe(false);
expect(isFunction('function')).toBe(false);
expect(isFunction(null)).toBe(false);
expect(isFunction(undefined)).toBe(false);
});
});
describe('isNull', () => {
it('returns true for null', () => {
expect(isNull(null)).toBe(true);
});
it('returns false for non-null', () => {
expect(isNull(undefined)).toBe(false);
expect(isNull(0)).toBe(false);
expect(isNull('')).toBe(false);
expect(isNull(false)).toBe(false);
expect(isNull({})).toBe(false);
});
});
describe('isUndefined', () => {
it('returns true for undefined', () => {
expect(isUndefined(undefined)).toBe(true);
expect(isUndefined(void 0)).toBe(true);
});
it('returns false for non-undefined', () => {
expect(isUndefined(null)).toBe(false);
expect(isUndefined(0)).toBe(false);
expect(isUndefined('')).toBe(false);
expect(isUndefined(false)).toBe(false);
expect(isUndefined({})).toBe(false);
});
});
describe('isNil', () => {
it('returns true for null and undefined', () => {
expect(isNil(null)).toBe(true);
expect(isNil(undefined)).toBe(true);
});
it('returns false for non-nil values', () => {
expect(isNil(0)).toBe(false);
expect(isNil('')).toBe(false);
expect(isNil(false)).toBe(false);
expect(isNil({})).toBe(false);
expect(isNil([])).toBe(false);
expect(isNil(Number.NaN)).toBe(false);
});
});
describe('isPromise', () => {
it('returns true for promises', () => {
expect(isPromise(Promise.resolve())).toBe(true);
// eslint-disable-next-line prefer-promise-reject-errors
expect(isPromise(Promise.reject().catch(() => {}))).toBe(true);
expect(isPromise(new Promise(() => {}))).toBe(true);
});
it('returns false for non-promises', () => {
expect(isPromise({})).toBe(false);
expect(isPromise({ then: () => {} })).toBe(false); // thenable but not Promise
expect(isPromise(null)).toBe(false);
expect(isPromise(undefined)).toBe(false);
expect(isPromise(() => {})).toBe(false);
});
});
describe('isObject', () => {
it('returns true for objects', () => {
expect(isObject({})).toBe(true);
expect(isObject([])).toBe(true);
expect(isObject(new Date())).toBe(true);
expect(isObject(/regex/)).toBe(true);
expect(isObject(new Map())).toBe(true);
expect(isObject(new Set())).toBe(true);
});
it('returns false for null', () => {
expect(isObject(null)).toBe(false);
});
it('returns false for primitives', () => {
expect(isObject(undefined)).toBe(false);
expect(isObject('string')).toBe(false);
expect(isObject(123)).toBe(false);
expect(isObject(true)).toBe(false);
// eslint-disable-next-line symbol-description
expect(isObject(Symbol())).toBe(false);
});
it('returns false for functions', () => {
expect(isObject(() => {})).toBe(false);
});
});
describe('isAbortError', () => {
it('returns true for AbortError', () => {
const error = new DOMException('Aborted', 'AbortError');
expect(isAbortError(error)).toBe(true);
});
it('returns true for custom AbortError', () => {
const error = new Error('Aborted');
error.name = 'AbortError';
expect(isAbortError(error)).toBe(true);
});
it('returns false for other errors', () => {
expect(isAbortError(new Error('test'))).toBe(false);
expect(isAbortError(new TypeError('test'))).toBe(false);
expect(isAbortError(new RangeError('test'))).toBe(false);
});
it('returns false for non-errors', () => {
expect(isAbortError(null)).toBe(false);
expect(isAbortError(undefined)).toBe(false);
expect(isAbortError('AbortError')).toBe(false);
expect(isAbortError({ name: 'AbortError' })).toBe(false);
});
});
});
+48
View File
@@ -0,0 +1,48 @@
export function isString(value: unknown): value is string {
return typeof value === 'string';
}
export function isNumber(value: unknown): value is number {
return typeof value === 'number';
}
export function isBoolean(value: unknown): value is boolean {
return typeof value === 'boolean';
}
export function isFunction(value: unknown): value is (...args: any[]) => any {
return typeof value === 'function';
}
export function isNull(value: unknown): value is null {
return value === null;
}
export function isUndefined(value: unknown): value is undefined {
return typeof value === 'undefined';
}
export function isNil(value: unknown): value is null | undefined {
return value == null;
}
export function isPromise(value: unknown): value is Promise<any> {
return value instanceof Promise;
}
/**
* Check if a value is an object, excluding null.
*/
export function isObject(value: unknown): value is object {
return value !== null && typeof value === 'object';
}
/**
* Check if a value is an AbortError.
*/
export function isAbortError(value: unknown): value is Error {
return (
value instanceof Error
&& value.name === 'AbortError'
);
}