fix: synchronize reused ticket detail routes

This commit is contained in:
2026-08-27 12:55:01 +09:00
parent 0496cd907b
commit b98d4b59f5
4 changed files with 120 additions and 19 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev", "dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts", "test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
"build": "deno run -A npm:vite@7.2.7 build", "build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview" "preview": "deno run -A npm:vite@7.2.7 preview"
}, },
@@ -251,7 +251,8 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
ticketDetailLoad.includes("/repositories") && ticketDetailLoad.includes("/repositories") &&
ticketDetailPage.includes('mutate("state", "/state"') && ticketDetailPage.includes('mutate("state", "/state"') &&
ticketDetailPage.includes("async function queueTicket") && ticketDetailPage.includes("async function queueTicket") &&
ticketDetailPage.includes("`${ticketPath}/queue`") && ticketDetailPage.includes("const path = ticketPath") &&
ticketDetailPage.includes("`${path}/queue`") &&
!ticketDetailPage.includes("/merge-request/merge") && !ticketDetailPage.includes("/merge-request/merge") &&
ticketDetailPage.includes("mergeRequest.selector_from") && ticketDetailPage.includes("mergeRequest.selector_from") &&
ticketDetailPage.includes("mergeRequest.review_status") && ticketDetailPage.includes("mergeRequest.review_status") &&
@@ -36,7 +36,7 @@
const initialData = untrack(() => data); const initialData = untrack(() => data);
const loadedTicket = initialData.ticket.data; const loadedTicket = initialData.ticket.data;
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed"); if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
const loadedRepositories = initialData.repositories.data; const loadedRepositories = $derived(data.repositories.data);
type QueueOutcome = { type QueueOutcome = {
requested_ticket: string; requested_ticket: string;
@@ -62,6 +62,8 @@
let manualRuntimeId = $state(""); let manualRuntimeId = $state("");
let manualWorkerId = $state(""); let manualWorkerId = $state("");
let cancellationReason = $state(""); let cancellationReason = $state("");
let routeTicketSnapshot = `${initialData.ticketId}:${loadedTicket.item_revision}`;
let routeGeneration = 0;
const coderAssignment = $derived( const coderAssignment = $derived(
ticket.assignments.find((assignment) => assignment.role === "coder") ?? null, ticket.assignments.find((assignment) => assignment.role === "coder") ?? null,
); );
@@ -95,13 +97,43 @@
function applyTicket(updatedTicket: TicketDetail): void { function applyTicket(updatedTicket: TicketDetail): void {
ticket = updatedTicket; ticket = updatedTicket;
editTitle = ticket.title; editTitle = updatedTicket.title;
editBody = ticket.body; editBody = updatedTicket.body;
repositoryId = ticket.repository_id ?? ""; repositoryId = updatedTicket.repository_id ?? "";
refSelector = ticket.ref_selector ?? ""; refSelector = updatedTicket.ref_selector ?? "";
nextState = ticket.state; nextState = updatedTicket.state;
} }
function resetTicketView(updatedTicket: TicketDetail): void {
applyTicket(updatedTicket);
editing = false;
transitionReason = "";
threadRole = "comment";
threadBody = "";
resolution = "";
busy = null;
errorMessage = null;
queueMessage = null;
readyOperationKey = null;
manualRuntimeId = "";
manualWorkerId = "";
cancellationReason = "";
}
$effect(() => {
const incomingTicketId = data.ticketId;
const incomingTicket = data.ticket.data;
if (!incomingTicket) return;
const incomingSnapshot = `${incomingTicketId}:${incomingTicket.item_revision}`;
untrack(() => {
if (incomingSnapshot === routeTicketSnapshot) return;
routeTicketSnapshot = incomingSnapshot;
routeGeneration += 1;
resetTicketView(incomingTicket);
});
});
async function mutate( async function mutate(
action: string, action: string,
suffix: string, suffix: string,
@@ -109,40 +141,51 @@
method = "POST", method = "POST",
): Promise<boolean> { ): Promise<boolean> {
if (busy) return false; if (busy) return false;
const generation = routeGeneration;
const path = `${ticketPath}${suffix}`;
busy = action; busy = action;
errorMessage = null; errorMessage = null;
try { try {
const path = `${ticketPath}${suffix}`;
const response = await workspaceApiJsonWithBody<TicketDetail>(path, { const response = await workspaceApiJsonWithBody<TicketDetail>(path, {
method, method,
...(body === undefined ? {} : { body: JSON.stringify(body) }), ...(body === undefined ? {} : { body: JSON.stringify(body) }),
}); });
if (generation !== routeGeneration) return false;
applyTicket(response); applyTicket(response);
return true; return true;
} catch (error) { } catch (error) {
if (generation === routeGeneration) {
errorMessage = error instanceof Error ? error.message : String(error); errorMessage = error instanceof Error ? error.message : String(error);
}
return false; return false;
} finally { } finally {
busy = null; if (generation === routeGeneration) busy = null;
} }
} }
async function queueTicket(): Promise<void> { async function queueTicket(): Promise<void> {
if (busy) return; if (busy) return;
const generation = routeGeneration;
const path = ticketPath;
busy = "queue"; busy = "queue";
errorMessage = null; errorMessage = null;
queueMessage = null; queueMessage = null;
try { try {
const outcome = await workspaceApiJsonWithBody<QueueOutcome>( const outcome = await workspaceApiJsonWithBody<QueueOutcome>(
`${ticketPath}/queue`, `${path}/queue`,
{ method: "POST", body: JSON.stringify({}) }, { method: "POST", body: JSON.stringify({}) },
); );
if (generation !== routeGeneration) return;
const updatedTicket = await workspaceApiJson<TicketDetail>(path);
if (generation !== routeGeneration) return;
queueMessage = `Queued ${outcome.queued_tickets.length} Ticket(s): ${outcome.queued_tickets.join(", ")}`; queueMessage = `Queued ${outcome.queued_tickets.length} Ticket(s): ${outcome.queued_tickets.join(", ")}`;
applyTicket(await workspaceApiJson<TicketDetail>(ticketPath)); applyTicket(updatedTicket);
} catch (error) { } catch (error) {
if (generation === routeGeneration) {
errorMessage = error instanceof Error ? error.message : String(error); errorMessage = error instanceof Error ? error.message : String(error);
}
} finally { } finally {
busy = null; if (generation === routeGeneration) busy = null;
} }
} }
@@ -152,11 +195,13 @@
principal: Record<string, string>, principal: Record<string, string>,
): Promise<void> { ): Promise<void> {
if (busy) return; if (busy) return;
const generation = routeGeneration;
const path = ticketPath;
busy = action; busy = action;
errorMessage = null; errorMessage = null;
try { try {
await workspaceApiJsonWithBody( await workspaceApiJsonWithBody(
`${ticketPath}/assignments/${role}`, `${path}/assignments/${role}`,
{ {
method: "PUT", method: "PUT",
body: JSON.stringify({ body: JSON.stringify({
@@ -166,11 +211,16 @@
}), }),
}, },
); );
applyTicket(await workspaceApiJson<TicketDetail>(ticketPath)); if (generation !== routeGeneration) return;
const updatedTicket = await workspaceApiJson<TicketDetail>(path);
if (generation !== routeGeneration) return;
applyTicket(updatedTicket);
} catch (error) { } catch (error) {
if (generation === routeGeneration) {
errorMessage = error instanceof Error ? error.message : String(error); errorMessage = error instanceof Error ? error.message : String(error);
}
} finally { } finally {
busy = null; if (generation === routeGeneration) busy = null;
} }
} }
@@ -0,0 +1,50 @@
import { assert, assertStringIncludes } from "jsr:@std/assert";
const pageSource = await Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
import.meta.url,
),
);
Deno.test("ticket detail synchronizes reused route data", () => {
const effectStart = pageSource.indexOf("$effect(() => {");
assert(effectStart >= 0, "ticket detail must react to reused route props");
const effectSource = pageSource.slice(effectStart);
for (
const token of [
"data.ticketId",
"data.ticket.data",
"incomingTicket.item_revision",
"routeGeneration += 1",
"resetTicketView(incomingTicket)",
]
) {
assertStringIncludes(effectSource, token);
}
});
Deno.test("ticket detail fences stale mutation responses", () => {
for (
const operation of [
"async function mutate(",
"async function queueTicket(",
"async function mutateAssignment(",
]
) {
const operationStart = pageSource.indexOf(operation);
assert(operationStart >= 0, `missing ${operation}`);
const nextOperation = pageSource.indexOf(
"\n async function ",
operationStart + 1,
);
const operationSource = pageSource.slice(
operationStart,
nextOperation === -1 ? undefined : nextOperation,
);
assertStringIncludes(operationSource, "const generation = routeGeneration");
assertStringIncludes(operationSource, "generation !== routeGeneration");
assertStringIncludes(operationSource, "generation === routeGeneration");
}
});