Skip to content
Merged
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
112 changes: 112 additions & 0 deletions components/cases/__tests__/header-badge-reactivity.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useStatusModal } from "@/hooks/use-status-modal";
import { server } from "@/src/__tests__/mocks/server";
import useStore from "@/store/store";
import Header from "../header";

// Same local replacement as header.test.tsx — unrelated to change-detection
// reactivity, which is what this file covers.
vi.mock("reactflow", () => ({
useReactFlow: () => ({ setCenter: vi.fn() }),
useUpdateNodeInternals: () => vi.fn(),
}));

const CASE_ID = "case-1";
const CHANGES_PATH = `/api/cases/${CASE_ID}/changes`;

function publishedCase(overrides: Partial<{ published: boolean }> = {}) {
return {
id: CASE_ID,
name: "Test Case",
type: "assurance-case",
permissions: "manage",
createdDate: new Date().toISOString(),
comments: [],
published: true,
publishStatus: "PUBLISHED" as const,
publishedAt: "2026-08-01T00:00:00.000Z",
...overrides,
};
}

function changesResponse(hasChanges: boolean) {
return HttpResponse.json({
hasChanges,
publishedAt: "2026-08-01T00:00:00.000Z",
publishedId: "published-1",
});
}

afterEach(() => {
vi.restoreAllMocks();
});

