fix: preserve draft dialog strict mode lifecycle
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { StrictMode, useState } from "react";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { initialState } from "../../app/state.js";
|
||||
@@ -91,8 +91,7 @@ const DraftDestination = () => {
|
||||
return <p>Destination: {location.pathname}{location.search}</p>;
|
||||
};
|
||||
|
||||
const renderDialog = (selectedCapability: CapabilityDetail | null = capability) =>
|
||||
render(
|
||||
const DialogRoutes = ({ selectedCapability }: { readonly selectedCapability: CapabilityDetail | null }) => (
|
||||
<MemoryRouter initialEntries={["/console/discover"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
@@ -101,7 +100,17 @@ const renderDialog = (selectedCapability: CapabilityDetail | null = capability)
|
||||
/>
|
||||
<Route path="/console/drafts/:workspaceId" element={<DraftDestination />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const renderDialog = (selectedCapability: CapabilityDetail | null = capability) =>
|
||||
render(<DialogRoutes selectedCapability={selectedCapability} />);
|
||||
|
||||
const renderStrictDialog = (selectedCapability: CapabilityDetail | null = capability) =>
|
||||
render(
|
||||
<StrictMode>
|
||||
<DialogRoutes selectedCapability={selectedCapability} />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -119,6 +128,34 @@ beforeEach(() => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("CreateDraftDialog", () => {
|
||||
it("keeps successful creation navigable after StrictMode effect replay", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(authoringClient.createEmpty).mockResolvedValueOnce(workspace("strict-created"));
|
||||
renderStrictDialog(null);
|
||||
|
||||
await user.type(screen.getByRole("textbox", { name: "Workspace id" }), "requested-id");
|
||||
await user.type(screen.getByRole("textbox", { name: "Draft name" }), "report-workflow");
|
||||
await user.click(screen.getByRole("button", { name: "Create draft" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Destination: /console/drafts/strict-created"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps rejected creation visible after StrictMode effect replay", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(authoringClient.createEmpty).mockRejectedValueOnce(
|
||||
new Error("creation failed"),
|
||||
);
|
||||
renderStrictDialog(null);
|
||||
|
||||
await user.type(screen.getByRole("textbox", { name: "Workspace id" }), "requested-id");
|
||||
await user.type(screen.getByRole("textbox", { name: "Draft name" }), "report-workflow");
|
||||
await user.click(screen.getByRole("button", { name: "Create draft" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("creation failed");
|
||||
});
|
||||
|
||||
it("does not navigate when a pending create is cancelled and later resolves", async () => {
|
||||
const user = userEvent.setup();
|
||||
const pendingCreate = deferred<DraftWorkspace>();
|
||||
|
||||
@@ -62,7 +62,8 @@ export const CreateDraftDialog = ({
|
||||
const drafts = useDraftWorkspace(null);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
const unmountingRef = useRef(false);
|
||||
const lifecycleTokenRef = useRef<number | null>(null);
|
||||
const nextLifecycleTokenRef = useRef(0);
|
||||
const requestGenerationRef = useRef(0);
|
||||
const client = useMemo<DraftAuthoringClient | null>(
|
||||
() => (writeExecutor ? createDraftAuthoringClient(writeExecutor) : null),
|
||||
@@ -82,13 +83,17 @@ export const CreateDraftDialog = ({
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (dialog === null) return;
|
||||
const lifecycleToken = ++nextLifecycleTokenRef.current;
|
||||
lifecycleTokenRef.current = lifecycleToken;
|
||||
const previouslyFocused =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
showModal(dialog);
|
||||
dialog.querySelector<HTMLElement>("[data-dialog-autofocus]")?.focus();
|
||||
|
||||
return () => {
|
||||
unmountingRef.current = true;
|
||||
if (lifecycleTokenRef.current === lifecycleToken) {
|
||||
lifecycleTokenRef.current = null;
|
||||
}
|
||||
requestGenerationRef.current += 1;
|
||||
closeModal(dialog);
|
||||
if (previouslyFocused !== null && document.contains(previouslyFocused)) {
|
||||
@@ -101,8 +106,12 @@ export const CreateDraftDialog = ({
|
||||
requestGenerationRef.current += 1;
|
||||
};
|
||||
|
||||
const isCurrentRequest = (generation: number): boolean =>
|
||||
!unmountingRef.current && requestGenerationRef.current === generation;
|
||||
const isCurrentRequest = (
|
||||
generation: number,
|
||||
lifecycleToken: number,
|
||||
): boolean =>
|
||||
lifecycleTokenRef.current === lifecycleToken &&
|
||||
requestGenerationRef.current === generation;
|
||||
|
||||
const requestClose = (): void => {
|
||||
invalidatePendingCreate();
|
||||
@@ -115,7 +124,7 @@ export const CreateDraftDialog = ({
|
||||
};
|
||||
|
||||
const handleDialogClose = (): void => {
|
||||
if (unmountingRef.current) return;
|
||||
if (lifecycleTokenRef.current === null) return;
|
||||
requestClose();
|
||||
};
|
||||
|
||||
@@ -128,6 +137,8 @@ export const CreateDraftDialog = ({
|
||||
|
||||
setPhase("saving");
|
||||
setMessage(null);
|
||||
const lifecycleToken = lifecycleTokenRef.current;
|
||||
if (lifecycleToken === null) return;
|
||||
const requestGeneration = requestGenerationRef.current + 1;
|
||||
requestGenerationRef.current = requestGeneration;
|
||||
try {
|
||||
@@ -140,10 +151,10 @@ export const CreateDraftDialog = ({
|
||||
title,
|
||||
capabilityName: capability.name,
|
||||
});
|
||||
if (!isCurrentRequest(requestGeneration)) return;
|
||||
if (!isCurrentRequest(requestGeneration, lifecycleToken)) return;
|
||||
navigate(draftPath(created.workspaceId, capability));
|
||||
} catch (error: unknown) {
|
||||
if (!isCurrentRequest(requestGeneration)) return;
|
||||
if (!isCurrentRequest(requestGeneration, lifecycleToken)) return;
|
||||
setPhase("error");
|
||||
setMessage(errorMessage(error));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user