From f21c7a99f13ba9a7f9392c05490f170515b1f003 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 29 Jul 2022 17:31:48 -0500 Subject: [PATCH 1/6] use a react-router loader to fetch the VPC for VpcPage --- app/components/Breadcrumbs.tsx | 3 +- app/components/ErrorBoundary.tsx | 3 +- app/hooks/use-params.ts | 30 +++++---- app/main.tsx | 13 +--- .../project/networking/VpcPage/VpcPage.tsx | 26 +++++--- app/routes.tsx | 9 ++- libs/api/hooks.ts | 61 +++++++++++-------- libs/api/index.ts | 28 ++++++++- package.json | 2 +- yarn.lock | 28 ++++----- 10 files changed, 125 insertions(+), 78 deletions(-) diff --git a/app/components/Breadcrumbs.tsx b/app/components/Breadcrumbs.tsx index a4a57423ba..3b3d9ea94f 100644 --- a/app/components/Breadcrumbs.tsx +++ b/app/components/Breadcrumbs.tsx @@ -19,7 +19,8 @@ const useCrumbs = () => useMatches() .map((m) => { invariant( - !m.handle || ['string', 'function'].includes(typeof m.handle.crumb), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + !m.handle || ['string', 'function'].includes(typeof (m.handle as any).crumb), `Route crumb must be a string or function if present. Check Route for ${m.pathname}.` ) return m as ValidatedMatch diff --git a/app/components/ErrorBoundary.tsx b/app/components/ErrorBoundary.tsx index cbe422936a..a8d6f2f905 100644 --- a/app/components/ErrorBoundary.tsx +++ b/app/components/ErrorBoundary.tsx @@ -26,6 +26,7 @@ export const ErrorBoundary = (props: { children: React.ReactNode }) => ( ) export function RouterDataErrorBoundary() { - const error = useRouteError() + // TODO: validate this unknown at runtime _before_ passing to ErrorFallback + const error = useRouteError() as Props['error'] return } diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts index 5ec82744fc..86bf17f2d6 100644 --- a/app/hooks/use-params.ts +++ b/app/hooks/use-params.ts @@ -5,6 +5,24 @@ import invariant from 'tiny-invariant' const err = (param: string) => `Param '${param}' not found in route. You might be rendering a component under the wrong route.` +export const requireParams = + (...requiredKeys: K[]) => + (params: Readonly>) => { + const requiredParams: { [k in K]?: string } = {} + if (process.env.NODE_ENV !== 'production') { + for (const k of requiredKeys) { + const value = params[k] + invariant(k in params && value, err(k)) + requiredParams[k] = value + } + } + return requiredParams as { readonly [k in K]: string } + } + +export const requireInstanceParams = requireParams('orgName', 'projectName', 'instanceName') +export const requireVpcParams = requireParams('orgName', 'projectName', 'vpcName') +export const requireProjectParams = requireParams('orgName', 'projectName') + /** * Wrapper for RR's `useParams` that guarantees (in dev) that the specified * params are present. No keys besides those specified are present on the result @@ -13,17 +31,7 @@ const err = (param: string) => // default of never is required to prevent the highly undesirable property that if // you don't pass any arguments, the result object thinks every property is defined export function useRequiredParams(...requiredKeys: K[]) { - const params = useParams() - // same as below except we build an object with only the specified keys - const requiredParams: { [k in K]?: string } = {} - if (process.env.NODE_ENV !== 'production') { - for (const k of requiredKeys) { - const value = params[k] - invariant(k in params && value, err(k)) - requiredParams[k] = value - } - } - return requiredParams as { readonly [k in K]: string } + return requireParams(...requiredKeys)(useParams()) } /** diff --git a/app/main.tsx b/app/main.tsx index 7b2460ce9d..24250d9c6f 100644 --- a/app/main.tsx +++ b/app/main.tsx @@ -1,7 +1,8 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClientProvider } from '@tanstack/react-query' import { StrictMode } from 'react' import ReactDOM from 'react-dom' +import { queryClient } from '@oxide/api' import { SkipLink } from '@oxide/ui' import { ErrorBoundary } from './components/ErrorBoundary' @@ -18,16 +19,6 @@ if (process.env.SHA) { ) } -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - staleTime: 2000, - networkMode: 'offlineFirst', - }, - }, -}) - function render() { ReactDOM.render( diff --git a/app/pages/project/networking/VpcPage/VpcPage.tsx b/app/pages/project/networking/VpcPage/VpcPage.tsx index 8bcb81bff1..ed56c776bd 100644 --- a/app/pages/project/networking/VpcPage/VpcPage.tsx +++ b/app/pages/project/networking/VpcPage/VpcPage.tsx @@ -1,10 +1,12 @@ import { format } from 'date-fns' +import type { LoaderFunctionArgs } from 'react-router-dom' +import { useLoaderData } from 'react-router-dom' -import { useApiQuery } from '@oxide/api' +import { apiQueryClient } from '@oxide/api' import { Networking24Icon, PageHeader, PageTitle, PropertiesTable } from '@oxide/ui' import { Tab, Tabs } from 'app/components/Tabs' -import { useRequiredParams } from 'app/hooks' +import { requireVpcParams } from 'app/hooks' import { VpcFirewallRulesTab } from './tabs/VpcFirewallRulesTab' import { VpcRoutersTab } from './tabs/VpcRoutersTab' @@ -13,26 +15,30 @@ import { VpcSystemRoutesTab } from './tabs/VpcSystemRoutesTab' const formatDateTime = (d: Date) => format(d, 'MMM d, yyyy H:mm aa') +const loader = async ({ params }: LoaderFunctionArgs) => + apiQueryClient.fetchQuery('vpcView', requireVpcParams(params)) + export const VpcPage = () => { - const vpcParams = useRequiredParams('orgName', 'projectName', 'vpcName') - const { data: vpc } = useApiQuery('vpcView', vpcParams) + // could do `as Vpc`, but this keeps us more honest until they get Remix's + // loader type inference into the router + const vpc = useLoaderData() as Awaited> return ( <> - }>{vpc?.name || ''} + }>{vpc.name || ''} - {vpc?.description} - {vpc?.dnsName} + {vpc.description} + {vpc.dnsName} - {vpc?.timeCreated && formatDateTime(vpc.timeCreated)} + {vpc.timeCreated && formatDateTime(vpc.timeCreated)} - {vpc?.timeModified && formatDateTime(vpc.timeModified)} + {vpc.timeModified && formatDateTime(vpc.timeModified)} @@ -58,3 +64,5 @@ export const VpcPage = () => { ) } + +VpcPage.loader = loader diff --git a/app/routes.tsx b/app/routes.tsx index 9f0b5aeba7..7208ca6906 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -40,7 +40,7 @@ const instanceCrumb: CrumbFunc = (m) => m.params.instanceName! const vpcCrumb: CrumbFunc = (m) => m.params.vpcName! export const Router = () => ( - loading}> + } /> }> } /> @@ -106,7 +106,12 @@ export const Router = () => ( } /> - } handle={{ crumb: vpcCrumb }} /> + } + loader={VpcPage.loader} + handle={{ crumb: vpcCrumb }} + /> } /> diff --git a/libs/api/hooks.ts b/libs/api/hooks.ts index 9ca52916d9..b5d67a4af2 100644 --- a/libs/api/hooks.ts +++ b/libs/api/hooks.ts @@ -1,5 +1,7 @@ import type { + FetchQueryOptions, InvalidateQueryFilters, + QueryClient, QueryFilters, QueryKey, UseMutationOptions, @@ -84,33 +86,40 @@ export const getUseApiMutation = options ) +export const wrapQueryClient = (api: A, queryClient: QueryClient) => ({ + invalidateQueries: ( + method: M, + params?: Params, + filters?: InvalidateQueryFilters + ) => queryClient.invalidateQueries(params ? [method, params] : [method], filters), + setQueryData: (method: M, params: Params, data: Result) => + queryClient.setQueryData([method, params], data), + cancelQueries: ( + method: M, + params?: Params, + filters?: QueryFilters + ) => queryClient.cancelQueries(params ? [method, params] : [method], filters), + refetchQueries: ( + method: M, + params?: Params, + filters?: QueryFilters + ) => queryClient.refetchQueries(params ? [method, params] : [method], filters), + fetchQuery: ( + method: M, + params?: Params, + options: FetchQueryOptions, ErrorResponse> = {} + ) => + queryClient.fetchQuery({ + queryKey: [method, params], + queryFn: () => api[method](params).then((resp) => resp.data), + ...options, + }), +}) + export const getUseApiQueryClient = - () => - () => { - const queryClient = useQueryClient() - return { - invalidateQueries: ( - method: M, - params?: Params, - filters?: InvalidateQueryFilters - ) => queryClient.invalidateQueries(params ? [method, params] : [method], filters), - setQueryData: ( - method: M, - params: Params, - data: Result - ) => queryClient.setQueryData([method, params], data), - cancelQueries: ( - method: M, - params?: Params, - filters?: QueryFilters - ) => queryClient.cancelQueries(params ? [method, params] : [method], filters), - refetchQueries: ( - method: M, - params?: Params, - filters?: QueryFilters - ) => queryClient.refetchQueries(params ? [method, params] : [method], filters), - } - } + (api: A) => + () => + wrapQueryClient(api, useQueryClient()) /* 1. what's up with [method, params]? diff --git a/libs/api/index.ts b/libs/api/index.ts index d16a91fe6e..4198d6ef46 100644 --- a/libs/api/index.ts +++ b/libs/api/index.ts @@ -1,8 +1,15 @@ // for convenience so we can do `import type { ApiTypes } from '@oxide/api'` +import { QueryClient } from '@tanstack/react-query' + import type * as ApiTypes from './__generated__/Api' import { Api } from './__generated__/Api' import { handleErrors } from './errors' -import { getUseApiMutation, getUseApiQuery, getUseApiQueryClient } from './hooks' +import { + getUseApiMutation, + getUseApiQuery, + getUseApiQueryClient, + wrapQueryClient, +} from './hooks' const api = new Api({ baseUrl: process.env.API_URL, @@ -12,7 +19,24 @@ export type ApiMethods = typeof api.methods export const useApiQuery = getUseApiQuery(api.methods, handleErrors) export const useApiMutation = getUseApiMutation(api.methods, handleErrors) -export const useApiQueryClient = getUseApiQueryClient() + +// Needs to be defined here instead of in app so we can use it to define +// `apiQueryClient`, which provides API-typed versions of QueryClient methods +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: 10000, + }, + }, +}) + +// to be used in loaders, which are outside the component tree and therefore +// don't have access to context +export const apiQueryClient = wrapQueryClient(api.methods, queryClient) + +// to be used to retrieve the typed query client in components +export const useApiQueryClient = getUseApiQueryClient(api.methods) export * from './roles' export * from './util' diff --git a/package.json b/package.json index d53b0a2e93..450912d483 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "react-error-boundary": "^3.1.3", "react-is": "^17.0.2", "react-popper": "^2.2.5", - "react-router-dom": "^6.4.0-pre.8", + "react-router-dom": "^6.4.0-pre.10", "recharts": "^2.1.6", "tiny-invariant": "^1.2.0", "ts-dedent": "^2.2.0", diff --git a/yarn.lock b/yarn.lock index e071cfa85d..ea65fb59ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2804,10 +2804,10 @@ "@react-spring/shared" "~9.4.5" "@react-spring/types" "~9.4.5" -"@remix-run/router@0.2.0-pre.3": - version "0.2.0-pre.3" - resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-0.2.0-pre.3.tgz#2864cf275433ec39d2370a1aec28a5957060f47a" - integrity sha512-/1vRiMh8j31PBgLgB2lqcwDDEBEwsnucOouZ8GvJTRNHULkPOU3kvQ1ZUpt9IPd1lS5AVC93I9pVLnFhlNA4Mw== +"@remix-run/router@0.2.0-pre.5": + version "0.2.0-pre.5" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-0.2.0-pre.5.tgz#ce0cc27d0c19b915c8b1b56ed54d3bd2c1cedf37" + integrity sha512-OisGorQBu4xO0x2p3j8TdKafk6bVTRfA2qX6aj4URoBJl/5Dpdit1dBBK6qgaFw4zO/Wa3dFQsQ5szj4CzPrEw== "@storybook/addon-actions@6.5.9": version "6.5.9" @@ -12939,19 +12939,19 @@ react-resize-detector@^6.6.3: lodash.throttle "^4.1.1" resize-observer-polyfill "^1.5.1" -react-router-dom@^6.4.0-pre.8: - version "6.4.0-pre.8" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.4.0-pre.8.tgz#1f784fa09719801250070431703dd02c5fdcde6e" - integrity sha512-5hxQdiPOG98YOANbJ8IBUKbRgU6k4/TSX4VyO6jRWoG6KY0K2EG5wjEZgZ0tlAN9fXFgnHsro/HKi/r+HxugAg== +react-router-dom@^6.4.0-pre.10: + version "6.4.0-pre.10" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.4.0-pre.10.tgz#70e85dc4cc19b8554ecf892995b200d60d284222" + integrity sha512-ebGY+wZmW8Nu/gR9X8SX3kU4H90xrG389s53SGwgC5m2LBYE9LKhiq7fppS6oqHkqZoKB4QUGEC8AFAf2cUscw== dependencies: - react-router "6.4.0-pre.8" + react-router "6.4.0-pre.10" -react-router@6.4.0-pre.8: - version "6.4.0-pre.8" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.4.0-pre.8.tgz#90ce4cc590b453fc26ad2d131f51d908783c9091" - integrity sha512-xwt2s9jW5NaakymlSRG0hZoGda6E/Skfgmj/4vlwvRX/kqoO8jU0g+Vi3sHDCatH/V+GjDyTQkdMEDm8qGsBsQ== +react-router@6.4.0-pre.10: + version "6.4.0-pre.10" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.4.0-pre.10.tgz#acb7104f299f73be59a646fa7c7c2c8d7fbebd03" + integrity sha512-PlDP/CMekSZvfwf9QNpxuerDxGymjAHMeQNepOjt/fqgVbBUj9sL4R8VeggxbfaCWS6Cdzy2FsHmVziTwPRFhQ== dependencies: - "@remix-run/router" "0.2.0-pre.3" + "@remix-run/router" "0.2.0-pre.5" react-smooth@^2.0.0: version "2.0.0" From 2164f853bce9ed11c021fb72fa894d94adc1f135 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 29 Jul 2022 23:53:22 -0500 Subject: [PATCH 2/6] do a prefetch instead of a fetch in the loader, use RQ in component --- app/hooks/use-params.ts | 2 ++ .../project/networking/VpcPage/VpcPage.tsx | 22 +++++++++---------- libs/api/hooks.ts | 10 +++++++++ 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts index 86bf17f2d6..4562835a16 100644 --- a/app/hooks/use-params.ts +++ b/app/hooks/use-params.ts @@ -23,6 +23,8 @@ export const requireInstanceParams = requireParams('orgName', 'projectName', 'in export const requireVpcParams = requireParams('orgName', 'projectName', 'vpcName') export const requireProjectParams = requireParams('orgName', 'projectName') +export const useVpcParams = () => requireVpcParams(useParams()) + /** * Wrapper for RR's `useParams` that guarantees (in dev) that the specified * params are present. No keys besides those specified are present on the result diff --git a/app/pages/project/networking/VpcPage/VpcPage.tsx b/app/pages/project/networking/VpcPage/VpcPage.tsx index ed56c776bd..1f82959dac 100644 --- a/app/pages/project/networking/VpcPage/VpcPage.tsx +++ b/app/pages/project/networking/VpcPage/VpcPage.tsx @@ -1,12 +1,11 @@ import { format } from 'date-fns' import type { LoaderFunctionArgs } from 'react-router-dom' -import { useLoaderData } from 'react-router-dom' -import { apiQueryClient } from '@oxide/api' +import { apiQueryClient, useApiQuery } from '@oxide/api' import { Networking24Icon, PageHeader, PageTitle, PropertiesTable } from '@oxide/ui' import { Tab, Tabs } from 'app/components/Tabs' -import { requireVpcParams } from 'app/hooks' +import { requireVpcParams, useVpcParams } from 'app/hooks' import { VpcFirewallRulesTab } from './tabs/VpcFirewallRulesTab' import { VpcRoutersTab } from './tabs/VpcRoutersTab' @@ -16,29 +15,28 @@ import { VpcSystemRoutesTab } from './tabs/VpcSystemRoutesTab' const formatDateTime = (d: Date) => format(d, 'MMM d, yyyy H:mm aa') const loader = async ({ params }: LoaderFunctionArgs) => - apiQueryClient.fetchQuery('vpcView', requireVpcParams(params)) + apiQueryClient.prefetchQuery('vpcView', requireVpcParams(params)) export const VpcPage = () => { - // could do `as Vpc`, but this keeps us more honest until they get Remix's - // loader type inference into the router - const vpc = useLoaderData() as Awaited> + const vpcParams = useVpcParams() + const { data: vpc } = useApiQuery('vpcView', vpcParams) return ( <> - }>{vpc.name || ''} + }>{vpc?.name || ''} - {vpc.description} - {vpc.dnsName} + {vpc?.description} + {vpc?.dnsName} - {vpc.timeCreated && formatDateTime(vpc.timeCreated)} + {vpc?.timeCreated && formatDateTime(vpc.timeCreated)} - {vpc.timeModified && formatDateTime(vpc.timeModified)} + {vpc?.timeModified && formatDateTime(vpc.timeModified)} diff --git a/libs/api/hooks.ts b/libs/api/hooks.ts index b5d67a4af2..3ddb0804e1 100644 --- a/libs/api/hooks.ts +++ b/libs/api/hooks.ts @@ -114,6 +114,16 @@ export const wrapQueryClient = (api: A, queryClient: QueryC queryFn: () => api[method](params).then((resp) => resp.data), ...options, }), + prefetchQuery: ( + method: M, + params?: Params, + options: FetchQueryOptions, ErrorResponse> = {} + ) => + queryClient.prefetchQuery({ + queryKey: [method, params], + queryFn: () => api[method](params).then((resp) => resp.data), + ...options, + }), }) export const getUseApiQueryClient = From c7f36e4e02475da57e3db3246f35599881dc6fef Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 2 Aug 2022 12:19:07 -0500 Subject: [PATCH 3/6] bump RR to 6.4.0-pre.11 --- libs/api-mocks/msw/util.ts | 3 +++ package.json | 2 +- yarn.lock | 28 ++++++++++++++-------------- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/libs/api-mocks/msw/util.ts b/libs/api-mocks/msw/util.ts index b8c9a4b7b6..52f9606685 100644 --- a/libs/api-mocks/msw/util.ts +++ b/libs/api-mocks/msw/util.ts @@ -45,3 +45,6 @@ export const paginated = ( nextPage: `${items[startIndex + limit].id}`, } } + +// handy for testing +export const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)) diff --git a/package.json b/package.json index 450912d483..d6a7289134 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "react-error-boundary": "^3.1.3", "react-is": "^17.0.2", "react-popper": "^2.2.5", - "react-router-dom": "^6.4.0-pre.10", + "react-router-dom": "^6.4.0-pre.11", "recharts": "^2.1.6", "tiny-invariant": "^1.2.0", "ts-dedent": "^2.2.0", diff --git a/yarn.lock b/yarn.lock index ea65fb59ab..50bf7040e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2804,10 +2804,10 @@ "@react-spring/shared" "~9.4.5" "@react-spring/types" "~9.4.5" -"@remix-run/router@0.2.0-pre.5": - version "0.2.0-pre.5" - resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-0.2.0-pre.5.tgz#ce0cc27d0c19b915c8b1b56ed54d3bd2c1cedf37" - integrity sha512-OisGorQBu4xO0x2p3j8TdKafk6bVTRfA2qX6aj4URoBJl/5Dpdit1dBBK6qgaFw4zO/Wa3dFQsQ5szj4CzPrEw== +"@remix-run/router@0.2.0-pre.6": + version "0.2.0-pre.6" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-0.2.0-pre.6.tgz#6a878c3f76e95b6d0860ca4bc0705d11c357f2cd" + integrity sha512-AhmScBjypbo/ZpFB/4L4TcPNvcRyGZNQVPWcCo68EaDbt1pNQFMFvyWxBebfvTgBek9LNLQNZQKvmKpCIh9MpQ== "@storybook/addon-actions@6.5.9": version "6.5.9" @@ -12939,19 +12939,19 @@ react-resize-detector@^6.6.3: lodash.throttle "^4.1.1" resize-observer-polyfill "^1.5.1" -react-router-dom@^6.4.0-pre.10: - version "6.4.0-pre.10" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.4.0-pre.10.tgz#70e85dc4cc19b8554ecf892995b200d60d284222" - integrity sha512-ebGY+wZmW8Nu/gR9X8SX3kU4H90xrG389s53SGwgC5m2LBYE9LKhiq7fppS6oqHkqZoKB4QUGEC8AFAf2cUscw== +react-router-dom@^6.4.0-pre.11: + version "6.4.0-pre.11" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.4.0-pre.11.tgz#9f6d22d5e1a357a2ccdb451f39825e443d06f639" + integrity sha512-TTNg7QRknz/aGGmbuYdV68Cur7H9FKmVOIXYEmHlHixI3sf2/VtZnq15jXZAl0IdXEVHVa/730/NhIk4d0lvDA== dependencies: - react-router "6.4.0-pre.10" + react-router "6.4.0-pre.11" -react-router@6.4.0-pre.10: - version "6.4.0-pre.10" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.4.0-pre.10.tgz#acb7104f299f73be59a646fa7c7c2c8d7fbebd03" - integrity sha512-PlDP/CMekSZvfwf9QNpxuerDxGymjAHMeQNepOjt/fqgVbBUj9sL4R8VeggxbfaCWS6Cdzy2FsHmVziTwPRFhQ== +react-router@6.4.0-pre.11: + version "6.4.0-pre.11" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.4.0-pre.11.tgz#4ed8cb9619178a86caf8e65d8e921efdb831709c" + integrity sha512-y4vPA/jtLkz2doiqGqLHKpAQkwgUBZHRicSE5N8uqjZrZKUhBSHZcx4KSQK0sfTuPqOUtc7GFFyOZcGDDZSyzw== dependencies: - "@remix-run/router" "0.2.0-pre.5" + "@remix-run/router" "0.2.0-pre.6" react-smooth@^2.0.0: version "2.0.0" From 5c17e8a058015e1eaf65eeb31cd43bfe44144293 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 2 Aug 2022 14:34:14 -0500 Subject: [PATCH 4/6] use loader for vpcs index too --- app/pages/project/networking/VpcsPage.tsx | 12 ++++++++++-- app/routes.tsx | 2 +- libs/api-mocks/msw/util.ts | 5 +++++ libs/api-mocks/vpc.ts | 4 +--- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/app/pages/project/networking/VpcsPage.tsx b/app/pages/project/networking/VpcsPage.tsx index 0e2be1c820..f96db6f671 100644 --- a/app/pages/project/networking/VpcsPage.tsx +++ b/app/pages/project/networking/VpcsPage.tsx @@ -1,8 +1,9 @@ import { useMemo } from 'react' +import type { LoaderFunctionArgs } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom' import type { Vpc } from '@oxide/api' -import { useApiMutation, useApiQuery, useApiQueryClient } from '@oxide/api' +import { apiQueryClient, useApiMutation, useApiQuery, useApiQueryClient } from '@oxide/api' import type { MenuAction } from '@oxide/table' import { DateCell, linkCell, useQueryTable } from '@oxide/table' import { @@ -16,7 +17,7 @@ import { import CreateVpcSideModalForm from 'app/forms/vpc-create' import EditVpcSideModalForm from 'app/forms/vpc-edit' -import { useQuickActions, useRequiredParams } from 'app/hooks' +import { requireProjectParams, useQuickActions, useRequiredParams } from 'app/hooks' const EmptyState = () => ( + apiQueryClient.prefetchQuery('vpcList', { ...requireProjectParams(params), limit: 10 }) + export const VpcsPage = ({ modal }: VpcsPageProps) => { const queryClient = useApiQueryClient() const { orgName, projectName } = useRequiredParams('orgName', 'projectName') @@ -109,3 +115,5 @@ export const VpcsPage = ({ modal }: VpcsPageProps) => { ) } + +VpcsPage.loader = loader diff --git a/app/routes.tsx b/app/routes.tsx index 7208ca6906..88b6d63b36 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -101,7 +101,7 @@ export const Router = () => ( - } /> + } loader={VpcsPage.loader} /> } /> } /> diff --git a/libs/api-mocks/msw/util.ts b/libs/api-mocks/msw/util.ts index 52f9606685..5cef397cb3 100644 --- a/libs/api-mocks/msw/util.ts +++ b/libs/api-mocks/msw/util.ts @@ -48,3 +48,8 @@ export const paginated = ( // handy for testing export const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)) + +// make a bunch of copies of an object with different names and IDs. useful for +// testing pagination +export const repeat = (obj: T, n: number): T[] => + new Array(n).fill(0).map((_, i) => ({ ...obj, id: obj.id + i, name: obj.name + i })) diff --git a/libs/api-mocks/vpc.ts b/libs/api-mocks/vpc.ts index cf46e14d88..b5e34b6f31 100644 --- a/libs/api-mocks/vpc.ts +++ b/libs/api-mocks/vpc.ts @@ -1,6 +1,6 @@ import type { RouterRoute } from 'libs/api/__generated__/Api' -import type { Vpc, VpcFirewallRule, VpcResultsPage, VpcRouter, VpcSubnet } from '@oxide/api' +import type { Vpc, VpcFirewallRule, VpcRouter, VpcSubnet } from '@oxide/api' import type { Json } from './json-type' import { project } from './project' @@ -20,8 +20,6 @@ export const vpc: Json = { time_modified, } -export const vpcs: Json = { items: [vpc] } - export const vpcSubnet: Json = { // this is supposed to be flattened into the top level. will fix in API id: 'vpc-subnet-id', From 159875ee5bfa227d806e3a558632800ca8145f44 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 3 Aug 2022 15:25:26 -0500 Subject: [PATCH 5/6] rework json helper to make adding `delay` easier --- app/test/server.ts | 9 +++++---- libs/api-mocks/msw/db.ts | 2 +- libs/api-mocks/msw/handlers.ts | 30 +++++++++++++++--------------- libs/api-mocks/msw/util.ts | 18 ++++++++++-------- 4 files changed, 31 insertions(+), 28 deletions(-) diff --git a/app/test/server.ts b/app/test/server.ts index ea345855e9..c8157ad254 100644 --- a/app/test/server.ts +++ b/app/test/server.ts @@ -1,7 +1,7 @@ import { rest } from 'msw' import { setupServer } from 'msw/node' -import { handlers, json } from '@oxide/api-mocks' +import { handlers } from '@oxide/api-mocks' export const server = setupServer(...handlers) @@ -15,9 +15,10 @@ export function overrideOnce( server.use( rest[method](path, (_req, res, ctx) => // https://mswjs.io/docs/api/response/once - typeof body === 'string' - ? res.once(ctx.status(status), ctx.text(body)) - : res.once(json(body, status)) + res.once( + ctx.status(status), + typeof body === 'string' ? ctx.text(body) : ctx.json(body) + ) ) ) } diff --git a/libs/api-mocks/msw/db.ts b/libs/api-mocks/msw/db.ts index 350f5edd80..49786b8d5d 100644 --- a/libs/api-mocks/msw/db.ts +++ b/libs/api-mocks/msw/db.ts @@ -10,7 +10,7 @@ import { json } from './util' const notFoundBody = { error_code: 'ObjectNotFound' } as const export type NotFound = typeof notFoundBody -export const notFoundErr = json({ error_code: 'ObjectNotFound' } as const, 404) +export const notFoundErr = json({ error_code: 'ObjectNotFound' } as const, { status: 404 }) type Ok = [T, null] type LookupError = typeof notFoundErr // Lookups can only 404 diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index ad4a7f2e33..da32b2a936 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -56,11 +56,11 @@ function getTimestamps() { const alreadyExistsBody = { error_code: 'ObjectAlreadyExists' } as const type AlreadyExists = typeof alreadyExistsBody -const alreadyExistsErr = json(alreadyExistsBody, 400) +const alreadyExistsErr = json(alreadyExistsBody, { status: 400 }) const unavailableBody = { error_code: 'ServiceUnavailable' } as const type Unavailable = typeof unavailableBody -const unavailableErr = json(unavailableBody, 503) +const unavailableErr = json(unavailableBody, { status: 503 }) const badRequest = (msg: string) => compose( @@ -115,7 +115,7 @@ export const handlers = [ ...getTimestamps(), } db.sshKeys.push(newSshKey) - return res(json(newSshKey, 201)) + return res(json(newSshKey, { status: 201 })) } ), @@ -150,7 +150,7 @@ export const handlers = [ ...getTimestamps(), } db.orgs.push(newOrg) - return res(json(newOrg, 201)) + return res(json(newOrg, { status: 201 })) } ), @@ -180,7 +180,7 @@ export const handlers = [ org.name = req.body.name org.description = req.body.description || '' - return res(json(org, 200)) + return res(json(org)) } ), @@ -262,7 +262,7 @@ export const handlers = [ ...getTimestamps(), } db.projects.push(newProject) - return res(json(newProject, 201)) + return res(json(newProject, { status: 201 })) } ), @@ -287,7 +287,7 @@ export const handlers = [ project.name = req.body.name project.description = req.body.description || '' - return res(json(project, 200)) + return res(json(project)) } ), @@ -391,7 +391,7 @@ export const handlers = [ time_run_state_updated: new Date().toISOString(), } db.instances.push(newInstance) - return res(json(newInstance, 201, 2000)) + return res(json(newInstance, { status: 201, delay: 2000 })) } ), @@ -401,7 +401,7 @@ export const handlers = [ const [instance, err] = lookupInstance(req.params) if (err) return res(err) instance.run_state = 'running' - return res(json(instance, 202)) + return res(json(instance, { status: 202 })) } ), @@ -411,7 +411,7 @@ export const handlers = [ const [instance, err] = lookupInstance(req.params) if (err) return res(err) instance.run_state = 'stopped' - return res(json(instance, 202)) + return res(json(instance, { status: 202 })) } ), @@ -641,7 +641,7 @@ export const handlers = [ ...getTimestamps(), } db.disks.push(newDisk) - return res(json(newDisk, 201)) + return res(json(newDisk, { status: 201 })) } ), @@ -727,7 +727,7 @@ export const handlers = [ ...getTimestamps(), } db.vpcs.push(newVpc) - return res(json(newVpc, 201)) + return res(json(newVpc, { status: 201 })) } ), @@ -748,7 +748,7 @@ export const handlers = [ if (req.body.dns_name) { vpc.dns_name = req.body.dns_name } - return res(json(vpc, 200)) + return res(json(vpc)) } ), @@ -788,7 +788,7 @@ export const handlers = [ ...getTimestamps(), } db.vpcSubnets.push(newSubnet) - return res(json(newSubnet, 201)) + return res(json(newSubnet, { status: 201 })) } ), @@ -875,7 +875,7 @@ export const handlers = [ ...getTimestamps(), } db.vpcRouters.push(newRouter) - return res(json(newRouter, 201)) + return res(json(newRouter, { status: 201 })) } ), diff --git a/libs/api-mocks/msw/util.ts b/libs/api-mocks/msw/util.ts index 5cef397cb3..ddbf792612 100644 --- a/libs/api-mocks/msw/util.ts +++ b/libs/api-mocks/msw/util.ts @@ -2,13 +2,18 @@ import type { ResponseTransformer } from 'msw' import { compose, context } from 'msw' /** - * Custom transformer: convenience function for less typing. Equivalent to - * `res(ctx.status(status), ctx.json(body))` in a handler. + * Custom transformer: convenience function for setting response `status` and/or + * `delay`. * - * https://mswjs.io/docs/basics/response-transformer#custom-transformer + * @see https://mswjs.io/docs/basics/response-transformer#custom-transformer */ -export const json = (body: B, status = 200, delay = 0): ResponseTransformer => - compose(context.status(status), context.json(body), context.delay(delay)) +export function json( + body: B, + options: { status?: number; delay?: number } = {} +): ResponseTransformer { + const { status = 200, delay = 0 } = options + return compose(context.status(status), context.json(body), context.delay(delay)) +} export interface ResultsPage { items: I[] @@ -46,9 +51,6 @@ export const paginated = ( } } -// handy for testing -export const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)) - // make a bunch of copies of an object with different names and IDs. useful for // testing pagination export const repeat = (obj: T, n: number): T[] => From 6e9c445c1b393763533026327f168d2e92d1ce3d Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 3 Aug 2022 23:22:48 -0500 Subject: [PATCH 6/6] use loaders to prefetch a bunch of queries loaders 4 all! you get a loader! you get a loader! --- app/hooks/use-params.ts | 3 +- app/pages/OrgAccessPage.tsx | 18 +++- app/pages/OrgsPage.tsx | 9 +- app/pages/ProjectsPage.tsx | 15 ++- .../project/access/ProjectAccessPage.tsx | 14 ++- app/pages/project/disks/DisksPage.tsx | 11 ++- app/pages/project/images/ImagesPage.tsx | 14 ++- app/pages/project/instances/InstancesPage.tsx | 14 ++- .../instances/instance/InstancePage.tsx | 11 ++- .../project/networking/VpcPage/VpcPage.tsx | 9 +- app/pages/project/networking/VpcsPage.tsx | 18 ++-- app/pages/project/snapshots/SnapshotsPage.tsx | 14 ++- app/pages/settings/SSHKeysPage.tsx | 5 + app/routes.tsx | 92 ++++++++++++++----- patches/react-router+6.4.0-pre.11.patch | 23 +++++ 15 files changed, 207 insertions(+), 63 deletions(-) create mode 100644 patches/react-router+6.4.0-pre.11.patch diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts index 4562835a16..35bccfe912 100644 --- a/app/hooks/use-params.ts +++ b/app/hooks/use-params.ts @@ -19,9 +19,10 @@ export const requireParams = return requiredParams as { readonly [k in K]: string } } +export const requireOrgParams = requireParams('orgName') +export const requireProjectParams = requireParams('orgName', 'projectName') export const requireInstanceParams = requireParams('orgName', 'projectName', 'instanceName') export const requireVpcParams = requireParams('orgName', 'projectName', 'vpcName') -export const requireProjectParams = requireParams('orgName', 'projectName') export const useVpcParams = () => requireVpcParams(useParams()) diff --git a/app/pages/OrgAccessPage.tsx b/app/pages/OrgAccessPage.tsx index 07cdd2b592..3a07efdb28 100644 --- a/app/pages/OrgAccessPage.tsx +++ b/app/pages/OrgAccessPage.tsx @@ -1,8 +1,10 @@ import { createColumnHelper } from '@tanstack/react-table' import { getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useMemo, useState } from 'react' +import type { LoaderFunctionArgs } from 'react-router-dom' import { + apiQueryClient, orgRoleOrder, setUserRole, useApiMutation, @@ -24,9 +26,7 @@ import { } from '@oxide/ui' import { OrgAccessAddUserSideModal, OrgAccessEditUserSideModal } from 'app/forms/org-access' -import { useRequiredParams } from 'app/hooks' - -type UserRow = UserAccessRow +import { requireOrgParams, useRequiredParams } from 'app/hooks' const EmptyState = ({ onClick }: { onClick: () => void }) => ( @@ -40,9 +40,19 @@ const EmptyState = ({ onClick }: { onClick: () => void }) => ( ) +OrgAccessPage.loader = async ({ params }: LoaderFunctionArgs) => { + await Promise.all([ + apiQueryClient.prefetchQuery('organizationPolicyView', requireOrgParams(params)), + // used in useUserAccessRows to resolve user names + apiQueryClient.prefetchQuery('userList', { limit: 200 }), + ]) +} + +type UserRow = UserAccessRow + const colHelper = createColumnHelper() -export const OrgAccessPage = () => { +export function OrgAccessPage() { const [addModalOpen, setAddModalOpen] = useState(false) const [editingUserRow, setEditingUserRow] = useState(null) const orgParams = useRequiredParams('orgName') diff --git a/app/pages/OrgsPage.tsx b/app/pages/OrgsPage.tsx index b0c143d004..058a36b6da 100644 --- a/app/pages/OrgsPage.tsx +++ b/app/pages/OrgsPage.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { Link, useLocation, useNavigate } from 'react-router-dom' import type { Organization } from '@oxide/api' +import { apiQueryClient } from '@oxide/api' import { useApiQueryClient } from '@oxide/api' import { useApiMutation, useApiQuery } from '@oxide/api' import type { MenuAction } from '@oxide/table' @@ -30,11 +31,15 @@ const EmptyState = () => ( /> ) +OrgsPage.loader = async () => { + await apiQueryClient.prefetchQuery('organizationList', { limit: 10 }) +} + interface OrgsPageProps { modal?: 'createOrg' | 'editOrg' } -const OrgsPage = ({ modal }: OrgsPageProps) => { +export default function OrgsPage({ modal }: OrgsPageProps) { const navigate = useNavigate() const location = useLocation() @@ -107,5 +112,3 @@ const OrgsPage = ({ modal }: OrgsPageProps) => { ) } - -export default OrgsPage diff --git a/app/pages/ProjectsPage.tsx b/app/pages/ProjectsPage.tsx index ace33e74ab..f8a61507ba 100644 --- a/app/pages/ProjectsPage.tsx +++ b/app/pages/ProjectsPage.tsx @@ -1,7 +1,9 @@ import { useMemo } from 'react' +import type { LoaderFunctionArgs } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom' import type { Project } from '@oxide/api' +import { apiQueryClient } from '@oxide/api' import { useApiMutation, useApiQuery, useApiQueryClient } from '@oxide/api' import type { MenuAction } from '@oxide/table' import { DateCell, linkCell, useQueryTable } from '@oxide/table' @@ -17,7 +19,7 @@ import { import CreateProjectSideModalForm from 'app/forms/project-create' import EditProjectSideModalForm from 'app/forms/project-edit' -import { useQuickActions, useRequiredParams } from '../hooks' +import { requireOrgParams, useQuickActions, useRequiredParams } from '../hooks' const EmptyState = () => ( ( /> ) +ProjectsPage.loader = async ({ params }: LoaderFunctionArgs) => { + await apiQueryClient.prefetchQuery('projectList', { + ...requireOrgParams(params), + limit: 10, + }) +} + interface ProjectsPageProps { modal?: 'createProject' | 'editProject' } -const ProjectsPage = ({ modal }: ProjectsPageProps) => { +export default function ProjectsPage({ modal }: ProjectsPageProps) { const navigate = useNavigate() const location = useLocation() @@ -113,5 +122,3 @@ const ProjectsPage = ({ modal }: ProjectsPageProps) => { ) } - -export default ProjectsPage diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 26ee5336de..0ad95afe08 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -1,8 +1,10 @@ import { createColumnHelper } from '@tanstack/react-table' import { getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useMemo, useState } from 'react' +import type { LoaderFunctionArgs } from 'react-router-dom' import { + apiQueryClient, projectRoleOrder, setUserRole, useApiMutation, @@ -27,7 +29,7 @@ import { ProjectAccessAddUserSideModal, ProjectAccessEditUserSideModal, } from 'app/forms/project-access' -import { useRequiredParams } from 'app/hooks' +import { requireProjectParams, useRequiredParams } from 'app/hooks' const EmptyState = ({ onClick }: { onClick: () => void }) => ( @@ -41,11 +43,19 @@ const EmptyState = ({ onClick }: { onClick: () => void }) => ( ) +ProjectAccessPage.loader = async ({ params }: LoaderFunctionArgs) => { + await Promise.all([ + apiQueryClient.prefetchQuery('projectPolicyView', requireProjectParams(params)), + // used in useUserAccessRows to resolve user names + apiQueryClient.prefetchQuery('userList', { limit: 200 }), + ]) +} + type UserRow = UserAccessRow const colHelper = createColumnHelper() -export const ProjectAccessPage = () => { +export function ProjectAccessPage() { const [addModalOpen, setAddModalOpen] = useState(false) const [editingUserRow, setEditingUserRow] = useState(null) const projectParams = useRequiredParams('orgName', 'projectName') diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index a5fd328fa0..749340b065 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -1,6 +1,8 @@ +import type { LoaderFunctionArgs } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom' import type { Disk } from '@oxide/api' +import { apiQueryClient } from '@oxide/api' import { useApiMutation, useApiQueryClient } from '@oxide/api' import { useApiQuery } from '@oxide/api' import type { MenuAction } from '@oxide/table' @@ -18,7 +20,7 @@ import { import { DiskStatusBadge } from 'app/components/StatusBadge' import CreateDiskSideModalForm from 'app/forms/disk-create' -import { useRequiredParams } from 'app/hooks' +import { requireProjectParams, useRequiredParams } from 'app/hooks' function AttachedInstance(props: { orgName: string @@ -50,6 +52,13 @@ interface DisksPageProps { modal?: 'createDisk' } +DisksPage.loader = async ({ params }: LoaderFunctionArgs) => { + await apiQueryClient.prefetchQuery('diskList', { + ...requireProjectParams(params), + limit: 10, + }) +} + export function DisksPage({ modal }: DisksPageProps) { const navigate = useNavigate() diff --git a/app/pages/project/images/ImagesPage.tsx b/app/pages/project/images/ImagesPage.tsx index b8c4aedb52..1a40e54f25 100644 --- a/app/pages/project/images/ImagesPage.tsx +++ b/app/pages/project/images/ImagesPage.tsx @@ -1,7 +1,10 @@ +import type { LoaderFunctionArgs } from 'react-router-dom' + +import { apiQueryClient } from '@oxide/api' import { DateCell, SizeCell, useQueryTable } from '@oxide/table' import { EmptyMessage, Images24Icon, PageHeader, PageTitle } from '@oxide/ui' -import { useRequiredParams } from 'app/hooks' +import { requireProjectParams, useRequiredParams } from 'app/hooks' const EmptyState = () => ( ( /> ) -export const ImagesPage = () => { +ImagesPage.loader = async ({ params }: LoaderFunctionArgs) => { + await apiQueryClient.prefetchQuery('imageList', { + ...requireProjectParams(params), + limit: 10, + }) +} + +export function ImagesPage() { const projectParams = useRequiredParams('orgName', 'projectName') const { Table, Column } = useQueryTable('imageList', projectParams) return ( diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index bd73698dc9..1a758b64b9 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -1,7 +1,8 @@ import { useMemo } from 'react' +import type { LoaderFunctionArgs } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom' -import { useApiQuery, useApiQueryClient } from '@oxide/api' +import { apiQueryClient, useApiQuery, useApiQueryClient } from '@oxide/api' import { DateCell, InstanceResourceCell, @@ -18,7 +19,7 @@ import { buttonStyle, } from '@oxide/ui' -import { useQuickActions, useRequiredParams } from 'app/hooks' +import { requireProjectParams, useQuickActions, useRequiredParams } from 'app/hooks' import { useMakeInstanceActions } from './actions' @@ -32,7 +33,14 @@ const EmptyState = () => ( /> ) -export const InstancesPage = () => { +InstancesPage.loader = async ({ params }: LoaderFunctionArgs) => { + await apiQueryClient.prefetchQuery('instanceList', { + ...requireProjectParams(params), + limit: 10, + }) +} + +export function InstancesPage() { const projectParams = useRequiredParams('orgName', 'projectName') const { orgName, projectName } = projectParams diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx index c231a93fe6..df8f483fa7 100644 --- a/app/pages/project/instances/instance/InstancePage.tsx +++ b/app/pages/project/instances/instance/InstancePage.tsx @@ -1,15 +1,16 @@ import filesize from 'filesize' import { memo, useMemo } from 'react' +import type { LoaderFunctionArgs } from 'react-router-dom' import { useNavigate } from 'react-router-dom' -import { useApiQuery, useApiQueryClient } from '@oxide/api' +import { apiQueryClient, useApiQuery, useApiQueryClient } from '@oxide/api' import { Instances24Icon, PageHeader, PageTitle, PropertiesTable, Tab } from '@oxide/ui' import { pick } from '@oxide/util' import { MoreActionsMenu } from 'app/components/MoreActionsMenu' import { InstanceStatusBadge } from 'app/components/StatusBadge' import { Tabs } from 'app/components/Tabs' -import { useQuickActions, useRequiredParams } from 'app/hooks' +import { requireInstanceParams, useQuickActions, useRequiredParams } from 'app/hooks' import { useMakeInstanceActions } from '../actions' import { MetricsTab } from './tabs/MetricsTab' @@ -38,7 +39,11 @@ const InstanceTabs = memo(() => ( )) -export const InstancePage = () => { +InstancePage.loader = async ({ params }: LoaderFunctionArgs) => { + await apiQueryClient.prefetchQuery('instanceView', requireInstanceParams(params)) +} + +export function InstancePage() { const instanceParams = useRequiredParams('orgName', 'projectName', 'instanceName') const navigate = useNavigate() diff --git a/app/pages/project/networking/VpcPage/VpcPage.tsx b/app/pages/project/networking/VpcPage/VpcPage.tsx index 1f82959dac..11b01a01ea 100644 --- a/app/pages/project/networking/VpcPage/VpcPage.tsx +++ b/app/pages/project/networking/VpcPage/VpcPage.tsx @@ -14,10 +14,11 @@ import { VpcSystemRoutesTab } from './tabs/VpcSystemRoutesTab' const formatDateTime = (d: Date) => format(d, 'MMM d, yyyy H:mm aa') -const loader = async ({ params }: LoaderFunctionArgs) => - apiQueryClient.prefetchQuery('vpcView', requireVpcParams(params)) +VpcPage.loader = async ({ params }: LoaderFunctionArgs) => { + await apiQueryClient.prefetchQuery('vpcView', requireVpcParams(params)) +} -export const VpcPage = () => { +export function VpcPage() { const vpcParams = useVpcParams() const { data: vpc } = useApiQuery('vpcView', vpcParams) @@ -62,5 +63,3 @@ export const VpcPage = () => { ) } - -VpcPage.loader = loader diff --git a/app/pages/project/networking/VpcsPage.tsx b/app/pages/project/networking/VpcsPage.tsx index f96db6f671..5b91cdf60c 100644 --- a/app/pages/project/networking/VpcsPage.tsx +++ b/app/pages/project/networking/VpcsPage.tsx @@ -29,16 +29,20 @@ const EmptyState = () => ( /> ) +// just as in the vpcList call for the quick actions menu, include limit: 10 to make +// sure it matches the call in the QueryTable +VpcsPage.loader = async ({ params }: LoaderFunctionArgs) => { + await apiQueryClient.prefetchQuery('vpcList', { + ...requireProjectParams(params), + limit: 10, + }) +} + interface VpcsPageProps { modal?: 'createVpc' | 'editVpc' } -// just as in the vpcList call for the quick actions menu, include limit: 10 to make -// sure it matches the call in the QueryTable -const loader = async ({ params }: LoaderFunctionArgs) => - apiQueryClient.prefetchQuery('vpcList', { ...requireProjectParams(params), limit: 10 }) - -export const VpcsPage = ({ modal }: VpcsPageProps) => { +export function VpcsPage({ modal }: VpcsPageProps) { const queryClient = useApiQueryClient() const { orgName, projectName } = useRequiredParams('orgName', 'projectName') const location = useLocation() @@ -115,5 +119,3 @@ export const VpcsPage = ({ modal }: VpcsPageProps) => { ) } - -VpcsPage.loader = loader diff --git a/app/pages/project/snapshots/SnapshotsPage.tsx b/app/pages/project/snapshots/SnapshotsPage.tsx index 4ad3082a7a..9277d6ddcb 100644 --- a/app/pages/project/snapshots/SnapshotsPage.tsx +++ b/app/pages/project/snapshots/SnapshotsPage.tsx @@ -1,7 +1,10 @@ +import type { LoaderFunctionArgs } from 'react-router-dom' + +import { apiQueryClient } from '@oxide/api' import { DateCell, SizeCell, useQueryTable } from '@oxide/table' import { EmptyMessage, PageHeader, PageTitle, Snapshots24Icon } from '@oxide/ui' -import { useRequiredParams } from 'app/hooks' +import { requireProjectParams, useRequiredParams } from 'app/hooks' const EmptyState = () => ( ( /> ) -export const SnapshotsPage = () => { +SnapshotsPage.loader = async ({ params }: LoaderFunctionArgs) => { + await apiQueryClient.prefetchQuery('snapshotList', { + ...requireProjectParams(params), + limit: 10, + }) +} + +export function SnapshotsPage() { const projectParams = useRequiredParams('orgName', 'projectName') const { Table, Column } = useQueryTable('snapshotList', projectParams) return ( diff --git a/app/pages/settings/SSHKeysPage.tsx b/app/pages/settings/SSHKeysPage.tsx index 52745acc50..a5a865ccce 100644 --- a/app/pages/settings/SSHKeysPage.tsx +++ b/app/pages/settings/SSHKeysPage.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import type { SshKey } from '@oxide/api' +import { apiQueryClient } from '@oxide/api' import { useApiQueryClient } from '@oxide/api' import { useApiMutation } from '@oxide/api' import type { MenuAction } from '@oxide/table' @@ -16,6 +17,10 @@ import { import { CreateSSHKeySideModalForm } from 'app/forms/ssh-key-create' +SSHKeysPage.loader = async () => { + await apiQueryClient.prefetchQuery('sessionSshkeyList', { limit: 10 }) +} + export function SSHKeysPage() { const { Table, Column } = useQueryTable('sessionSshkeyList', {}) const [createModalOpen, setCreateModalOpen] = useState(false) diff --git a/app/routes.tsx b/app/routes.tsx index 88b6d63b36..a5353ee4aa 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -46,19 +46,45 @@ export const Router = () => ( } /> + {/* TODO: prefetch sessionMe in a loader on a route wrapping all relevant pages, handle possible 401 */} }> } /> } /> + }> + } /> + } handle={{ crumb: 'Profile' }} /> + } + handle={{ crumb: 'Appearance' }} + /> + } + loader={SSHKeysPage.loader} + handle={{ crumb: 'SSH Keys' }} + /> + } handle={{ crumb: 'Hotkeys' }} /> + + } /> }> }> - } /> - } /> + } loader={OrgsPage.loader} /> + } + loader={OrgsPage.loader} + /> - } /> + } + loader={OrgsPage.loader} + /> @@ -68,16 +94,25 @@ export const Router = () => ( } + loader={OrgAccessPage.loader} handle={{ crumb: 'Access & IAM' }} /> {/* ORG */} }> - } /> - } /> + } loader={ProjectsPage.loader} /> + } + loader={ProjectsPage.loader} + /> - } /> + } + loader={ProjectsPage.loader} + /> @@ -89,10 +124,10 @@ export const Router = () => ( > } /> - } /> + } loader={InstancesPage.loader} /> } /> - } /> + } loader={InstancePage.loader} /> } @@ -102,9 +137,17 @@ export const Router = () => ( } loader={VpcsPage.loader} /> - } /> + } + loader={VpcsPage.loader} + /> - } /> + } + loader={VpcsPage.loader} + /> ( /> - } /> - } /> + } loader={DisksPage.loader} /> + } + loader={DisksPage.loader} + /> } + loader={SnapshotsPage.loader} handle={{ crumb: 'Snapshots' }} /> - } handle={{ crumb: 'Images' }} /> + } + loader={ImagesPage.loader} + handle={{ crumb: 'Images' }} + /> } + loader={ProjectAccessPage.loader} handle={{ crumb: 'Access & IAM' }} /> - - }> - } /> - } handle={{ crumb: 'Profile' }} /> - } - handle={{ crumb: 'Appearance' }} - /> - } handle={{ crumb: 'SSH Keys' }} /> - } handle={{ crumb: 'Hotkeys' }} /> - ) diff --git a/patches/react-router+6.4.0-pre.11.patch b/patches/react-router+6.4.0-pre.11.patch new file mode 100644 index 0000000000..cb78c3f5d2 --- /dev/null +++ b/patches/react-router+6.4.0-pre.11.patch @@ -0,0 +1,23 @@ +diff --git a/node_modules/react-router/dist/index.js b/node_modules/react-router/dist/index.js +index 03d63dd..7970ae1 100644 +--- a/node_modules/react-router/dist/index.js ++++ b/node_modules/react-router/dist/index.js +@@ -1000,12 +1000,17 @@ function Navigate(_ref4) { + " may be used only in the context of a component.") : invariant(false) : void 0; + process.env.NODE_ENV !== "production" ? warning(!React.useContext(NavigationContext).static, " must not be used on the initial render in a . " + "This is a no-op, but you should modify your code so the is " + "only ever rendered in response to some user interaction or state change.") : void 0; + let navigate = useNavigate(); ++ // To be removed as soon as this fix is released: ++ // https://github.com/remix-run/react-router/pull/9124 ++ let isMountedRef = React.useRef(false); + React.useEffect(() => { ++ if (isMountedRef.current) return; ++ isMountedRef.current = true; + navigate(to, { + replace, + state + }); +- }); ++ }, []); + return null; + } +