From b98d4b59f531bf07697ffdba8b0ce775141d6998 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 27 Aug 2026 12:55:01 +0900 Subject: [PATCH] fix: synchronize reused ticket detail routes --- web/workspace/deno.json | 2 +- .../console/worker-console.ui.test.ts | 3 +- .../tickets/[ticketId]/+page.svelte | 84 +++++++++++++++---- .../test/ticket-detail-route-reuse.test.ts | 50 +++++++++++ 4 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 web/workspace/test/ticket-detail-route-reuse.test.ts diff --git a/web/workspace/deno.json b/web/workspace/deno.json index cec2b50c..cafd203e 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "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", "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", "preview": "deno run -A npm:vite@7.2.7 preview" }, diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 2cb0fffe..1fcb2b37 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -251,7 +251,8 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as ticketDetailLoad.includes("/repositories") && ticketDetailPage.includes('mutate("state", "/state"') && 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("mergeRequest.selector_from") && ticketDetailPage.includes("mergeRequest.review_status") && diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte index c76534e7..e4821de1 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte @@ -36,7 +36,7 @@ const initialData = untrack(() => data); const loadedTicket = initialData.ticket.data; if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed"); - const loadedRepositories = initialData.repositories.data; + const loadedRepositories = $derived(data.repositories.data); type QueueOutcome = { requested_ticket: string; @@ -62,6 +62,8 @@ let manualRuntimeId = $state(""); let manualWorkerId = $state(""); let cancellationReason = $state(""); + let routeTicketSnapshot = `${initialData.ticketId}:${loadedTicket.item_revision}`; + let routeGeneration = 0; const coderAssignment = $derived( ticket.assignments.find((assignment) => assignment.role === "coder") ?? null, ); @@ -95,13 +97,43 @@ function applyTicket(updatedTicket: TicketDetail): void { ticket = updatedTicket; - editTitle = ticket.title; - editBody = ticket.body; - repositoryId = ticket.repository_id ?? ""; - refSelector = ticket.ref_selector ?? ""; - nextState = ticket.state; + editTitle = updatedTicket.title; + editBody = updatedTicket.body; + repositoryId = updatedTicket.repository_id ?? ""; + refSelector = updatedTicket.ref_selector ?? ""; + 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( action: string, suffix: string, @@ -109,40 +141,51 @@ method = "POST", ): Promise { if (busy) return false; + const generation = routeGeneration; + const path = `${ticketPath}${suffix}`; busy = action; errorMessage = null; try { - const path = `${ticketPath}${suffix}`; const response = await workspaceApiJsonWithBody(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); + if (generation !== routeGeneration) return false; applyTicket(response); return true; } catch (error) { - errorMessage = error instanceof Error ? error.message : String(error); + if (generation === routeGeneration) { + errorMessage = error instanceof Error ? error.message : String(error); + } return false; } finally { - busy = null; + if (generation === routeGeneration) busy = null; } } async function queueTicket(): Promise { if (busy) return; + const generation = routeGeneration; + const path = ticketPath; busy = "queue"; errorMessage = null; queueMessage = null; try { const outcome = await workspaceApiJsonWithBody( - `${ticketPath}/queue`, + `${path}/queue`, { method: "POST", body: JSON.stringify({}) }, ); + if (generation !== routeGeneration) return; + const updatedTicket = await workspaceApiJson(path); + if (generation !== routeGeneration) return; queueMessage = `Queued ${outcome.queued_tickets.length} Ticket(s): ${outcome.queued_tickets.join(", ")}`; - applyTicket(await workspaceApiJson(ticketPath)); + applyTicket(updatedTicket); } catch (error) { - errorMessage = error instanceof Error ? error.message : String(error); + if (generation === routeGeneration) { + errorMessage = error instanceof Error ? error.message : String(error); + } } finally { - busy = null; + if (generation === routeGeneration) busy = null; } } @@ -152,11 +195,13 @@ principal: Record, ): Promise { if (busy) return; + const generation = routeGeneration; + const path = ticketPath; busy = action; errorMessage = null; try { await workspaceApiJsonWithBody( - `${ticketPath}/assignments/${role}`, + `${path}/assignments/${role}`, { method: "PUT", body: JSON.stringify({ @@ -166,11 +211,16 @@ }), }, ); - applyTicket(await workspaceApiJson(ticketPath)); + if (generation !== routeGeneration) return; + const updatedTicket = await workspaceApiJson(path); + if (generation !== routeGeneration) return; + applyTicket(updatedTicket); } catch (error) { - errorMessage = error instanceof Error ? error.message : String(error); + if (generation === routeGeneration) { + errorMessage = error instanceof Error ? error.message : String(error); + } } finally { - busy = null; + if (generation === routeGeneration) busy = null; } } diff --git a/web/workspace/test/ticket-detail-route-reuse.test.ts b/web/workspace/test/ticket-detail-route-reuse.test.ts new file mode 100644 index 00000000..e1f24b92 --- /dev/null +++ b/web/workspace/test/ticket-detail-route-reuse.test.ts @@ -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"); + } +});