preact-missing-hooks
Version:
A lightweight, extendable collection of missing React-like hooks for Preact — plus fresh, powerful new ones designed specifically for modern Preact apps.
166 lines (146 loc) • 5.34 kB
text/typescript
import { useState, useCallback, useRef, useEffect } from "preact/hooks";
/** Lower number = higher priority. Default priority when not specified. */
const DEFAULT_PRIORITY = 1;
export type ThreadedWorkerMode = "sequential" | "parallel";
export interface UseThreadedWorkerOptions {
/** Sequential: single worker, priority-ordered. Parallel: worker pool. */
mode: ThreadedWorkerMode;
/** Max concurrent workers. Only used when mode is "parallel". Default 4. */
concurrency?: number;
}
export interface RunOptions {
/** 1 = highest priority. Lower number runs first. FIFO within same priority. */
priority?: number;
}
interface QueuedTask<TData, TResult> {
data: TData;
priority: number;
sequence: number;
resolve: (value: TResult) => void;
reject: (reason: unknown) => void;
}
export interface UseThreadedWorkerReturn<TData, TResult> {
/** Enqueue work. Returns a Promise that resolves with the worker result. */
run: (data: TData, options?: RunOptions) => Promise<TResult>;
/** True while any task is queued or running. */
loading: boolean;
/** Result of the most recently completed successful task. */
result: TResult | undefined;
/** Error from the most recently failed task. */
error: unknown;
/** Number of tasks currently queued + running. */
queueSize: number;
/** Clear all pending (not yet started) tasks. Running tasks continue. */
clearQueue: () => void;
/** Stop accepting new work and clear pending queue. Running tasks finish. */
terminate: () => void;
}
/**
* Production-grade hook to run async work in a queue with optional priority
* and either sequential or parallel execution.
*
* @param workerFn - Async function to run for each task (e.g. API call, heavy compute).
* @param options - mode: "sequential" | "parallel", concurrency (parallel only).
* @returns run, loading, result, error, queueSize, clearQueue, terminate.
*/
export function useThreadedWorker<TData, TResult>(
workerFn: (data: TData) => Promise<TResult>,
options: UseThreadedWorkerOptions
): UseThreadedWorkerReturn<TData, TResult> {
const { mode, concurrency = 4 } = options;
const maxConcurrent = mode === "sequential" ? 1 : Math.max(1, concurrency);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<TResult | undefined>(undefined);
const [error, setError] = useState<unknown>(undefined);
const [queueSize, setQueueSize] = useState(0);
const queueRef = useRef<QueuedTask<TData, TResult>[]>([]);
const sequenceRef = useRef(0);
const activeCountRef = useRef(0);
const terminatedRef = useRef(false);
const workerFnRef = useRef(workerFn);
workerFnRef.current = workerFn;
const updateQueueSize = useCallback(() => {
setQueueSize(queueRef.current.length + activeCountRef.current);
}, []);
const processNext = useCallback(() => {
if (terminatedRef.current) return;
if (activeCountRef.current >= maxConcurrent) return;
if (queueRef.current.length === 0) {
if (activeCountRef.current === 0) setLoading(false);
updateQueueSize();
return;
}
// Sort by priority (asc), then by sequence (FIFO within same priority).
queueRef.current.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
return a.sequence - b.sequence;
});
const task = queueRef.current.shift()!;
activeCountRef.current += 1;
setLoading(true);
updateQueueSize();
const fn = workerFnRef.current;
fn(task.data)
.then((value) => {
setResult(value);
setError(undefined);
task.resolve(value);
})
.catch((err) => {
setError(err);
task.reject(err);
})
.finally(() => {
activeCountRef.current -= 1;
updateQueueSize();
processNext();
});
// Fill remaining slots (parallel mode).
if (queueRef.current.length > 0 && activeCountRef.current < maxConcurrent) {
processNext();
}
}, [maxConcurrent, updateQueueSize]);
const run = useCallback(
(data: TData, runOptions?: RunOptions): Promise<TResult> => {
if (terminatedRef.current) {
return Promise.reject(new Error("Worker is terminated"));
}
const priority = runOptions?.priority ?? DEFAULT_PRIORITY;
const sequence = ++sequenceRef.current;
const promise = new Promise<TResult>((resolve, reject) => {
queueRef.current.push({ data, priority, sequence, resolve, reject });
});
updateQueueSize();
setLoading(true);
queueMicrotask(processNext);
return promise;
},
[processNext, updateQueueSize]
);
const clearQueue = useCallback(() => {
const pending = queueRef.current;
queueRef.current = [];
pending.forEach((t) => t.reject(new Error("Task cleared from queue")));
updateQueueSize();
if (activeCountRef.current === 0) setLoading(false);
}, [updateQueueSize]);
const terminate = useCallback(() => {
terminatedRef.current = true;
clearQueue();
}, [clearQueue]);
// Reset terminated on unmount so the same hook instance can't be "revived" without options change.
useEffect(() => {
return () => {
terminatedRef.current = true;
};
}, []);
return {
run,
loading,
result,
error,
queueSize,
clearQueue,
terminate,
};
}