From 49639a08573b2d21ddb5de19c17456b48b1fdce9 Mon Sep 17 00:00:00 2001 From: Shad Date: Sun, 14 Jun 2026 00:34:47 +0400 Subject: [PATCH] fix(oracle-r4): validate selectedStepId across live refreshes Oracle r4 'Watch Out For' caught a real edge case: if a backend graph changes shape while the scenarioId stays the same, the prior selectedStepId could survive the merge and point at a step that no longer exists. Two new vitest regressions in state/store.test.ts now pin the contract: - refreshLive() resets selectedStepId to the scenario's defaultStepId when the prior step no longer exists in the merged catalog - refreshLive() preserves a still-valid selectedStepId runLiveFetch() now derives stepStillThere from the merged scenario's own steps (not the old store) and falls back to defaultStepId when stale. Same single-set call as before; no extra renders. Confidence: high Scope-risk: narrow Not-tested: real backend definition with step IDs that disappear mid-session (covered by stub + assertion above) --- src/state/store.test.ts | 24 ++++++++++++++++++++++++ src/state/store.ts | 7 +++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/state/store.test.ts b/src/state/store.test.ts index 9460d92..85313b2 100644 --- a/src/state/store.test.ts +++ b/src/state/store.test.ts @@ -74,4 +74,28 @@ describe("store live-mode + refresh", () => { await useApp.getState().setMode("snapshot"); expect(useApp.getState().toasts.length).toBe(before); }); + + it("refreshLive() resets selectedStepId to defaultStepId when the prior step no longer exists in the merged catalog", async () => { + stubFetch(); + await useApp.getState().setMode("live"); + const scenario = useApp.getState().scenarios[0]; + if (!scenario) throw new Error("no scenario"); + useApp.setState({ scenarioId: scenario.id, selectedStepId: "definitely-not-a-real-step-id" }); + await useApp.getState().refreshLive(); + const after = useApp.getState(); + const sc = after.scenarios.find((s) => s.id === after.scenarioId); + expect(sc).toBeDefined(); + expect(sc!.steps.some((st) => st.id === after.selectedStepId)).toBe(true); + }); + + it("refreshLive() preserves a still-valid selectedStepId", async () => { + stubFetch(); + await useApp.getState().setMode("live"); + const scenario = useApp.getState().scenarios[0]; + if (!scenario) throw new Error("no scenario"); + const validStep = scenario.steps[scenario.steps.length - 1]; + useApp.setState({ scenarioId: scenario.id, selectedStepId: validStep.id }); + await useApp.getState().refreshLive(); + expect(useApp.getState().selectedStepId).toBe(validStep.id); + }); }); diff --git a/src/state/store.ts b/src/state/store.ts index 8ebbbbf..2bf6aca 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -84,14 +84,17 @@ async function runLiveFetch( const merged = [...scenarios, ...syntheticScenarios]; const first = merged[0]; const currentId = get().scenarioId; + const currentStepId = get().selectedStepId; const stillThere = merged.find((s) => s.id === currentId); + const stepStillThere = stillThere?.steps.some((st) => st.id === currentStepId); + const nextScenario = stillThere ?? first; set({ scenarios: merged, liveTotals: { workItems: workItems.length, distinctDefs }, liveFetchedAt: Date.now(), liveLoading: false, - scenarioId: stillThere ? currentId : first?.id ?? currentId, - selectedStepId: stillThere ? get().selectedStepId : first?.defaultStepId ?? null, + scenarioId: nextScenario?.id ?? currentId, + selectedStepId: stepStillThere ? currentStepId : nextScenario?.defaultStepId ?? null, }); get().pushToast( "ok",