Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions app/components/QueryParamTabs.browser.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { createRoutesStub, useLoaderData, useNavigation } from 'react-router'
import { expect, test } from 'vitest'
import { render } from 'vitest-browser-react'

import { Tabs } from '~/ui/lib/Tabs'

import { QueryParamTabs } from './QueryParamTabs'

test('waits for the loader before showing the new tab', async () => {
let finishLoad = () => {}
// tab each render of the second panel saw in loader data
const panelTwoRenders: string[] = []

function PanelTwo() {
const { tab } = useLoaderData<{ tab: string }>()
panelTwoRenders.push(tab)
return <p>Panel two</p>
}

function Page() {
// like the loading bar in RootLayout, which rerenders while the nav is pending
const navigation = useNavigation()
return (
<QueryParamTabs defaultValue="one">
<Tabs.List>
<Tabs.Trigger value="one">One</Tabs.Trigger>
<Tabs.Trigger value="two">Two</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="one">
<p>Panel one</p>
</Tabs.Content>
<Tabs.Content value="two">
<PanelTwo />
</Tabs.Content>
<p>Navigation {navigation.state}</p>
</QueryParamTabs>
)
}

const Stub = createRoutesStub([
{
path: '/',
Component: Page,
// like a page loader that only prefetches the current tab's data
loader: async ({ request }) => {
const tab = new URL(request.url).searchParams.get('tab') ?? 'one'
if (tab === 'two') await new Promise<void>((resolve) => (finishLoad = resolve))
return { tab }
},
},
])

const screen = await render(<Stub />)
await expect.element(screen.getByText('Panel one')).toBeVisible()

await screen.getByRole('tab', { name: 'Two' }).click()
await expect.element(screen.getByText('Navigation loading')).toBeInTheDocument()
// still on the old tab while the new tab's loader runs
await expect.element(screen.getByText('Panel one')).toBeVisible()
expect(panelTwoRenders).toEqual([])

finishLoad()
await expect.element(screen.getByText('Panel two')).toBeVisible()
expect(panelTwoRenders).not.toContain('one')
})
10 changes: 7 additions & 3 deletions app/components/QueryParamTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,16 @@ export function QueryParamTabs(props: TabsRootProps) {
const value = searchParams.get('tab') || props.defaultValue

function onValueChange(newValue: string | number | null) {
// Copy rather than mutate: the hook's object is what we read `value` from,
// so mutating it switches the tab on the next render, before the navigation
// (and the loader it triggers) has committed.
const nextParams = new URLSearchParams(searchParams)
if (newValue === props.defaultValue) {
searchParams.delete('tab')
nextParams.delete('tab')
} else {
searchParams.set('tab', String(newValue))
nextParams.set('tab', String(newValue))
}
setSearchParams(searchParams, { replace: true })
setSearchParams(nextParams, { replace: true })
Comment on lines +23 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) On the four existing tab pages, switching tabs now waits on network refetches, where before it switched instantly. Changing ?tab= revalidates every matched clientLoader, and the default staleTime: 2000 makes their prefetches refetch. IpPoolPage alone refetches 4-5 endpoints. The tab now waits for those because value no longer reads the mutated params. Fix: tab-only changes on these pages should not wait on a refetch. Export a shouldRevalidate from IpPoolPage, SubnetPoolPage, AlertReceiverPage and UtilizationPage that returns false when only tab changed. Keep revalidation for path changes and other params.

Why this was flagged

A user stays on /system/networking/ip-pools/:pool for more than 2s, then clicks the "Members"/"Linked silos" tab. That calls onValueChange, and setSearchParams(nextParams) starts a navigation. React Router revalidates by default when the search string changes. No route here exports shouldRevalidate (only AuthenticatedLayout does, and it returns true). So IpPoolPage clientLoader (IpPoolPage.tsx:83) runs again. With queryClient staleTime: 2000 (app/api/client.ts:74), it refetches ipPoolView, ipPoolSiloList, ipPoolRangeList, ipPoolUtilizationView and siloList. On the base branch, the mutated memoized params let the nav-state rerender show the new tab at once while the loader ran in the background. With the copy at QueryParamTabs.tsx:26, value stays on the old tab until every request resolves. So each tab click on IpPoolPage, SubnetPoolPage, AlertReceiverPage and UtilizationPage lags by a network round trip.

Verification: setSearchParams(nextParams, { replace: true }) (QueryParamTabs.tsx:32) starts a navigation that changes only the search string. The only shouldRevalidate in app/ is AuthenticatedLayout.tsx:16, and it returns true. With staleTime: 2000 (app/api/client.ts:74), the prefetchQuery/fetchQuery calls refetch. Now value changes only when the navigation commits, so every tab click waits for a round trip.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct and a good catch, but this is actually an improvement. It makes sense for these to wait for the loading to finish before loading the tab. On main, if you change tabs outside of the 2 second staleTime, you get an immediate nav plus a loading bar while the loader reruns. Weird experience, would rather have it get the data up to date before flipping.

https://console-git-fix-query-param-tabs-oxidecomputer.vercel.app/system/networking/ip-pools/ip-pool-1

}

return <Tabs.Root {...props} value={value} onValueChange={onValueChange} />
Expand Down
Loading