| 1 | import { useLayoutEffect, useMemo, useRef } from "react"; |
| 2 | import { useCommittedSlot, type CommittedSlot } from "../lib/useCommittedSlot"; |
| 3 | import { CommandCancelled, executeCapturedCommand, type CommandOutcome } from "../lib/commandOutcome"; |
| 4 | import { createOperationOwner, operationTargetsEqual, type OperationTarget } from "./operationOwner"; |
| 5 | import { createSessionSurfaceFence, type SessionSurfaceOwnership } from "./sessionTarget"; |
| 6 | import { trackAppOperation } from "./appLifecycleProbe"; |
| 7 | |
| 8 | export type SessionResource = Readonly<{ tabId: string; sessionKey: string }>; |
| 9 | export type SessionOperationAuthority = { |
| 10 | checkpoint(): void; |
| 11 | ownsUI(): boolean; |
| 12 | }; |
| 13 | type Input = { visible: SessionResource; resources?: readonly OperationTarget[] }; |
| 14 | type State = { |
| 15 | owner: ReturnType<typeof createOperationOwner>; |
| 16 | surface: ReturnType<typeof createSessionSurfaceFence>; |
| 17 | epoch: number; |
| 18 | }; |
| 19 | |
| 20 | function authorityFor(state: State, slot: CommittedSlot<Input>, target: OperationTarget, channel: string) { |
| 21 | const epoch = slot.epoch; |
| 22 | state.epoch = state.owner.mount(); |
| 23 | const identity = state.owner.begin(target, undefined, JSON.stringify([target, channel])); |
| 24 | const surface: SessionSurfaceOwnership | undefined = state.surface.capture(); |
| 25 | const authority: SessionOperationAuthority = { |
| 26 | checkpoint() { |
| 27 | if (slot.phase !== "ready" || epoch !== slot.epoch) throw new CommandCancelled("disposed"); |
| 28 | if (!state.owner.owns(identity) || (slot.value?.resources && !slot.value.resources.some(resource => operationTargetsEqual(resource, target)))) { |
| 29 | throw new CommandCancelled("superseded"); |
| 30 | } |
| 31 | }, |
| 32 | ownsUI() { |
| 33 | try { this.checkpoint(); } catch { return false; } |
| 34 | return Boolean(surface && state.surface.owns(surface) && (target.kind !== "session" || (surface.tabId === target.tabId && surface.sessionKey === target.sessionKey))); |
| 35 | }, |
| 36 | }; |
| 37 | return { identity, authority }; |
| 38 | } |
| 39 | |
| 40 | // Stable entry is created outside render. The executor receives no capture callback. |
| 41 | function bindOperations(state: State, slot: CommittedSlot<Input>) { |
| 42 | return async <Input, Result>( |
| 43 | target: OperationTarget, channel: string, input: Input, |
| 44 | execute: (input: Input, authority: SessionOperationAuthority) => Result, |
| 45 | ): Promise<CommandOutcome<Awaited<Result>>> => { |
| 46 | if (slot.phase !== "ready" || (target.kind === "session" && !target.tabId)) return { status: "cancelled", reason: slot.phase === "disposed" ? "disposed" : "not-ready" }; |
| 47 | const { identity, authority } = authorityFor(state, slot, Object.freeze({ ...target }), channel); |
| 48 | const result = await executeCapturedCommand(input, execute, authority); |
| 49 | const uiOwned = authority.ownsUI(); |
| 50 | state.owner.finish(identity, result.status); |
| 51 | return result.status === "failed" && !uiOwned ? { status: "cancelled", reason: "superseded" } : result; |
| 52 | }; |
| 53 | } |
| 54 | |
| 55 | /** A source request may finish on A while B is visible; only its UI rights expire. */ |
| 56 | export function useResourceOperations(input: Input) { |
| 57 | const slot = useCommittedSlot(input); |
| 58 | const stateRef = useRef<State | null>(null); |
| 59 | if (!stateRef.current) stateRef.current = { owner: createOperationOwner(trackAppOperation), surface: createSessionSurfaceFence(), epoch: 0 }; |
| 60 | const state = stateRef.current; |
| 61 | useLayoutEffect(() => { state.surface.commit(input.visible.tabId, input.visible.sessionKey); }, [input.visible.tabId, input.visible.sessionKey, state]); |
| 62 | useLayoutEffect(() => () => { |
| 63 | state.surface.dispose(); |
| 64 | state.owner.unmount(state.epoch); |
| 65 | }, [state]); |
| 66 | return useMemo(() => bindOperations(state, slot), [state, slot]); |
| 67 | } |
| 68 |