describe("Header — canvas publish badge reacts live to structural edits (no reload)", () => {
beforeEach(() => {
useStore.setState({ assuranceCase: publishedCase() });
useStatusModal.getState().onClose();
});

it("shows the 'Changes pending' dot once a structural edit replaces the store's assuranceCase, without remounting", async () => {
let requestCount = 0;
server.use(
http.get(CHANGES_PATH, () => {
requestCount += 1;
// Behind the published version only from the second check
// onward — i.e. only after the simulated structural edit below.
return changesResponse(requestCount > 1);
})
);

render(<Header setOpen={vi.fn()} />);

await waitFor(() => expect(requestCount).toBe(1));
expect(screen.queryByTitle("Changes pending")).not.toBeInTheDocument();

// Simulate a structural edit landing (e.g. via node-attributes.tsx /
// node-options-menu.tsx, both of which call setAssuranceCase with a
// freshly spread object) — no fetch, no reload, just what a real
// element create/update/delete does to the store.
useStore.setState({
assuranceCase: { ...useStore.getState().assuranceCase, name: "Edited" },
} as Partial<ReturnType<typeof useStore.getState>>);

await waitFor(() => expect(requestCount).toBe(2));
await waitFor(() =>
expect(screen.getByTitle("Changes pending")).toBeInTheDocument()
);
});

it("does not re-fetch when only comment-related store state changes", async () => {
let requestCount = 0;
server.use(
http.get(CHANGES_PATH, () => {
requestCount += 1;
return changesResponse(false);
})
);

render(<Header setOpen={vi.fn()} />);

await waitFor(() => expect(requestCount).toBe(1));

// A comment mutation touches nodeComments/caseNotes only, never
// assuranceCase — verifies the comment-immunity constraint at the
// store-shape level (server-side exclusion is covered separately in
// publish-journey.spec.ts).
useStore.setState({
nodeComments: [
{
id: "c1",
content: "A comment",
createdAt: new Date().toISOString(),
},
],
} as Partial<ReturnType<typeof useStore.getState>>);

await new Promise((resolve) => setTimeout(resolve, 0));
expect(requestCount).toBe(1);
});
});
7 changes: 6 additions & 1 deletion components/cases/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,15 @@ const Header = ({ setOpen }: HeaderProps) => {

const currentStatus: PublishStatusType = getDisplayStatus();

// Use change detection for published cases
// Use change detection for published cases. `refreshKey: assuranceCase`
// re-fetches whenever the case content changes (structural edits replace
// this object; comment mutations live in separate store slices and never
// touch it) — without it this only fetched once on mount and the canvas
// badge's amber "changes pending" dot stayed stale until a page reload.
const { hasChanges } = useChangeDetection({
caseId: assuranceCase?.id ?? null,
enabled: currentStatus === "PUBLISHED",
refreshKey: assuranceCase,
});

const _handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
Expand Down
1 change: 1 addition & 0 deletions components/publishing/status-modal-wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function StatusModalWrapper() {
const { hasChanges, refresh: refreshChanges } = useChangeDetection({
caseId: statusModal.caseId,
enabled: statusModal.isOpen && statusModal.status === "PUBLISHED",
refreshKey: assuranceCase,
});

const handleStatusTransition = async (
Expand Down
80 changes: 80 additions & 0 deletions hooks/__tests__/use-change-detection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { afterEach, describe, expect, it, vi } from "vitest";
import { server } from "@/src/__tests__/mocks/server";
import { useChangeDetection } from "../use-change-detection";

const CASE_ID = "case-1";
const CHANGES_PATH = `/api/cases/${CASE_ID}/changes`;

function jsonResponse(hasChanges: boolean) {
return HttpResponse.json({
hasChanges,
publishedAt: "2026-08-01T00:00:00.000Z",
publishedId: "published-1",
});
}

afterEach(() => {
vi.restoreAllMocks();
});

describe("useChangeDetection — refreshKey invalidation", () => {
it("re-fetches when refreshKey changes reference while enabled, without polling", async () => {
let requestCount = 0;
server.use(
http.get(CHANGES_PATH, () => {
requestCount += 1;
// First fetch: no changes yet. Second fetch (after a structural
// edit lands and refreshKey changes): changes detected.
return jsonResponse(requestCount > 1);
})
);

const { result, rerender } = renderHook(
({ refreshKey }: { refreshKey: unknown }) =>
useChangeDetection({ caseId: CASE_ID, enabled: true, refreshKey }),
{ initialProps: { refreshKey: { rev: 1 } } }
);

await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.hasChanges).toBe(false);
expect(requestCount).toBe(1);

// A structural edit lands — the caller passes a new object reference
// (the store's assuranceCase is replaced, never mutated, on edits).
rerender({ refreshKey: { rev: 2 } });

await waitFor(() => expect(requestCount).toBe(2));
await waitFor(() => expect(result.current.hasChanges).toBe(true));
});

it("does not re-fetch when refreshKey is referentially unchanged across renders", async () => {
let requestCount = 0;
server.use(
http.get(CHANGES_PATH, () => {
requestCount += 1;
return jsonResponse(false);
})
);

const stableKey = { rev: 1 };
const { result, rerender } = renderHook(
({ refreshKey }: { refreshKey: unknown }) =>
useChangeDetection({ caseId: CASE_ID, enabled: true, refreshKey }),
{ initialProps: { refreshKey: stableKey } }
);

await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(requestCount).toBe(1);

// Unrelated re-render (e.g. a comment mutation elsewhere in the store)
// passes the same refreshKey reference — must not trigger a refetch.
rerender({ refreshKey: stableKey });

// Give any accidental async refetch a tick to happen before asserting
// it didn't.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(requestCount).toBe(1);
});
});
20 changes: 18 additions & 2 deletions hooks/use-change-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ interface UseChangeDetectionOptions {
includeDetails?: boolean;
/** Poll interval in milliseconds (0 to disable polling) */
pollInterval?: number;
/**
* Opaque value that, when it changes by reference, triggers a refetch
* without waiting for `enabled`/`caseId` to change. Pass something that
* only changes when case *content* changes — e.g. the `assuranceCase`
* object from the canvas store, which is replaced (never mutated) on
* every structural edit (create/update/delete/move of an element) but is
* untouched by comment mutations, which live in separate store slices.
* This is what makes the divergence indicator reactive to edits landing
* while it's already mounted, instead of only refreshing on next mount.
*/
refreshKey?: unknown;
}

interface UseChangeDetectionReturn {
Expand Down Expand Up @@ -117,6 +128,7 @@ export function useChangeDetection({
includeDetails = false,
enabled = true,
pollInterval = 0,
refreshKey,
}: UseChangeDetectionOptions): UseChangeDetectionReturn {
const [state, setState] = useState<ChangeDetectionState>(initialState);

Expand Down Expand Up @@ -145,14 +157,18 @@ export function useChangeDetection({
}
}, [caseId, includeDetails]);

// Initial fetch and when dependencies change
// Initial fetch, when dependencies change, and whenever `refreshKey`
// changes reference while enabled — the structural-edit invalidation
// path (see the option's doc comment above). `refreshKey` isn't read in
// the effect body, only used to force a re-run on change.
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey is an intentional opaque invalidation signal, not a value read inside the effect
useEffect(() => {
if (enabled && caseId) {
fetchChanges();
} else {
setState(initialState);
}
}, [enabled, caseId, fetchChanges]);
}, [enabled, caseId, fetchChanges, refreshKey]);

// Optional polling
useEffect(() => {
Expand Down
Loading