diff --git a/web/src/shell/activity/ActivityProvider.tsx b/web/src/shell/activity/ActivityProvider.tsx index bf64571..e668973 100644 --- a/web/src/shell/activity/ActivityProvider.tsx +++ b/web/src/shell/activity/ActivityProvider.tsx @@ -124,7 +124,8 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents } }, [qc, status, setActiveNow]); useEffect(() => { - if (!active) return; + // #116: ответ обрабатывается, только если отслеживаемый пакет — тот же объект, что в activeRef (смена проекта обнуляет его синхронно) + if (!active || activeRef.current !== active) return; const b = batch.data, e = batch.error; if (b && b.id === active.id && !batch.isError) { if (terminal(b)) { diff --git a/web/src/shell/activity/activity.test.tsx b/web/src/shell/activity/activity.test.tsx index 8e0e12a..d0884b6 100644 --- a/web/src/shell/activity/activity.test.tsx +++ b/web/src/shell/activity/activity.test.tsx @@ -337,6 +337,40 @@ describe("ActivityProvider", () => { expect(m.now().jira.event).toMatchObject({ kind: "batch", id: 5 }); }); + it("#116: ответ опроса прежнего проекта, пришедший в коммите смены проекта, не возвращает событие", async () => { + const late = deferred(); + let polls = 0; + hub({ + ...listing((mod, project) => (mod === "wiki" && project === "AWPCA" ? [item(9, "wiki")] : [])), + "GET /batches/:id": async () => { + if (++polls > 1) await late.promise; // первый ответ показан сразу, следующий держится до смены проекта + return batch("running", { type: "wiki_sync" }, { id: 9, module: "wiki" }); + }, + }); + const m = mount({ pollMs: 5 }); + await waitFor(() => expect(m.now().wiki.event).toMatchObject({ kind: "batch", id: 9 })); + await waitFor(() => expect(polls).toBeGreaterThanOrEqual(2)); // повторный опрос ушёл и висит + // смена проекта и ответ опроса — одним коммитом: оба обновления копятся в act и рендерятся вместе на выходе из него + await act(async () => { m.setProject("STOCKS_DRRK"); late.resolve(); await sleep(20); }); + await waitFor(() => expect(m.now().wiki.event).toBeNull()); + await sleep(50); + expect(m.now().wiki.event).toBeNull(); + expect(m.now().wiki.busy).toBe(false); + }); + + it("#116: A → B → A — после возврата на AWPCA незавершённый пакет 9 снова подхватывается", async () => { + hub({ + ...listing((mod, project) => (mod === "wiki" && project === "AWPCA" ? [item(9, "wiki")] : [])), + ...byId({ 9: "wiki_sync" }, () => "running"), + }); + const m = mount(); + await waitFor(() => expect(m.now().wiki.event).toMatchObject({ kind: "batch", id: 9 })); + m.setProject("STOCKS_DRRK"); + await waitFor(() => expect(m.now().wiki.event).toBeNull()); + m.setProject("AWPCA"); + await waitFor(() => expect(m.now().wiki.event).toMatchObject({ kind: "batch", id: 9 })); + }); + it("M-2: пакет, отданный track для нового проекта, переживает смену проекта на него; чужой — сбрасывается", async () => { hub({ ...byId({ 9: "wiki_sync" }, () => "running") }); const m = mount();