| /** | |
| * Synchronous state machine for the query lifecycle, compatible with | |
| * React's `useSyncExternalStore`. | |
| * | |
| * Three states: | |
| * idle β no query, safe to dequeue and process | |
| * dispatching β an item was dequeued, async chain hasn't reached onQuery yet | |
| * running β onQuery called tryStart(), query is executing | |
| * | |
| * Transitions: | |
| * idle β dispatching (reserve) | |
| * dispatching β running (tryStart) | |
| * idle β running (tryStart, for direct user submissions) | |
| * running β idle (end / forceEnd) | |
| * dispatching β idle (cancelReservation, when processQueueIfReady fails) | |
| * | |
| * `isActive` returns true for both dispatching and running, preventing | |
| * re-entry from the queue processor during the async gap. | |
| * | |
| * Usage with React: | |
| * const queryGuard = useRef(new QueryGuard()).current | |
| * const isQueryActive = useSyncExternalStore( | |
| * queryGuard.subscribe, | |
| * queryGuard.getSnapshot, | |
| * ) | |
| */ | |
| import { createSignal } from './signal.js' | |
| export class QueryGuard { | |
| private _status: 'idle' | 'dispatching' | 'running' = 'idle' | |
| private _generation = 0 | |
| private _changed = createSignal() | |
| /** | |
| * Reserve the guard for queue processing. Transitions idle β dispatching. | |
| * Returns false if not idle (another query or dispatch in progress). | |
| */ | |
| reserve(): boolean { | |
| if (this._status !== 'idle') return false | |
| this._status = 'dispatching' | |
| this._notify() | |
| return true | |
| } | |
| /** | |
| * Cancel a reservation when processQueueIfReady had nothing to process. | |
| * Transitions dispatching β idle. | |
| */ | |
| cancelReservation(): void { | |
| if (this._status !== 'dispatching') return | |
| this._status = 'idle' | |
| this._notify() | |
| } | |
| /** | |
| * Start a query. Returns the generation number on success, | |
| * or null if a query is already running (concurrent guard). | |
| * Accepts transitions from both idle (direct user submit) | |
| * and dispatching (queue processor path). | |
| */ | |
| tryStart(): number | null { | |
| if (this._status === 'running') return null | |
| this._status = 'running' | |
| ++this._generation | |
| this._notify() | |
| return this._generation | |
| } | |
| /** | |
| * End a query. Returns true if this generation is still current | |
| * (meaning the caller should perform cleanup). Returns false if a | |
| * newer query has started (stale finally block from a cancelled query). | |
| */ | |
| end(generation: number): boolean { | |
| if (this._generation !== generation) return false | |
| if (this._status !== 'running') return false | |
| this._status = 'idle' | |
| this._notify() | |
| return true | |
| } | |
| /** | |
| * Force-end the current query regardless of generation. | |
| * Used by onCancel where any running query should be terminated. | |
| * Increments generation so stale finally blocks from the cancelled | |
| * query's promise rejection will see a mismatch and skip cleanup. | |
| */ | |
| forceEnd(): void { | |
| if (this._status === 'idle') return | |
| this._status = 'idle' | |
| ++this._generation | |
| this._notify() | |
| } | |
| /** | |
| * Is the guard active (dispatching or running)? | |
| * Always synchronous β not subject to React state batching delays. | |
| */ | |
| get isActive(): boolean { | |
| return this._status !== 'idle' | |
| } | |
| get generation(): number { | |
| return this._generation | |
| } | |
| // -- | |
| // useSyncExternalStore interface | |
| /** Subscribe to state changes. Stable reference β safe as useEffect dep. */ | |
| subscribe = this._changed.subscribe | |
| /** Snapshot for useSyncExternalStore. Returns `isActive`. */ | |
| getSnapshot = (): boolean => { | |
| return this._status !== 'idle' | |
| } | |
| private _notify(): void { | |
| this._changed.emit() | |
| } | |
| } | |