122 lines
3.5 KiB
TypeScript
122 lines
3.5 KiB
TypeScript
/**
|
|
* 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()
|
|
}
|
|
}
|