fix: close workflow console target transitions
This commit is contained in:
@@ -143,7 +143,12 @@ describe("useCapabilityDiscovery", () => {
|
||||
.mockResolvedValueOnce(page([summary("local.documents.read")]))
|
||||
.mockResolvedValueOnce(page([summary("remote.documents.read", "remote.documents")]));
|
||||
client.inspect.mockResolvedValue(detail("local.documents.read"));
|
||||
const { result, rerender } = renderHook(() => useCapabilityDiscovery());
|
||||
const renders: Array<{ items: ReadonlyArray<CapabilitySummary>; selected: CapabilityDetail | null }> = [];
|
||||
const { result, rerender } = renderHook(() => {
|
||||
const current = useCapabilityDiscovery();
|
||||
renders.push({ items: current.items, selected: current.selected });
|
||||
return current;
|
||||
});
|
||||
await waitFor(() => expect(result.current.phase).toBe("ready"));
|
||||
act(() => result.current.inspect("local.documents.read"));
|
||||
await waitFor(() => expect(result.current.selected?.name).toBe("local.documents.read"));
|
||||
@@ -154,10 +159,35 @@ describe("useCapabilityDiscovery", () => {
|
||||
recordEvidence: vi.fn(),
|
||||
readExecutor: {} as ConsoleReadExecutor,
|
||||
});
|
||||
const renderCountBeforeTargetChange = renders.length;
|
||||
rerender();
|
||||
|
||||
expect(renders[renderCountBeforeTargetChange]?.items).toEqual([]);
|
||||
expect(renders[renderCountBeforeTargetChange]?.selected).toBeNull();
|
||||
await waitFor(() => expect(result.current.items[0]?.sourceId).toBe("remote.documents"));
|
||||
expect(result.current.selected).toBeNull();
|
||||
expect(client.list).toHaveBeenLastCalledWith({ limit: 50 });
|
||||
});
|
||||
|
||||
it("reconnects with applied filters instead of unsubmitted filter edits", async () => {
|
||||
client.list
|
||||
.mockResolvedValueOnce(page([summary("local.documents.read")]))
|
||||
.mockResolvedValue(page([summary("remote.documents.read", "remote.documents")]));
|
||||
const { result, rerender } = renderHook(() => useCapabilityDiscovery());
|
||||
await waitFor(() => expect(result.current.phase).toBe("ready"));
|
||||
|
||||
act(() => result.current.setQuery("unsubmitted-query"));
|
||||
act(() => result.current.setSourceId("unsubmitted-source"));
|
||||
|
||||
mockedUseConsoleWorkspace.mockReturnValue({
|
||||
connection: connectedState,
|
||||
connectedTarget: "http://reconnected-workflow.example/rpc",
|
||||
recordEvidence: vi.fn(),
|
||||
readExecutor: {} as ConsoleReadExecutor,
|
||||
});
|
||||
rerender();
|
||||
|
||||
await waitFor(() => expect(result.current.items[0]?.sourceId).toBe("remote.documents"));
|
||||
expect(result.current.selected).toBeNull();
|
||||
expect(client.list).toHaveBeenLastCalledWith({ limit: 50 });
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CapabilityDetail,
|
||||
CapabilitySummary,
|
||||
} from "../domain/capability-models.js";
|
||||
import type { ConsoleReadExecutor } from "../domain/read-executor.js";
|
||||
|
||||
const PAGE_LIMIT = 50;
|
||||
|
||||
@@ -38,6 +39,11 @@ type DiscoveryStateWithAppliedFilters = DiscoveryState & {
|
||||
readonly appliedSourceId: string;
|
||||
};
|
||||
|
||||
type ConnectionProvenance = {
|
||||
readonly readExecutor: ConsoleReadExecutor;
|
||||
readonly connectedTarget: string;
|
||||
};
|
||||
|
||||
const initialState: DiscoveryStateWithAppliedFilters = {
|
||||
phase: "disconnected",
|
||||
query: "",
|
||||
@@ -78,6 +84,14 @@ const appendUnique = (
|
||||
return result;
|
||||
};
|
||||
|
||||
const isSameConnection = (
|
||||
left: ConnectionProvenance | null,
|
||||
right: ConnectionProvenance | null,
|
||||
): boolean => {
|
||||
if (left === null || right === null) return left === right;
|
||||
return left.readExecutor === right.readExecutor && left.connectedTarget === right.connectedTarget;
|
||||
};
|
||||
|
||||
export const useCapabilityDiscovery = (): CapabilityDiscoveryController => {
|
||||
const { connectedTarget, readExecutor } = useConsoleWorkspace();
|
||||
const client = useMemo<CapabilityClient | null>(
|
||||
@@ -87,12 +101,24 @@ export const useCapabilityDiscovery = (): CapabilityDiscoveryController => {
|
||||
const [state, setState] = useState<DiscoveryStateWithAppliedFilters>(initialState);
|
||||
const listGenerationRef = useRef(0);
|
||||
const inspectGenerationRef = useRef(0);
|
||||
const committedProvenanceRef = useRef<ConnectionProvenance | null>(null);
|
||||
const listProvenanceRef = useRef<ConnectionProvenance | null>(null);
|
||||
const selectedProvenanceRef = useRef<ConnectionProvenance | null>(null);
|
||||
const currentProvenance = useMemo<ConnectionProvenance | null>(
|
||||
() =>
|
||||
readExecutor !== null && connectedTarget !== null
|
||||
? { readExecutor, connectedTarget }
|
||||
: null,
|
||||
[connectedTarget, readExecutor],
|
||||
);
|
||||
|
||||
const runList = useCallback(
|
||||
(filters: CapabilityFilters, cursor: string | undefined, append: boolean): void => {
|
||||
if (!client) return;
|
||||
if (!client || currentProvenance === null) return;
|
||||
const requestProvenance = currentProvenance;
|
||||
const generation = ++listGenerationRef.current;
|
||||
if (append === false) inspectGenerationRef.current++;
|
||||
listProvenanceRef.current = requestProvenance;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
appliedQuery: filters.query,
|
||||
@@ -107,7 +133,10 @@ export const useCapabilityDiscovery = (): CapabilityDiscoveryController => {
|
||||
void client
|
||||
.list(requestParams(filters.query, filters.sourceId, cursor))
|
||||
.then((page) => {
|
||||
if (generation !== listGenerationRef.current) return;
|
||||
if (
|
||||
generation !== listGenerationRef.current ||
|
||||
!isSameConnection(requestProvenance, committedProvenanceRef.current)
|
||||
) return;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
phase: "ready",
|
||||
@@ -117,7 +146,10 @@ export const useCapabilityDiscovery = (): CapabilityDiscoveryController => {
|
||||
}));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (generation !== listGenerationRef.current) return;
|
||||
if (
|
||||
generation !== listGenerationRef.current ||
|
||||
!isSameConnection(requestProvenance, committedProvenanceRef.current)
|
||||
) return;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
@@ -125,13 +157,16 @@ export const useCapabilityDiscovery = (): CapabilityDiscoveryController => {
|
||||
}));
|
||||
});
|
||||
},
|
||||
[client],
|
||||
[client, currentProvenance],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
committedProvenanceRef.current = currentProvenance;
|
||||
if (!client || !connectedTarget) {
|
||||
listGenerationRef.current++;
|
||||
inspectGenerationRef.current++;
|
||||
listProvenanceRef.current = null;
|
||||
selectedProvenanceRef.current = null;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
phase: "disconnected",
|
||||
@@ -143,11 +178,15 @@ export const useCapabilityDiscovery = (): CapabilityDiscoveryController => {
|
||||
return;
|
||||
}
|
||||
|
||||
runList({ query: state.query, sourceId: state.sourceId }, undefined, false);
|
||||
runList(
|
||||
{ query: state.appliedQuery, sourceId: state.appliedSourceId },
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
// The executor identity changes with the connected target. Query and source
|
||||
// filters are intentionally retained so reconnecting preserves the view.
|
||||
// filters are intentionally retained so reconnecting preserves the submitted view.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client, connectedTarget, runList]);
|
||||
}, [client, connectedTarget, currentProvenance, readExecutor, runList]);
|
||||
|
||||
const setQuery = useCallback((query: string) => {
|
||||
setState((current) => ({ ...current, query }));
|
||||
@@ -182,24 +221,54 @@ export const useCapabilityDiscovery = (): CapabilityDiscoveryController => {
|
||||
const inspect = useCallback(
|
||||
(qualifiedName: string) => {
|
||||
if (!client) return;
|
||||
if (currentProvenance === null) return;
|
||||
const generation = ++inspectGenerationRef.current;
|
||||
const requestProvenance = currentProvenance;
|
||||
selectedProvenanceRef.current = null;
|
||||
setState((current) => ({ ...current, phase: "loading", selected: null, message: null }));
|
||||
void client
|
||||
.inspect(qualifiedName)
|
||||
.then((detail) => {
|
||||
if (generation !== inspectGenerationRef.current) return;
|
||||
if (
|
||||
generation !== inspectGenerationRef.current ||
|
||||
!isSameConnection(requestProvenance, committedProvenanceRef.current)
|
||||
) return;
|
||||
selectedProvenanceRef.current = requestProvenance;
|
||||
setState((current) => ({ ...current, phase: "ready", selected: detail, message: null }));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (generation !== inspectGenerationRef.current) return;
|
||||
if (
|
||||
generation !== inspectGenerationRef.current ||
|
||||
!isSameConnection(requestProvenance, committedProvenanceRef.current)
|
||||
) return;
|
||||
setState((current) => ({ ...current, phase: "error", message: errorMessage(error) }));
|
||||
});
|
||||
},
|
||||
[client],
|
||||
[client, currentProvenance],
|
||||
);
|
||||
|
||||
const hasCurrentList = isSameConnection(
|
||||
listProvenanceRef.current,
|
||||
currentProvenance,
|
||||
);
|
||||
const hasCurrentSelection = isSameConnection(
|
||||
selectedProvenanceRef.current,
|
||||
currentProvenance,
|
||||
);
|
||||
const visiblePhase =
|
||||
currentProvenance === null
|
||||
? "disconnected"
|
||||
: hasCurrentList
|
||||
? state.phase
|
||||
: "loading";
|
||||
|
||||
return {
|
||||
...state,
|
||||
phase: visiblePhase,
|
||||
items: hasCurrentList ? state.items : [],
|
||||
selected: hasCurrentSelection ? state.selected : null,
|
||||
nextCursor: hasCurrentList ? state.nextCursor : null,
|
||||
message: hasCurrentList ? state.message : null,
|
||||
setQuery,
|
||||
setSourceId,
|
||||
search,
|
||||
|
||||
@@ -130,14 +130,19 @@ describe("useDraftWorkspace", () => {
|
||||
});
|
||||
|
||||
it("does not expose a loaded detail during URL or target transitions", async () => {
|
||||
client.list.mockResolvedValue(page([]));
|
||||
client.list.mockResolvedValue(page([workspace("draft-first")]));
|
||||
client.load
|
||||
.mockResolvedValueOnce(workspace("draft-first"))
|
||||
.mockReturnValueOnce(deferred<DraftWorkspace>().promise)
|
||||
.mockReturnValueOnce(deferred<DraftWorkspace>().promise);
|
||||
|
||||
const renders: Array<{ items: ReadonlyArray<DraftWorkspace>; selected: DraftWorkspace | null }> = [];
|
||||
const { result, rerender } = renderHook(
|
||||
({ workspaceId }: { workspaceId: string | null }) => useDraftWorkspace(workspaceId),
|
||||
({ workspaceId }: { workspaceId: string | null }) => {
|
||||
const current = useDraftWorkspace(workspaceId);
|
||||
renders.push({ items: current.items, selected: current.selected });
|
||||
return current;
|
||||
},
|
||||
{ initialProps: { workspaceId: "draft-first" } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.selected?.workspaceId).toBe("draft-first"));
|
||||
@@ -148,8 +153,10 @@ describe("useDraftWorkspace", () => {
|
||||
recordEvidence: vi.fn(),
|
||||
readExecutor: {} as ConsoleReadExecutor,
|
||||
});
|
||||
const renderCountBeforeTargetChange = renders.length;
|
||||
rerender({ workspaceId: "draft-first" });
|
||||
expect(result.current.selected).toBeNull();
|
||||
expect(renders[renderCountBeforeTargetChange]?.items).toEqual([]);
|
||||
expect(renders[renderCountBeforeTargetChange]?.selected).toBeNull();
|
||||
|
||||
rerender({ workspaceId: "draft-second" });
|
||||
expect(result.current.selected).toBeNull();
|
||||
|
||||
@@ -28,7 +28,12 @@ type StoredDraftSelection = {
|
||||
readonly workspace: DraftWorkspace;
|
||||
readonly workspaceId: string;
|
||||
readonly connectedTarget: string;
|
||||
readonly connectionGeneration: number;
|
||||
readonly readExecutor: ConsoleReadExecutor;
|
||||
};
|
||||
|
||||
type DraftReadProvenance = {
|
||||
readonly readExecutor: ConsoleReadExecutor;
|
||||
readonly connectedTarget: string;
|
||||
};
|
||||
|
||||
type DraftWorkspaceState = Omit<DraftWorkspaceController, "refresh" | "selected"> & {
|
||||
@@ -47,6 +52,14 @@ const initialState: DraftWorkspaceState = {
|
||||
const errorMessage = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
const isCurrentProvenance = (
|
||||
current: DraftReadProvenance | null,
|
||||
request: DraftReadProvenance,
|
||||
): boolean =>
|
||||
current !== null &&
|
||||
request.readExecutor === current.readExecutor &&
|
||||
request.connectedTarget === current.connectedTarget;
|
||||
|
||||
export const useDraftWorkspace = (
|
||||
workspaceId: string | null,
|
||||
): DraftWorkspaceController => {
|
||||
@@ -65,27 +78,27 @@ export const useDraftWorkspace = (
|
||||
readonly connectedTarget: string | null;
|
||||
readonly workspaceId: string | null;
|
||||
} | null>(null);
|
||||
const connectionSignatureRef = useRef<{
|
||||
readonly readExecutor: ConsoleReadExecutor | null;
|
||||
readonly connectedTarget: string | null;
|
||||
} | null>(null);
|
||||
const connectionGenerationRef = useRef(0);
|
||||
const connectionSignature = { readExecutor, connectedTarget };
|
||||
const previousConnection = connectionSignatureRef.current;
|
||||
if (
|
||||
previousConnection === null ||
|
||||
previousConnection.readExecutor !== readExecutor ||
|
||||
previousConnection.connectedTarget !== connectedTarget
|
||||
) {
|
||||
connectionGenerationRef.current++;
|
||||
connectionSignatureRef.current = connectionSignature;
|
||||
}
|
||||
const listProvenanceRef = useRef<DraftReadProvenance | null>(null);
|
||||
const committedProvenanceRef = useRef<DraftReadProvenance | null>(null);
|
||||
|
||||
const currentProvenance = useMemo<DraftReadProvenance | null>(
|
||||
() =>
|
||||
readExecutor !== null && connectedTarget !== null
|
||||
? {
|
||||
readExecutor,
|
||||
connectedTarget,
|
||||
}
|
||||
: null,
|
||||
[connectedTarget, readExecutor],
|
||||
);
|
||||
|
||||
const runList = useCallback((force = false) => {
|
||||
if (!client || !connectedTarget) return;
|
||||
if (!client || currentProvenance === null) return;
|
||||
if (listPendingRef.current && !force) return;
|
||||
listPendingRef.current = false;
|
||||
const generation = ++listGenerationRef.current;
|
||||
const requestProvenance = currentProvenance;
|
||||
listProvenanceRef.current = requestProvenance;
|
||||
listPendingRef.current = true;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
@@ -96,7 +109,10 @@ export const useDraftWorkspace = (
|
||||
void client
|
||||
.list()
|
||||
.then((page) => {
|
||||
if (generation !== listGenerationRef.current) return;
|
||||
if (
|
||||
generation !== listGenerationRef.current ||
|
||||
!isCurrentProvenance(committedProvenanceRef.current, requestProvenance)
|
||||
) return;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
listPhase: "ready",
|
||||
@@ -105,7 +121,10 @@ export const useDraftWorkspace = (
|
||||
}));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (generation !== listGenerationRef.current) return;
|
||||
if (
|
||||
generation !== listGenerationRef.current ||
|
||||
!isCurrentProvenance(committedProvenanceRef.current, requestProvenance)
|
||||
) return;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
listPhase: "error",
|
||||
@@ -115,7 +134,7 @@ export const useDraftWorkspace = (
|
||||
.finally(() => {
|
||||
if (generation === listGenerationRef.current) listPendingRef.current = false;
|
||||
});
|
||||
}, [client, connectedTarget]);
|
||||
}, [client, currentProvenance]);
|
||||
|
||||
const runDetail = useCallback(
|
||||
(nextWorkspaceId: string | null, force = false) => {
|
||||
@@ -144,7 +163,7 @@ export const useDraftWorkspace = (
|
||||
}
|
||||
|
||||
const requestTarget = connectedTarget;
|
||||
const requestConnectionGeneration = connectionGenerationRef.current;
|
||||
const requestProvenance = currentProvenance;
|
||||
detailPendingRef.current = true;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
@@ -158,7 +177,8 @@ export const useDraftWorkspace = (
|
||||
.then((detail) => {
|
||||
if (
|
||||
generation !== detailGenerationRef.current ||
|
||||
requestConnectionGeneration !== connectionGenerationRef.current
|
||||
requestProvenance === null ||
|
||||
!isCurrentProvenance(committedProvenanceRef.current, requestProvenance)
|
||||
) return;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
@@ -167,13 +187,17 @@ export const useDraftWorkspace = (
|
||||
workspace: detail,
|
||||
workspaceId: nextWorkspaceId,
|
||||
connectedTarget: requestTarget,
|
||||
connectionGeneration: requestConnectionGeneration,
|
||||
readExecutor: requestProvenance.readExecutor,
|
||||
},
|
||||
detailMessage: null,
|
||||
}));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (generation !== detailGenerationRef.current) return;
|
||||
if (
|
||||
generation !== detailGenerationRef.current ||
|
||||
requestProvenance === null ||
|
||||
!isCurrentProvenance(committedProvenanceRef.current, requestProvenance)
|
||||
) return;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
detailPhase: "error",
|
||||
@@ -185,10 +209,11 @@ export const useDraftWorkspace = (
|
||||
if (generation === detailGenerationRef.current) detailPendingRef.current = false;
|
||||
});
|
||||
},
|
||||
[client, connectedTarget],
|
||||
[client, connectedTarget, currentProvenance],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
committedProvenanceRef.current = currentProvenance;
|
||||
const previousRequest = observedRequestRef.current;
|
||||
const connectionChanged =
|
||||
previousRequest === null ||
|
||||
@@ -203,6 +228,7 @@ export const useDraftWorkspace = (
|
||||
detailGenerationRef.current++;
|
||||
listPendingRef.current = false;
|
||||
detailPendingRef.current = false;
|
||||
listProvenanceRef.current = null;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
listPhase: "disconnected",
|
||||
@@ -234,7 +260,7 @@ export const useDraftWorkspace = (
|
||||
} else if (workspaceChanged) {
|
||||
runDetail(workspaceId, true);
|
||||
}
|
||||
}, [client, connectedTarget, readExecutor, runDetail, runList, workspaceId]);
|
||||
}, [client, connectedTarget, currentProvenance, readExecutor, runDetail, runList, workspaceId]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!client || !connectedTarget) return;
|
||||
@@ -249,9 +275,34 @@ export const useDraftWorkspace = (
|
||||
connectedTarget !== null &&
|
||||
storedSelection.workspaceId === workspaceId &&
|
||||
storedSelection.connectedTarget === connectedTarget &&
|
||||
storedSelection.connectionGeneration === connectionGenerationRef.current
|
||||
storedSelection.readExecutor === readExecutor
|
||||
? storedSelection.workspace
|
||||
: null;
|
||||
|
||||
return { ...state, selected, refresh };
|
||||
const hasCurrentList =
|
||||
currentProvenance !== null &&
|
||||
listProvenanceRef.current !== null &&
|
||||
isCurrentProvenance(currentProvenance, listProvenanceRef.current);
|
||||
const visibleListPhase =
|
||||
currentProvenance === null
|
||||
? "disconnected"
|
||||
: hasCurrentList
|
||||
? state.listPhase
|
||||
: "loading";
|
||||
const visibleDetailPhase =
|
||||
currentProvenance === null
|
||||
? "disconnected"
|
||||
: storedSelection !== null && selected === null
|
||||
? "loading"
|
||||
: state.detailPhase;
|
||||
|
||||
return {
|
||||
...state,
|
||||
listPhase: visibleListPhase,
|
||||
detailPhase: visibleDetailPhase,
|
||||
items: hasCurrentList ? state.items : [],
|
||||
listMessage: hasCurrentList ? state.listMessage : null,
|
||||
selected,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user