diff --git a/e2e/account-secrets.spec.ts b/e2e/account-secrets.spec.ts new file mode 100644 index 0000000000..8aac41da51 --- /dev/null +++ b/e2e/account-secrets.spec.ts @@ -0,0 +1,82 @@ +import { expect, test } from './playwright-utils.ts' + +async function saveSecret( + page: Parameters< + Parameters[1] + >[0]['page'], + input: { + name: string + description: string + value: string + } +) { + const response = await page.request.post('/account/secrets.json', { + data: { + action: 'save', + name: input.name, + scope: 'user', + appId: null, + description: input.description, + value: input.value, + allowedHosts: [], + allowedCapabilities: [], + }, + headers: { 'Content-Type': 'application/json' }, + }) + expect(response.ok()).toBeTruthy() +} + +test('switching secrets updates detail view without a full reload', async ({ + page, + login, +}) => { + await login() + + const nonce = Date.now().toString(36) + const firstSecret = { + name: `secret-switch-a-${nonce}`, + description: `First router test secret ${nonce}`, + value: `value-a-${nonce}`, + } + const secondSecret = { + name: `secret-switch-b-${nonce}`, + description: `Second router test secret ${nonce}`, + value: `value-b-${nonce}`, + } + + await saveSecret(page, firstSecret) + await saveSecret(page, secondSecret) + + await page.goto(`/account/secrets/user/${firstSecret.name}`) + await expect( + page.getByRole('heading', { level: 2, name: firstSecret.name }), + ).toBeVisible() + await expect(page.getByLabel('Description')).toHaveValue(firstSecret.description) + + await page.evaluate(() => { + ;(window as typeof window & { __secretRouteMarker?: string }).__secretRouteMarker = + 'still-here' + }) + + await page.getByRole('button', { name: secondSecret.name }).click() + + await expect(page).toHaveURL( + new RegExp(`/account/secrets/user/${secondSecret.name}$`), + ) + await expect( + page.getByRole('heading', { level: 2, name: secondSecret.name }), + ).toBeVisible() + await expect(page.getByLabel('Description')).toHaveValue( + secondSecret.description, + ) + await expect( + page.getByPlaceholder('Enter the secret value').first(), + ).toHaveValue(secondSecret.value) + await expect( + page.evaluate( + () => + (window as typeof window & { __secretRouteMarker?: string }) + .__secretRouteMarker, + ), + ).resolves.toBe('still-here') +}) diff --git a/packages/worker/client/routes/account-secrets.tsx b/packages/worker/client/routes/account-secrets.tsx index d88e4be4d8..1fbcbfb739 100644 --- a/packages/worker/client/routes/account-secrets.tsx +++ b/packages/worker/client/routes/account-secrets.tsx @@ -4,7 +4,10 @@ import { parseAccountSecretId, parseAccountSecretPath, } from '@kody-internal/shared/account-secret-route.ts' -import { navigate, routerEvents } from '#client/client-router.tsx' +import { + navigate, + routerEvents, +} from '#client/client-router.tsx' import { createDoubleCheck } from '#client/double-check.ts' import { type AccountStatus, @@ -329,6 +332,10 @@ export function AccountSecretsRoute(handle: Handle) { let submittingApprovalAction: ApprovalAction | null = null let saveState: 'idle' | 'saving' | 'deleting' = 'idle' let lastLoadedDataKey = '' + let lastFailedDataKey: string | null = null + let loadingDataKey: string | null = null + let loadRequestId = 0 + let retryTimeout: ReturnType | null = null let showSecretValue = false const deleteSecretCheck = createDoubleCheck(handle) const filterAppCombobox = TypeaheadCombobox(handle) @@ -393,11 +400,13 @@ export function AccountSecretsRoute(handle: Handle) { saveState = 'idle' } - async function loadAccountSecrets(signal: AbortSignal) { + async function loadAccountSecrets() { + const href = getCurrentHref() + const selection = getSelectionState(href) + const dataKey = getDataRefreshKey(href) + const requestId = ++loadRequestId + loadingDataKey = dataKey try { - const href = getCurrentHref() - const selection = getSelectionState(href) - lastLoadedDataKey = getDataRefreshKey(href) const requestUrl = new URL(accountSecretsApiPath, href) requestUrl.search = new URL(href).search if (selection.selectedSecretId) { @@ -409,9 +418,12 @@ export function AccountSecretsRoute(handle: Handle) { const response = await fetch(requestUrl.toString(), { headers: { Accept: 'application/json' }, credentials: 'include', - signal, }) - if (signal.aborted) return + if ( + requestId !== loadRequestId || + getDataRefreshKey(getCurrentHref()) !== dataKey + ) + return if (response.status === 401) { window.location.assign('/login') return @@ -422,14 +434,42 @@ export function AccountSecretsRoute(handle: Handle) { throw new Error('Unable to load your secrets.') } + lastLoadedDataKey = dataKey + lastFailedDataKey = null + if (retryTimeout) { + clearTimeout(retryTimeout) + retryTimeout = null + } applyPayload(payload, selection, null) handle.update() } catch (error) { - if (signal.aborted) return + if ( + requestId !== loadRequestId || + getDataRefreshKey(getCurrentHref()) !== dataKey + ) + return + lastFailedDataKey = dataKey status = 'error' message = error instanceof Error ? error.message : 'Unable to load your secrets.' handle.update() + if (typeof window !== 'undefined') { + if (retryTimeout) { + clearTimeout(retryTimeout) + retryTimeout = null + } + retryTimeout = window.setTimeout(() => { + retryTimeout = null + if (lastFailedDataKey !== dataKey) return + if (getDataRefreshKey(getCurrentHref()) !== dataKey) return + lastFailedDataKey = null + handle.update() + }, 3000) + } + } finally { + if (requestId === loadRequestId && loadingDataKey === dataKey) { + loadingDataKey = null + } } } @@ -678,10 +718,16 @@ export function AccountSecretsRoute(handle: Handle) { }, ...appOptions, ] + const currentDataKey = getDataRefreshKey(currentHref) const isRefreshingForLocationChange = status !== 'loading' && - getDataRefreshKey(currentHref) !== lastLoadedDataKey - if (status === 'loading' || isRefreshingForLocationChange) { + currentDataKey !== lastLoadedDataKey && + currentDataKey !== lastFailedDataKey + const isLoadingCurrentLocation = loadingDataKey === currentDataKey + if ( + (status === 'loading' || isRefreshingForLocationChange) && + !isLoadingCurrentLocation + ) { handle.queueTask(loadAccountSecrets) }