Unverified 提交 7ca7b0c1 authored 作者: keppo-bot[bot]'s avatar keppo-bot[bot] 提交者: GitHub

fix: persist GitHub sync state across Publish tab navigation (#3151)

Fixes #3139 ## Summary - Fixes the bug where clicking **Sync to GitHub** on the Publish tab and navigating away before the push finishes dropped the in-flight state — the push actually continued in the main process, but on return the UI showed neither success nor failure. - Moves sync state (`isSyncing`, `syncError`, `syncSuccess`, `conflicts`, rebase status, etc.) out of local `useState` in `ConnectedGitHubConnector` and into a Jotai atom keyed by `appId` (`src/atoms/githubSyncAtoms.ts`), so it survives unmount/remount. - Adds success/error toasts on completion so users get feedback even when they're viewing a different preview tab. ## Test plan - [x] New unit tests in `src/atoms/githubSyncAtoms.test.tsx` verify state is preserved across unmount/remount, that a "completion" happening while unmounted is visible on remount, that state is isolated per `appId`, and that `null` appId is a no-op. - [x] `npm run ts` — clean - [x] `npm run fmt` / `npm run lint:fix` — clean (only the pre-existing unrelated warning in `local_agent_handler.test.ts`) - [x] `npm test` — 1003/1003 passing - [ ] Manual: click Sync to GitHub, immediately switch to Code tab, switch back — spinner should still show (if still running) or the success message should be visible (if finished); a toast should appear on completion regardless of tab. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3151" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarWill Chen <7344640+wwwillchen@users.noreply.github.com> Co-authored-by: 's avatarClaude Opus 4.6 (1M context) <noreply@anthropic.com>
上级 2ff6fb13
...@@ -22,6 +22,7 @@ Detailed rules and learnings are in the `rules/` directory. Read the relevant fi ...@@ -22,6 +22,7 @@ Detailed rules and learnings are in the `rules/` directory. Read the relevant fi
| [rules/adding-settings.md](rules/adding-settings.md) | Adding a new user-facing setting or toggle to the Settings page | | [rules/adding-settings.md](rules/adding-settings.md) | Adding a new user-facing setting or toggle to the Settings page |
| [rules/chat-message-indicators.md](rules/chat-message-indicators.md) | Using `<dyad-status>` tags in chat messages for system indicators | | [rules/chat-message-indicators.md](rules/chat-message-indicators.md) | Using `<dyad-status>` tags in chat messages for system indicators |
| [rules/product-principles.md](rules/product-principles.md) | Planning new features, especially via `dyad:swarm-to-plan`, to guide design trade-offs | | [rules/product-principles.md](rules/product-principles.md) | Planning new features, especially via `dyad:swarm-to-plan`, to guide design trade-offs |
| [rules/jotai-testing.md](rules/jotai-testing.md) | Unit-testing Jotai atoms/hooks with `renderHook`, especially across unmount/remount |
## Project setup and lints ## Project setup and lints
......
# Jotai Testing
Learnings for writing unit tests against components/hooks that read or write Jotai atoms.
## Sharing a store across `renderHook` calls in a single test
When a test needs to render a hook, unmount it, and then render the hook again (e.g., to verify state persists across an unmount/remount — the exact scenario for atoms that replace local `useState`), all `renderHook` calls must share the **same Jotai store**. Otherwise each `renderHook`'s `Provider` wrapper creates its own isolated store and writes made by the first hook are invisible to the second.
**Wrong** — each call to `makeWrapper()` returns a component that creates a fresh `<Provider>` (no store prop), so every `renderHook` gets a new default store:
```tsx
function makeWrapper() {
return function Wrapper({ children }) {
return <Provider>{children}</Provider>;
};
}
```
**Right** — create one store per test and bind every `renderHook` in that test to it:
```tsx
import { createStore, Provider } from "jotai";
function makeWrapper() {
const store = createStore();
return function Wrapper({ children }) {
return <Provider store={store}>{children}</Provider>;
};
}
// In the test:
const wrapper = makeWrapper();
const first = renderHook(() => useMyAtomHook(id), { wrapper });
// ... mutate state ...
first.unmount();
const second = renderHook(() => useMyAtomHook(id), { wrapper });
// second now sees state written by first
```
The symptom when you get this wrong is assertions like `expected false to be true` on the remounted hook's state, even though the setter clearly ran against the first hook.
See `src/atoms/githubSyncAtoms.test.tsx` for a complete example covering unmount/remount, cross-unmount completion, and per-key isolation.
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { createStore, Provider } from "jotai";
import type { PropsWithChildren } from "react";
import {
DEFAULT_GITHUB_SYNC_STATE,
useGithubSyncState,
} from "@/atoms/githubSyncAtoms";
// Returns a wrapper bound to a fresh Jotai store so tests are isolated and
// sibling `renderHook` calls in the same test share the same store (modeling
// the production app which has a single global store).
function makeWrapper() {
const store = createStore();
return function Wrapper({ children }: PropsWithChildren) {
return <Provider store={store}>{children}</Provider>;
};
}
describe("useGithubSyncState", () => {
it("returns the default state when no app has synced yet", () => {
const { result } = renderHook(() => useGithubSyncState(1), {
wrapper: makeWrapper(),
});
expect(result.current[0]).toEqual(DEFAULT_GITHUB_SYNC_STATE);
});
it("preserves sync state across unmount/remount for the same appId", () => {
// This is the regression test for the navigation-mid-sync bug: when the
// user leaves the Publish tab while a push is running, the component
// unmounts. When they return, the re-mounted component must still show
// the in-flight / completed state.
const wrapper = makeWrapper();
const first = renderHook(() => useGithubSyncState(42), { wrapper });
act(() => {
first.result.current[1]({ isSyncing: true });
});
expect(first.result.current[0].isSyncing).toBe(true);
first.unmount();
const second = renderHook(() => useGithubSyncState(42), { wrapper });
// Without the atom, this would be `false` (fresh local state).
expect(second.result.current[0].isSyncing).toBe(true);
// Completing the push after remount should update the new hook instance.
act(() => {
second.result.current[1]({ isSyncing: false, syncSuccess: true });
});
expect(second.result.current[0].isSyncing).toBe(false);
expect(second.result.current[0].syncSuccess).toBe(true);
});
it("completes a push while the component is unmounted and shows the result on remount", () => {
// Models the exact bug scenario: a push is in flight, the user navigates
// away (component unmounts), the IPC completion handler still runs and
// writes to the atom, and on remount the success is visible.
const wrapper = makeWrapper();
const first = renderHook(() => useGithubSyncState(7), { wrapper });
// Capture the stable setter from the first mount (this is what the
// unmounted async handler would hold on to).
const updaterFromFirstMount = first.result.current[1];
act(() => {
updaterFromFirstMount({ isSyncing: true });
});
first.unmount();
// While the component is unmounted, the background IPC completes and
// writes to the atom via the stale setter captured above.
act(() => {
updaterFromFirstMount({ isSyncing: false, syncSuccess: true });
});
const second = renderHook(() => useGithubSyncState(7), { wrapper });
expect(second.result.current[0]).toEqual({
...DEFAULT_GITHUB_SYNC_STATE,
isSyncing: false,
syncSuccess: true,
});
});
it("isolates state between different appIds", () => {
const wrapper = makeWrapper();
const appA = renderHook(() => useGithubSyncState(1), { wrapper });
const appB = renderHook(() => useGithubSyncState(2), { wrapper });
act(() => {
appA.result.current[1]({ isSyncing: true, syncError: "oops" });
});
expect(appA.result.current[0].isSyncing).toBe(true);
expect(appA.result.current[0].syncError).toBe("oops");
// App B should be untouched.
expect(appB.result.current[0]).toEqual(DEFAULT_GITHUB_SYNC_STATE);
});
it("no-ops when appId is null", () => {
const { result } = renderHook(() => useGithubSyncState(null), {
wrapper: makeWrapper(),
});
expect(result.current[0]).toEqual(DEFAULT_GITHUB_SYNC_STATE);
act(() => {
result.current[1]({ isSyncing: true });
});
// Still default — a null appId cannot own any persistent state.
expect(result.current[0]).toEqual(DEFAULT_GITHUB_SYNC_STATE);
});
});
import { atom, useAtomValue, useSetAtom } from "jotai";
import { useCallback, useMemo } from "react";
export type RebaseAction = "abort" | "continue" | "safe-push" | null;
export interface GithubSyncState {
isSyncing: boolean;
syncError: string | null;
syncSuccess: boolean;
conflicts: string[];
rebaseInProgress: boolean;
rebaseStatusMessage: string | null;
rebaseAction: RebaseAction;
}
export const DEFAULT_GITHUB_SYNC_STATE: GithubSyncState = {
isSyncing: false,
syncError: null,
syncSuccess: false,
conflicts: [],
rebaseInProgress: false,
rebaseStatusMessage: null,
rebaseAction: null,
};
// Sync state is held in a global atom keyed by appId so that it survives
// unmounts when the user navigates away from the Publish tab while a push
// is in flight. The IPC push operation runs in the main process and its
// completion callback updates this atom — the next time the component
// mounts it will reflect the final result (success/failure).
export const githubSyncStatesAtom = atom<Record<number, GithubSyncState>>({});
type SyncPatch =
| Partial<GithubSyncState>
| ((prev: GithubSyncState) => Partial<GithubSyncState>);
export function useGithubSyncState(appId: number | null) {
const allStates = useAtomValue(githubSyncStatesAtom);
const setAllStates = useSetAtom(githubSyncStatesAtom);
const state = useMemo<GithubSyncState>(() => {
if (appId == null) return DEFAULT_GITHUB_SYNC_STATE;
return allStates[appId] ?? DEFAULT_GITHUB_SYNC_STATE;
}, [allStates, appId]);
const updateSyncState = useCallback(
(patch: SyncPatch) => {
if (appId == null) return;
setAllStates((prev) => {
const current = prev[appId] ?? DEFAULT_GITHUB_SYNC_STATE;
const resolved = typeof patch === "function" ? patch(current) : patch;
return {
...prev,
[appId]: { ...current, ...resolved },
};
});
},
[appId, setAllStates],
);
return [state, updateSyncState] as const;
}
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论