/ src / utils / QueryGuard.ts
QueryGuard.ts
  1  /**
  2   * Synchronous state machine for the query lifecycle, compatible with
  3   * React's `useSyncExternalStore`.
  4   *
  5   * Three states:
  6   *   idle        → no query, safe to dequeue and process
  7   *   dispatching → an item was dequeued, async chain hasn't reached onQuery yet
  8   *   running     → onQuery called tryStart(), query is executing
  9   *
 10   * Transitions:
 11   *   idle → dispatching  (reserve)
 12   *   dispatching → running  (tryStart)
 13   *   idle → running  (tryStart, for direct user submissions)
 14   *   running → idle  (end / forceEnd)
 15   *   dispatching → idle  (cancelReservation, when processQueueIfReady fails)
 16   *
 17   * `isActive` returns true for both dispatching and running, preventing
 18   * re-entry from the queue processor during the async gap.
 19   *
 20   * Usage with React:
 21   *   const queryGuard = useRef(new QueryGuard()).current
 22   *   const isQueryActive = useSyncExternalStore(
 23   *     queryGuard.subscribe,
 24   *     queryGuard.getSnapshot,
 25   *   )
 26   */
 27  import { createSignal } from './signal.js'
 28  
 29  export class QueryGuard {
 30    private _status: 'idle' | 'dispatching' | 'running' = 'idle'
 31    private _generation = 0
 32    private _changed = createSignal()
 33  
 34    /**
 35     * Reserve the guard for queue processing. Transitions idle → dispatching.
 36     * Returns false if not idle (another query or dispatch in progress).
 37     */
 38    reserve(): boolean {
 39      if (this._status !== 'idle') return false
 40      this._status = 'dispatching'
 41      this._notify()
 42      return true
 43    }
 44  
 45    /**
 46     * Cancel a reservation when processQueueIfReady had nothing to process.
 47     * Transitions dispatching → idle.
 48     */
 49    cancelReservation(): void {
 50      if (this._status !== 'dispatching') return
 51      this._status = 'idle'
 52      this._notify()
 53    }
 54  
 55    /**
 56     * Start a query. Returns the generation number on success,
 57     * or null if a query is already running (concurrent guard).
 58     * Accepts transitions from both idle (direct user submit)
 59     * and dispatching (queue processor path).
 60     */
 61    tryStart(): number | null {
 62      if (this._status === 'running') return null
 63      this._status = 'running'
 64      ++this._generation
 65      this._notify()
 66      return this._generation
 67    }
 68  
 69    /**
 70     * End a query. Returns true if this generation is still current
 71     * (meaning the caller should perform cleanup). Returns false if a
 72     * newer query has started (stale finally block from a cancelled query).
 73     */
 74    end(generation: number): boolean {
 75      if (this._generation !== generation) return false
 76      if (this._status !== 'running') return false
 77      this._status = 'idle'
 78      this._notify()
 79      return true
 80    }
 81  
 82    /**
 83     * Force-end the current query regardless of generation.
 84     * Used by onCancel where any running query should be terminated.
 85     * Increments generation so stale finally blocks from the cancelled
 86     * query's promise rejection will see a mismatch and skip cleanup.
 87     */
 88    forceEnd(): void {
 89      if (this._status === 'idle') return
 90      this._status = 'idle'
 91      ++this._generation
 92      this._notify()
 93    }
 94  
 95    /**
 96     * Is the guard active (dispatching or running)?
 97     * Always synchronous — not subject to React state batching delays.
 98     */
 99    get isActive(): boolean {
100      return this._status !== 'idle'
101    }
102  
103    get generation(): number {
104      return this._generation
105    }
106  
107    // --
108    // useSyncExternalStore interface
109  
110    /** Subscribe to state changes. Stable reference — safe as useEffect dep. */
111    subscribe = this._changed.subscribe
112  
113    /** Snapshot for useSyncExternalStore. Returns `isActive`. */
114    getSnapshot = (): boolean => {
115      return this._status !== 'idle'
116    }
117  
118    private _notify(): void {
119      this._changed.emit()
120    }
121  }