From ac6295722a327144faa3f4cb1b916369adee7ea1 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 8 Sep 2026 15:16:43 +0200 Subject: [PATCH] fix(frontend): tag each hub detail with the slug that asked for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resource()` assigns whatever its fetcher returns, with no guard for a run that has been superseded, so handing back the previously fetched project on a stale response published that project. With two slow requests in flight — pick A, leave B loading, pick C — B's answer put A's name, author and counts on the card while the plan underneath still said C, and Import wrote C. Each answer now carries its own slug and is read only while that slug is the chosen one, which also drops the local the previous shape needed to keep the resource's type from going circular. The hub-telemetry tests reset their shared fixture per case; the private-hub one had been passing on what the case above it left behind. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG --- .../components/home/ImportProjectModal.svelte | 25 ++++++++----------- frontend/src/lib/utils/featureUsage.test.ts | 10 ++++++-- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/frontend/src/lib/components/home/ImportProjectModal.svelte b/frontend/src/lib/components/home/ImportProjectModal.svelte index 8e9b6acca2..b079409a9c 100644 --- a/frontend/src/lib/components/home/ImportProjectModal.svelte +++ b/frontend/src/lib/components/home/ImportProjectModal.svelte @@ -141,24 +141,21 @@ // card renders from the pick until it lands — counts are the only thing missing, and // zero counts render as no badges rather than as zeroes. // - // The answer is checked against the slug that asked for it: `resource()` aborts the - // previous controller but `fetchHubProject` takes no signal, and nothing orders the - // responses — so picking A, dismissing, then picking B can land A's name, author and - // counts over an import that writes B. - // Kept in a local rather than read back off `detail.current` inside `detail`'s own fetcher, - // which makes the resource's type circular and resolves it to `any`. - let lastDetail = $state(undefined) + // Each answer carries the slug that asked for it, and is read only while that slug is + // still the chosen one: `resource()` aborts the previous controller but `fetchHubProject` + // takes no signal, and nothing orders the responses — so picking A, dismissing, then + // picking B can land A's name, author and counts over an import that writes B. Tagging + // rather than substituting, because whatever the fetcher returns is published: handing + // back the previous project on a superseded response is how it reaches the card. const detail = resource( () => slug, - async (s) => { - if (!s) return undefined - const fetched = await fetchHubProject(s) - if (s === slug) lastDetail = fetched - return lastDetail - } + async (s) => (s ? { slug: s, project: await fetchHubProject(s) } : undefined) + ) + let fetchedDetail = $derived( + detail.current?.slug === slug ? detail.current?.project : undefined ) let project = $derived( - detail.current ?? + fetchedDetail ?? (pick ? { slug: pick.slug, diff --git a/frontend/src/lib/utils/featureUsage.test.ts b/frontend/src/lib/utils/featureUsage.test.ts index 70dd2e9de9..1857efb0c0 100644 --- a/frontend/src/lib/utils/featureUsage.test.ts +++ b/frontend/src/lib/utils/featureUsage.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('$lib/gen', () => ({ OpenAPI: { BASE: '/api' } })) @@ -133,6 +133,13 @@ describe('hubScriptUsageKey', () => { }) describe('hubProjectUsageKey', () => { + // The fixture is module-level and mutable, so each case states the world it needs rather + // than inheriting whatever the case above it left behind. + beforeEach(() => { + hubBaseUrl.url.set('https://hub.windmill.dev') + hubBaseUrl.known.set(true) + }) + it('reports the slug for every spelling of the public hub', () => { for (const hub of [ 'https://hub.windmill.dev', @@ -150,7 +157,6 @@ describe('hubProjectUsageKey', () => { it('answers private until the instance has said which hub it points at', () => { // The store is seeded with the public hub, so a settings read that failed must not // read as permission to report the name. - hubBaseUrl.url.set('https://hub.windmill.dev') hubBaseUrl.known.set(false) expect(hubProjectUsageKey('acme-payroll')).toBe('private') hubBaseUrl.known.set(true)