diff --git a/.oxlintrc.json b/.oxlintrc.json index 34371ea5d..b1d8dc5e9 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -36,7 +36,7 @@ "react/button-has-type": "error", "react/jsx-boolean-value": "error", - "react-hooks/exhaustive-deps": "error", + "react-hooks/exhaustive-deps": ["error", { "additionalHooks": "useQuickActions" }], "react-hooks/rules-of-hooks": "error", "import/no-default-export": "error", "consistent-type-imports": "error", diff --git a/app/hooks/use-quick-actions.tsx b/app/hooks/use-quick-actions.tsx index 5848eab71..01c1793b0 100644 --- a/app/hooks/use-quick-actions.tsx +++ b/app/hooks/use-quick-actions.tsx @@ -101,15 +101,22 @@ function useParentActions(): QuickActionItem[] { } /** - * Register action items with the global quick actions menu. `items` must - * be memoized by the caller, otherwise the effect will run too often. + * Register action items with the global quick actions menu. Takes a factory + * and deps array like useMemo — the linter checks deps via additionalHooks. * * Each component instance gets its own source slot (via useId), so items are * cleaned up automatically when the component unmounts without affecting * other sources' registrations. */ -export function useQuickActions(items: QuickActionItem[]) { +export function useQuickActions( + factory: () => QuickActionItem[], + deps: React.DependencyList +) { const sourceId = useId() + // Deps are checked by the linter at call sites via the additionalHooks + // option in .oxlintrc.json, so we can safely forward them here. + // eslint-disable-next-line react-hooks/exhaustive-deps + const items = useMemo(factory, deps) useEffect(() => { setSourceItems(sourceId, items) diff --git a/app/layouts/ProjectLayoutBase.tsx b/app/layouts/ProjectLayoutBase.tsx index c021b08ae..407876df1 100644 --- a/app/layouts/ProjectLayoutBase.tsx +++ b/app/layouts/ProjectLayoutBase.tsx @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import { useMemo, type ReactElement } from 'react' +import type { ReactElement } from 'react' import { useLocation, type LoaderFunctionArgs } from 'react-router' import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api' @@ -58,28 +58,27 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { const { data: project } = usePrefetchedQuery(projectView(projectSelector)) const { pathname } = useLocation() + useQuickActions( - useMemo( - () => - [ - { value: 'Instances', path: pb.instances(projectSelector) }, - { value: 'Disks', path: pb.disks(projectSelector) }, - { value: 'Snapshots', path: pb.snapshots(projectSelector) }, - { value: 'Images', path: pb.projectImages(projectSelector) }, - { value: 'VPCs', path: pb.vpcs(projectSelector) }, - { value: 'Floating IPs', path: pb.floatingIps(projectSelector) }, - { value: 'Affinity Groups', path: pb.affinity(projectSelector) }, - { value: 'Project Access', path: pb.projectAccess(projectSelector) }, - ] - // filter out the entry for the path we're currently on - .filter((i) => i.path !== pathname) - .map((i) => ({ - navGroup: `Project '${project.name}'`, - value: i.value, - action: i.path, - })), - [pathname, project.name, projectSelector] - ) + () => + [ + { value: 'Instances', path: pb.instances(projectSelector) }, + { value: 'Disks', path: pb.disks(projectSelector) }, + { value: 'Snapshots', path: pb.snapshots(projectSelector) }, + { value: 'Images', path: pb.projectImages(projectSelector) }, + { value: 'VPCs', path: pb.vpcs(projectSelector) }, + { value: 'Floating IPs', path: pb.floatingIps(projectSelector) }, + { value: 'Affinity Groups', path: pb.affinity(projectSelector) }, + { value: 'Project Access', path: pb.projectAccess(projectSelector) }, + ] + // filter out the entry for the path we're currently on + .filter((i) => i.path !== pathname) + .map((i) => ({ + navGroup: `Project '${project.name}'`, + value: i.value, + action: i.path, + })), + [pathname, project.name, projectSelector] ) return ( diff --git a/app/layouts/SettingsLayout.tsx b/app/layouts/SettingsLayout.tsx index 649e2d159..fccec2279 100644 --- a/app/layouts/SettingsLayout.tsx +++ b/app/layouts/SettingsLayout.tsx @@ -5,7 +5,6 @@ * * Copyright Oxide Computer Company */ -import { useMemo } from 'react' import { useLocation } from 'react-router' import { @@ -30,22 +29,20 @@ export default function SettingsLayout() { const { pathname } = useLocation() useQuickActions( - useMemo( - () => - [ - { value: 'Profile', path: pb.profile() }, - { value: 'SSH Keys', path: pb.sshKeys() }, - { value: 'Access Tokens', path: pb.accessTokens() }, - ] - // filter out the entry for the path we're currently on - .filter((i) => i.path !== pathname) - .map((i) => ({ - navGroup: `Settings`, - value: i.value, - action: i.path, - })), - [pathname] - ) + () => + [ + { value: 'Profile', path: pb.profile() }, + { value: 'SSH Keys', path: pb.sshKeys() }, + { value: 'Access Tokens', path: pb.accessTokens() }, + ] + // filter out the entry for the path we're currently on + .filter((i) => i.path !== pathname) + .map((i) => ({ + navGroup: `Settings`, + value: i.value, + action: i.path, + })), + [pathname] ) return ( diff --git a/app/layouts/SiloLayout.tsx b/app/layouts/SiloLayout.tsx index d07076511..13d1e49dc 100644 --- a/app/layouts/SiloLayout.tsx +++ b/app/layouts/SiloLayout.tsx @@ -5,7 +5,6 @@ * * Copyright Oxide Computer Company */ -import { useMemo } from 'react' import { useLocation } from 'react-router' import { @@ -29,23 +28,21 @@ export default function SiloLayout() { const { me } = useCurrentUser() useQuickActions( - useMemo( - () => - [ - { value: 'Projects', path: pb.projects() }, - { value: 'Images', path: pb.siloImages() }, - { value: 'Utilization', path: pb.siloUtilization() }, - { value: 'Silo Access', path: pb.siloAccess() }, - ] - // filter out the entry for the path we're currently on - .filter((i) => i.path !== pathname) - .map((i) => ({ - navGroup: `Silo '${me.siloName}'`, - value: i.value, - action: i.path, - })), - [pathname, me.siloName] - ) + () => + [ + { value: 'Projects', path: pb.projects() }, + { value: 'Images', path: pb.siloImages() }, + { value: 'Utilization', path: pb.siloUtilization() }, + { value: 'Silo Access', path: pb.siloAccess() }, + ] + // filter out the entry for the path we're currently on + .filter((i) => i.path !== pathname) + .map((i) => ({ + navGroup: `Silo '${me.siloName}'`, + value: i.value, + action: i.path, + })), + [pathname, me.siloName] ) return ( diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index 05c0e7434..2c66b7ac1 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -5,7 +5,6 @@ * * Copyright Oxide Computer Company */ -import { useMemo } from 'react' import { useLocation } from 'react-router' import { api, q, queryClient } from '@oxide/api' @@ -48,7 +47,7 @@ export default function SystemLayout() { const { me } = useCurrentUser() - const actions = useMemo(() => { + useQuickActions(() => { const systemLinks = [ { value: 'Silos', path: pb.silos() }, { value: 'Utilization', path: pb.systemUtilization() }, @@ -73,8 +72,6 @@ export default function SystemLayout() { return [...systemLinks, backToSilo] }, [pathname, me.siloName]) - useQuickActions(actions) - return ( diff --git a/app/pages/ProjectsPage.tsx b/app/pages/ProjectsPage.tsx index 9fdd256f4..ca99041c5 100644 --- a/app/pages/ProjectsPage.tsx +++ b/app/pages/ProjectsPage.tsx @@ -7,7 +7,7 @@ */ import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' -import { useCallback, useMemo } from 'react' +import { useCallback } from 'react' import { Outlet, useNavigate } from 'react-router' import { api, getListQFn, q, queryClient, useApiMutation, type Project } from '@oxide/api' @@ -103,22 +103,21 @@ export default function ProjectsPage() { }) const { data: allProjects } = useQuery(q(api.projectList, { query: { limit: ALL_ISH } })) + useQuickActions( - useMemo( - () => [ - { - value: 'New project', - navGroup: 'Actions', - action: pb.projectsNew(), - }, - ...(allProjects?.items || []).map((p) => ({ - value: p.name, - action: pb.project({ project: p.name }), - navGroup: 'Go to project', - })), - ], - [allProjects] - ) + () => [ + { + value: 'New project', + navGroup: 'Actions', + action: pb.projectsNew(), + }, + ...(allProjects?.items || []).map((p) => ({ + value: p.name, + action: pb.project({ project: p.name }), + navGroup: 'Go to project', + })), + ], + [allProjects] ) return ( diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index c93b19a02..c1531bf1f 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -166,16 +166,14 @@ export default function SiloAccessPage() { }) useQuickActions( - useMemo( - () => [ - { - value: 'Add user or group', - navGroup: 'Actions', - action: () => setAddModalOpen(true), - }, - ], - [] - ) + () => [ + { + value: 'Add user or group', + navGroup: 'Actions', + action: () => setAddModalOpen(true), + }, + ], + [] ) return ( diff --git a/app/pages/SiloImagesPage.tsx b/app/pages/SiloImagesPage.tsx index e0209d130..16ddd126d 100644 --- a/app/pages/SiloImagesPage.tsx +++ b/app/pages/SiloImagesPage.tsx @@ -97,22 +97,21 @@ export default function SiloImagesPage() { const { table } = useQueryTable({ query: imageList, columns, emptyState: }) const { data: allImages } = useQuery(q(api.imageList, { query: { limit: ALL_ISH } })) + useQuickActions( - useMemo( - () => [ - { - value: 'Promote image', - navGroup: 'Actions', - action: () => setShowModal(true), - }, - ...(allImages?.items || []).map((i) => ({ - value: i.name, - action: pb.siloImageEdit({ image: i.name }), - navGroup: 'Go to silo image', - })), - ], - [allImages] - ) + () => [ + { + value: 'Promote image', + navGroup: 'Actions', + action: () => setShowModal(true), + }, + ...(allImages?.items || []).map((i) => ({ + value: i.name, + action: pb.siloImageEdit({ image: i.name }), + navGroup: 'Go to silo image', + })), + ], + [allImages] ) return ( diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index f99c926ff..07ee850d5 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -207,16 +207,14 @@ export default function ProjectAccessPage() { }) useQuickActions( - useMemo( - () => [ - { - value: 'Add user or group', - navGroup: 'Actions', - action: () => setAddModalOpen(true), - }, - ], - [] - ) + () => [ + { + value: 'Add user or group', + navGroup: 'Actions', + action: () => setAddModalOpen(true), + }, + ], + [] ) return ( diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index d15568910..7122e494c 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -7,7 +7,7 @@ */ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { useCallback, useMemo } from 'react' +import { useCallback } from 'react' import { Outlet, type LoaderFunctionArgs } from 'react-router' import { @@ -140,21 +140,19 @@ export default function AffinityPage() { }) useQuickActions( - useMemo( - () => [ - { - value: 'New anti-affinity group', - navGroup: 'Actions', - action: pb.affinityNew({ project }), - }, - ...antiAffinityGroups.map((g) => ({ - value: g.name, - action: pb.antiAffinityGroup({ project, antiAffinityGroup: g.name }), - navGroup: 'Go to anti-affinity group', - })), - ], - [project, antiAffinityGroups] - ) + () => [ + { + value: 'New anti-affinity group', + navGroup: 'Actions', + action: pb.affinityNew({ project }), + }, + ...antiAffinityGroups.map((g) => ({ + value: g.name, + action: pb.antiAffinityGroup({ project, antiAffinityGroup: g.name }), + navGroup: 'Go to anti-affinity group', + })), + ], + [project, antiAffinityGroups] ) return ( diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index b9acc3627..96b6ae9b2 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -192,22 +192,21 @@ export default function DisksPage() { const { data: allDisks } = useQuery( q(api.diskList, { query: { project, limit: ALL_ISH } }) ) + useQuickActions( - useMemo( - () => [ - { - value: 'New disk', - navGroup: 'Actions', - action: pb.disksNew({ project }), - }, - ...(allDisks?.items || []).map((d) => ({ - value: d.name, - action: pb.disk({ project, disk: d.name }), - navGroup: 'Go to disk', - })), - ], - [project, allDisks] - ) + () => [ + { + value: 'New disk', + navGroup: 'Actions', + action: pb.disksNew({ project }), + }, + ...(allDisks?.items || []).map((d) => ({ + value: d.name, + action: pb.disk({ project, disk: d.name }), + navGroup: 'Go to disk', + })), + ], + [project, allDisks] ) return ( diff --git a/app/pages/project/floating-ips/FloatingIpsPage.tsx b/app/pages/project/floating-ips/FloatingIpsPage.tsx index 6ffea50fe..2154ef875 100644 --- a/app/pages/project/floating-ips/FloatingIpsPage.tsx +++ b/app/pages/project/floating-ips/FloatingIpsPage.tsx @@ -7,7 +7,7 @@ */ import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useState } from 'react' import { useForm } from 'react-hook-form' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' @@ -216,22 +216,21 @@ export default function FloatingIpsPage() { const { data: allFips } = useQuery( q(api.floatingIpList, { query: { project, limit: ALL_ISH } }) ) + useQuickActions( - useMemo( - () => [ - { - value: 'New floating IP', - navGroup: 'Actions', - action: pb.floatingIpsNew({ project }), - }, - ...(allFips?.items || []).map((f) => ({ - value: f.name, - action: pb.floatingIpEdit({ project, floatingIp: f.name }), - navGroup: 'Go to floating IP', - })), - ], - [project, allFips] - ) + () => [ + { + value: 'New floating IP', + navGroup: 'Actions', + action: pb.floatingIpsNew({ project }), + }, + ...(allFips?.items || []).map((f) => ({ + value: f.name, + action: pb.floatingIpEdit({ project, floatingIp: f.name }), + navGroup: 'Go to floating IP', + })), + ], + [project, allFips] ) return ( diff --git a/app/pages/project/images/ImagesPage.tsx b/app/pages/project/images/ImagesPage.tsx index b464a48c2..047287a3d 100644 --- a/app/pages/project/images/ImagesPage.tsx +++ b/app/pages/project/images/ImagesPage.tsx @@ -112,22 +112,21 @@ export default function ImagesPage() { const { data: allImages } = useQuery( q(api.imageList, { query: { project, limit: ALL_ISH } }) ) + useQuickActions( - useMemo( - () => [ - { - value: 'Upload image', - navGroup: 'Actions', - action: pb.projectImagesNew({ project }), - }, - ...(allImages?.items || []).map((i) => ({ - value: i.name, - action: pb.projectImageEdit({ project, image: i.name }), - navGroup: 'Go to project image', - })), - ], - [project, allImages] - ) + () => [ + { + value: 'Upload image', + navGroup: 'Actions', + action: pb.projectImagesNew({ project }), + }, + ...(allImages?.items || []).map((i) => ({ + value: i.name, + action: pb.projectImageEdit({ project, image: i.name }), + navGroup: 'Go to project image', + })), + ], + [project, allImages] ) return ( diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index 8e9080eb5..7c44a8ec1 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -198,22 +198,21 @@ export default function InstancesPage() { const { data: allInstances } = useQuery( q(api.instanceList, { query: { project, limit: ALL_ISH } }) ) + useQuickActions( - useMemo( - () => [ - { - value: 'New instance', - navGroup: 'Actions', - action: pb.instancesNew({ project }), - }, - ...(allInstances?.items || []).map((i) => ({ - value: i.name, - action: pb.instance({ project, instance: i.name }), - navGroup: 'Go to instance', - })), - ], - [project, allInstances] - ) + () => [ + { + value: 'New instance', + navGroup: 'Actions', + action: pb.instancesNew({ project }), + }, + ...(allInstances?.items || []).map((i) => ({ + value: i.name, + action: pb.instance({ project, instance: i.name }), + navGroup: 'Go to instance', + })), + ], + [project, allInstances] ) return ( diff --git a/app/pages/project/snapshots/SnapshotsPage.tsx b/app/pages/project/snapshots/SnapshotsPage.tsx index ceeefe16d..16831b048 100644 --- a/app/pages/project/snapshots/SnapshotsPage.tsx +++ b/app/pages/project/snapshots/SnapshotsPage.tsx @@ -7,7 +7,7 @@ */ import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useState } from 'react' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' import { @@ -167,16 +167,14 @@ export default function SnapshotsPage() { }) useQuickActions( - useMemo( - () => [ - { - value: 'New snapshot', - navGroup: 'Actions', - action: pb.snapshotsNew({ project }), - }, - ], - [project] - ) + () => [ + { + value: 'New snapshot', + navGroup: 'Actions', + action: pb.snapshotsNew({ project }), + }, + ], + [project] ) return ( diff --git a/app/pages/project/vpcs/VpcsPage.tsx b/app/pages/project/vpcs/VpcsPage.tsx index 90ab42fd9..3091816ac 100644 --- a/app/pages/project/vpcs/VpcsPage.tsx +++ b/app/pages/project/vpcs/VpcsPage.tsx @@ -136,22 +136,21 @@ export default function VpcsPage() { }) const { data: allVpcs } = useQuery(q(api.vpcList, { query: { project, limit: ALL_ISH } })) + useQuickActions( - useMemo( - () => [ - { - value: 'New VPC', - navGroup: 'Actions', - action: pb.vpcsNew({ project }), - }, - ...(allVpcs?.items || []).map((v) => ({ - value: v.name, - action: pb.vpc({ project, vpc: v.name }), - navGroup: 'Go to VPC', - })), - ], - [project, allVpcs] - ) + () => [ + { + value: 'New VPC', + navGroup: 'Actions', + action: pb.vpcsNew({ project }), + }, + ...(allVpcs?.items || []).map((v) => ({ + value: v.name, + action: pb.vpc({ project, vpc: v.name }), + navGroup: 'Go to VPC', + })), + ], + [project, allVpcs] ) return ( diff --git a/app/pages/system/networking/IpPoolsPage.tsx b/app/pages/system/networking/IpPoolsPage.tsx index ec4c5432e..70c3be516 100644 --- a/app/pages/system/networking/IpPoolsPage.tsx +++ b/app/pages/system/networking/IpPoolsPage.tsx @@ -8,7 +8,7 @@ import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' -import { useCallback, useMemo } from 'react' +import { useCallback } from 'react' import { Outlet, useNavigate } from 'react-router' import { api, getListQFn, q, queryClient, useApiMutation, type IpPool } from '@oxide/api' @@ -132,22 +132,21 @@ export default function IpPoolsPage() { const { data: allPools } = useQuery( q(api.systemIpPoolList, { query: { limit: ALL_ISH } }) ) + useQuickActions( - useMemo( - () => [ - { - value: 'New IP pool', - navGroup: 'Actions', - action: pb.ipPoolsNew(), - }, - ...(allPools?.items || []).map((p) => ({ - value: p.name, - action: pb.ipPool({ pool: p.name }), - navGroup: 'Go to IP pool', - })), - ], - [allPools] - ) + () => [ + { + value: 'New IP pool', + navGroup: 'Actions', + action: pb.ipPoolsNew(), + }, + ...(allPools?.items || []).map((p) => ({ + value: p.name, + action: pb.ipPool({ pool: p.name }), + navGroup: 'Go to IP pool', + })), + ], + [allPools] ) return ( diff --git a/app/pages/system/silos/SilosPage.tsx b/app/pages/system/silos/SilosPage.tsx index a560e5d81..baed459da 100644 --- a/app/pages/system/silos/SilosPage.tsx +++ b/app/pages/system/silos/SilosPage.tsx @@ -7,7 +7,7 @@ */ import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' -import { useCallback, useMemo } from 'react' +import { useCallback } from 'react' import { Outlet } from 'react-router' import { api, getListQFn, q, queryClient, useApiMutation, type Silo } from '@oxide/api' @@ -98,18 +98,17 @@ export default function SilosPage() { }) const { data: allSilos } = useQuery(q(api.siloList, { query: { limit: ALL_ISH } })) + useQuickActions( - useMemo( - () => [ - { value: 'New silo', navGroup: 'Actions', action: pb.silosNew() }, - ...(allSilos?.items || []).map((o) => ({ - value: o.name, - action: pb.silo({ silo: o.name }), - navGroup: 'Go to silo', - })), - ], - [allSilos] - ) + () => [ + { value: 'New silo', navGroup: 'Actions', action: pb.silosNew() }, + ...(allSilos?.items || []).map((o) => ({ + value: o.name, + action: pb.silo({ silo: o.name }), + navGroup: 'Go to silo', + })), + ], + [allSilos] ) return (