Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions packages/studio/src/components/editor/TopologyLens.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ function finish(
act(() => studioEditLifecycle.finish(callId, result));
}

function finishFastWrite(
callId: string,
stage: "dispatched" | "saved" | "verified" | "failed",
changed = true,
): void {
finish(callId, stage, changed);
expect(host?.querySelector('[data-topology-lens="acquiring"]')).not.toBeNull();
act(() => vi.advanceTimersByTime(240));
}

beforeEach(() => {
vi.useFakeTimers();
geometryMock.measure.mockReset().mockReturnValue({
Expand Down Expand Up @@ -136,6 +146,10 @@ describe("TopologyLens", () => {
expect(host?.querySelector('[data-topology-scan="true"]')).not.toBeNull();

finish(callId, "saved");
expect(host?.querySelector('[data-topology-lens="acquiring"]')).not.toBeNull();
expect(host?.querySelector("[data-topology-seal]")).toBeNull();

act(() => vi.advanceTimersByTime(240));
expect(host?.querySelector('[data-topology-lens="sealing"]')).not.toBeNull();
expect(
host
Expand Down Expand Up @@ -179,6 +193,7 @@ describe("TopologyLens", () => {
expect(host?.querySelector<HTMLElement>("[data-topology-target]")?.style.left).toBe("10px");

finish(callId, "verified");
act(() => vi.advanceTimersByTime(240));

const sealedTarget = host?.querySelector<HTMLElement>("[data-topology-target]");
expect(sealedTarget?.style.left).toBe("70px");
Expand All @@ -202,8 +217,7 @@ describe("TopologyLens", () => {
mount();
const callId = begin();

finish(callId, stage);

finishFastWrite(callId, stage);
expect(host?.querySelector('[data-topology-lens="localizing"]')).not.toBeNull();
expect(host?.querySelector("[data-topology-seal]")).toBeNull();
act(() => vi.advanceTimersByTime(180));
Expand All @@ -215,8 +229,7 @@ describe("TopologyLens", () => {
mount();
const callId = begin();

finish(callId, stage, false);

finishFastWrite(callId, stage, false);
expect(host?.querySelector('[data-topology-lens="localizing"]')).not.toBeNull();
expect(host?.querySelector('[data-topology-terminal="no-change"]')).not.toBeNull();
expect(host?.querySelector("[data-topology-seal]")).toBeNull();
Expand All @@ -229,6 +242,8 @@ describe("TopologyLens", () => {
const callId = begin();
finish(callId, "saved");
act(() => vi.advanceTimersByTime(240));
expect(host?.querySelector('[data-topology-lens="sealing"]')).not.toBeNull();
act(() => vi.advanceTimersByTime(240));
expect(studioEditLifecycle.getSnapshot()).toEqual({ phase: "idle" });

act(() => root?.unmount());
Expand All @@ -254,14 +269,15 @@ describe("TopologyLens", () => {
expect(host?.querySelector('[data-topology-lens="acquiring"]')).not.toBeNull();
});

it("clears on iframe reload and project switch", () => {
it("remeasures through iframe reload and clears on project switch", () => {
mount();
begin();
expect(geometryMock.measure).toHaveBeenCalledTimes(1);

act(() => iframe?.dispatchEvent(new Event("load")));
expect(host?.querySelector('[data-topology-lens="hidden"]')).not.toBeNull();
expect(host?.querySelector('[data-topology-lens="acquiring"]')).not.toBeNull();
expect(geometryMock.measure).toHaveBeenCalledTimes(2);

begin();
act(() => studioEditLifecycle.activateProject("project-b"));
expect(host?.querySelector('[data-topology-lens="hidden"]')).not.toBeNull();
});
Expand Down
8 changes: 4 additions & 4 deletions packages/studio/src/components/editor/TopologyLens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export function TopologyLens({ iframeRef, activeCompositionPath }: TopologyLensP
const state = useTopologyLensState();
const reducedMotion = useSyncExternalStore(subscribeReducedMotion, getReducedMotion, () => false);
const [measured, setMeasured] = useState<MeasuredLens | null>(null);
const [iframeRevision, remeasureIframe] = useReducer((revision: number) => revision + 1, 0);
const callId = state.phase === "hidden" ? null : state.callId;
const handle = state.phase === "hidden" ? null : state.target.handle;
const phase = state.phase;
Expand All @@ -111,14 +112,13 @@ export function TopologyLens({ iframeRef, activeCompositionPath }: TopologyLensP
handle,
});
setMeasured(geometry ? { callId, geometry } : null);
}, [activeCompositionPath, callId, handle, iframeRef, phase]);
}, [activeCompositionPath, callId, handle, iframeRef, iframeRevision, phase]);

useEffect(() => {
const iframe = iframeRef.current;
if (!iframe || !callId) return;
const dismiss = () => studioEditLifecycle.dismiss(callId);
iframe.addEventListener("load", dismiss);
return () => iframe.removeEventListener("load", dismiss);
iframe.addEventListener("load", remeasureIframe);
return () => iframe.removeEventListener("load", remeasureIframe);
}, [callId, iframeRef]);

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,18 @@ describe("Topology Lens state", () => {
).toMatchObject({ phase: "localizing", callId: "call-repeat" });
});

it("lets a fast durable receipt interrupt acquisition and seal once", () => {
it("holds a fast durable receipt until acquisition finishes, then seals once", () => {
const acquiring = reduceTopologyLens(hidden(), { type: "lifecycle", value: lifecycle() });
const sealing = reduceTopologyLens(acquiring, {
const waiting = reduceTopologyLens(acquiring, {
type: "lifecycle",
value: lifecycle({ phase: "verified" }),
});
const sealing = reduceTopologyLens(waiting, {
type: "acquisition-elapsed",
callId: "call-a",
});

expect(waiting).toMatchObject({ phase: "acquiring" });
expect(sealing).toMatchObject({ phase: "sealing", receiptStage: "verified" });
expect(reduceTopologyLens(sealing, { type: "lifecycle", value: { phase: "idle" } })).toEqual(
hidden(),
Expand Down
51 changes: 31 additions & 20 deletions packages/studio/src/components/editor/topologyLensState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ type TopologyLensBase = {
operation: StudioWriteOperation;
};

type ActiveLifecycle = Exclude<StudioEditLifecycleState, { phase: "idle" }>;

export type TopologyLensState =
| { phase: "hidden" }
| (TopologyLensBase & { phase: "acquiring" })
| (TopologyLensBase & { phase: "acquiring"; pendingLifecycle: ActiveLifecycle | null })
| (TopologyLensBase & {
phase: "localizing";
terminal: "dispatched" | "failed" | "no-change" | null;
Expand All @@ -27,9 +29,7 @@ export type TopologyLensEvent =
| { type: "lifecycle"; value: StudioEditLifecycleState }
| { type: "acquisition-elapsed"; callId: string };

function visibleBase(
value: Exclude<StudioEditLifecycleState, { phase: "idle" }>,
): TopologyLensBase {
function visibleBase(value: ActiveLifecycle): TopologyLensBase {
return {
callId: value.callId,
projectId: value.projectId,
Expand All @@ -38,6 +38,22 @@ function visibleBase(
};
}

function presentLifecycle(lifecycle: ActiveLifecycle): TopologyLensState {
const base = visibleBase(lifecycle);
if (lifecycle.phase === "dispatching") {
return lifecycle.targetChanged
? { ...base, phase: "acquiring", pendingLifecycle: null }
: { ...base, phase: "localizing", terminal: null };
}
if (lifecycle.phase === "dispatched" || lifecycle.phase === "failed") {
return { ...base, phase: "localizing", terminal: lifecycle.phase };
}
if (lifecycle.receipt?.ok && lifecycle.receipt.changed === false) {
return { ...base, phase: "localizing", terminal: "no-change" };
}
return { ...base, phase: "sealing", receiptStage: lifecycle.phase };
}

/**
* Presentation follows transaction facts. Elapsed events may finish a visual
* transition, but cannot promote a receipt or invent persistence.
Expand All @@ -49,24 +65,19 @@ export function reduceTopologyLens(
if (event.type === "lifecycle") {
const lifecycle = event.value;
if (lifecycle.phase === "idle") return { phase: "hidden" };
const base = visibleBase(lifecycle);
switch (lifecycle.phase) {
case "dispatching":
return lifecycle.targetChanged
? { ...base, phase: "acquiring" }
: { ...base, phase: "localizing", terminal: null };
case "dispatched":
case "failed":
return { ...base, phase: "localizing", terminal: lifecycle.phase };
case "saved":
case "verified":
if (lifecycle.receipt?.ok && lifecycle.receipt.changed === false) {
return { ...base, phase: "localizing", terminal: "no-change" };
}
return { ...base, phase: "sealing", receiptStage: lifecycle.phase };
if (
state.phase === "acquiring" &&
lifecycle.callId === state.callId &&
lifecycle.phase !== "dispatching"
) {
return { ...state, pendingLifecycle: lifecycle };
}
return presentLifecycle(lifecycle);
}

if (state.phase === "hidden" || state.callId !== event.callId) return state;
return state.phase === "acquiring" ? { ...state, phase: "localizing", terminal: null } : state;
if (state.phase !== "acquiring") return state;
return state.pendingLifecycle
? presentLifecycle(state.pendingLifecycle)
: { ...state, phase: "localizing", terminal: null };
}
Loading