Repository navigation
Stabilize account secret detail refresh #75
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4a7b6a8
8271f76
f7176dd
7c8c7eb
dbba38d
7eee19d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| import { expect, test } from './playwright-utils.ts' | ||
|
|
||
| async function saveSecret( | ||
| page: Parameters< | ||
| Parameters<typeof test>[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') | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<typeof setTimeout> | 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) | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } finally { | ||
| if (requestId === loadRequestId && loadingDataKey === dataKey) { | ||
| loadingDataKey = null | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
|
||
|
|
@@ -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 | ||
| ) { | ||
|
coderabbitai[bot] marked this conversation as resolved.
Comment on lines
+721
to
+730
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Split “stale view” from “retry suppression” to avoid stale-detail actions after failed switch. At Line 724-Line 725, failed-key suppression is folded into 💡 Suggested fix- const isRefreshingForLocationChange =
- status !== 'loading' &&
- currentDataKey !== lastLoadedDataKey &&
- currentDataKey !== lastFailedDataKey
+ const isStaleForCurrentLocation = currentDataKey !== lastLoadedDataKey
+ const isRetrySuppressedForFailedKey = currentDataKey === lastFailedDataKey
+ const isRefreshingForLocationChange =
+ status !== 'loading' &&
+ isStaleForCurrentLocation &&
+ !isRetrySuppressedForFailedKey
const isLoadingCurrentLocation = loadingDataKey === currentDataKeyThen use 🤖 Prompt for AI Agents |
||
| handle.queueTask(loadAccountSecrets) | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.