From cfec15596c17357c07aa54b9dba42e8f6b3fe1d3 Mon Sep 17 00:00:00 2001 From: Vedant-Mhatre Date: Fri, 13 Mar 2026 19:21:50 -0700 Subject: [PATCH 1/3] fix(ui): avoid reloading pod quickstarts on rerender Signed-off-by: Vedant-Mhatre --- .../components/utils.quickstart.test.tsx | 127 ++++++++++++++++++ ui/src/app/applications/components/utils.tsx | 62 +++++++-- 2 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 ui/src/app/applications/components/utils.quickstart.test.tsx diff --git a/ui/src/app/applications/components/utils.quickstart.test.tsx b/ui/src/app/applications/components/utils.quickstart.test.tsx new file mode 100644 index 0000000000000..8d839c9649acf --- /dev/null +++ b/ui/src/app/applications/components/utils.quickstart.test.tsx @@ -0,0 +1,127 @@ +import * as React from 'react'; +import * as renderer from 'react-test-renderer'; +import {BehaviorSubject, Subscription} from 'rxjs'; + +import {ContextApis} from '../../shared/context'; +import {AbstractApplication, Application, ApplicationTree, ResourceNode} from '../../shared/models'; +import {services} from '../../shared/services'; +import {renderResourceButtons} from './utils'; + +describe('renderResourceButtons', () => { + const resource = { + uid: 'apps/Deployment/default/guestbook', + group: 'apps', + kind: 'Deployment', + namespace: 'default', + name: 'guestbook' + } as ResourceNode; + + const otherResource = { + ...resource, + uid: 'apps/Deployment/default/guestbook-canary', + name: 'guestbook-canary' + } as ResourceNode; + + const application = { + kind: 'Application', + metadata: {name: 'guestbook', namespace: 'argocd'}, + spec: {project: 'default'}, + status: { + resources: [ + { + group: resource.group, + kind: resource.kind, + namespace: resource.namespace, + name: resource.name + } + ] + } + } as Application; + + const tree = { + nodes: [resource] + } as ApplicationTree; + + const apis = { + popup: {prompt: jest.fn()}, + notifications: {show: jest.fn()}, + navigation: {goto: jest.fn()}, + baseHref: '' + } as unknown as ContextApis; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function QuickStartWrapper(props: {resourceNode: ResourceNode; version: number; appChanged: BehaviorSubject}) { + return
{renderResourceButtons(props.resourceNode, application, tree, apis, props.appChanged)}
; + } + + it('does not reload quickstart actions on unrelated parent rerenders', () => { + const canISpy = jest.spyOn(services.accounts, 'canI').mockResolvedValue(false); + const appChanged = new BehaviorSubject(application); + const subscription: Subscription = appChanged.subscribe(() => undefined); + let rendered: renderer.ReactTestRenderer | undefined; + + try { + renderer.act(() => { + rendered = renderer.create(); + }); + + expect(canISpy).toHaveBeenCalledTimes(1); + + renderer.act(() => { + rendered!.update(); + }); + + expect(canISpy).toHaveBeenCalledTimes(1); + } finally { + subscription.unsubscribe(); + } + }); + + it('reloads quickstart actions when the resource changes', () => { + const canISpy = jest.spyOn(services.accounts, 'canI').mockResolvedValue(false); + const appChanged = new BehaviorSubject(application); + const subscription: Subscription = appChanged.subscribe(() => undefined); + let rendered: renderer.ReactTestRenderer | undefined; + + try { + renderer.act(() => { + rendered = renderer.create(); + }); + + renderer.act(() => { + rendered!.update(); + }); + + expect(canISpy).toHaveBeenCalledTimes(2); + } finally { + subscription.unsubscribe(); + } + }); + + it('reloads quickstart actions when the appChanged subject identity changes', () => { + const canISpy = jest.spyOn(services.accounts, 'canI').mockResolvedValue(false); + const firstAppChanged = new BehaviorSubject(application); + const secondAppChanged = new BehaviorSubject(application); + const firstSubscription: Subscription = firstAppChanged.subscribe(() => undefined); + const secondSubscription: Subscription = secondAppChanged.subscribe(() => undefined); + let rendered: renderer.ReactTestRenderer | undefined; + + try { + renderer.act(() => { + rendered = renderer.create(); + }); + + renderer.act(() => { + rendered!.update(); + }); + + expect(canISpy).toHaveBeenCalledTimes(2); + } finally { + firstSubscription.unsubscribe(); + secondSubscription.unsubscribe(); + } + }); +}); diff --git a/ui/src/app/applications/components/utils.tsx b/ui/src/app/applications/components/utils.tsx index 1ea0597737f12..4ce3dbdc4912b 100644 --- a/ui/src/app/applications/components/utils.tsx +++ b/ui/src/app/applications/components/utils.tsx @@ -891,16 +891,49 @@ export function renderResourceActionMenu(menuItems: ActionMenuItem[]): React.Rea ); } -export function renderResourceButtons( - resource: ResourceTreeNode, - application: appModels.AbstractApplication, - tree: appModels.AbstractApplicationTree, - apis: ContextApis, - appChanged: BehaviorSubject -): React.ReactNode { - const menuItems: Observable = getActionItems(resource, application, tree, apis, appChanged, true); +interface ResourceButtonsProps { + resource: ResourceTreeNode; + application: appModels.AbstractApplication; + tree: appModels.AbstractApplicationTree; + apis: ContextApis; + appChanged: BehaviorSubject; +} + +interface ResourceButtonsInput { + resource: ResourceTreeNode; + application: appModels.AbstractApplication; + tree: appModels.AbstractApplicationTree; + apisId: number; + appChangedId: number; +} + +const objectIds = new WeakMap(); +let nextObjectId = 1; + +function getObjectId(value: object) { + let id = objectIds.get(value); + if (id === undefined) { + id = nextObjectId++; + objectIds.set(value, id); + } + return id; +} + +// Keep quickstart action loading stable across unrelated PodView rerenders. +const ResourceButtons = React.memo(({resource, application, tree, apis, appChanged}: ResourceButtonsProps) => { + const input = React.useMemo( + () => ({ + resource, + application, + tree, + apisId: getObjectId(apis), + appChangedId: getObjectId(appChanged) + }), + [resource, application, tree, apis, appChanged] + ); + return ( - menuItems}> + getActionItems(value.resource, value.application, value.tree, apis, appChanged, true)}> {items => (
{items.map((item, i) => ( @@ -922,6 +955,17 @@ export function renderResourceButtons( )} ); +}); +ResourceButtons.displayName = 'ResourceButtons'; + +export function renderResourceButtons( + resource: ResourceTreeNode, + application: appModels.AbstractApplication, + tree: appModels.AbstractApplicationTree, + apis: ContextApis, + appChanged: BehaviorSubject +): React.ReactNode { + return ; } export function syncStatusMessage(app: appModels.Application) { From 28fed981c52081d1b2abc3fbedc1800fb74c34ed Mon Sep 17 00:00:00 2001 From: Vedant-Mhatre Date: Fri, 13 Mar 2026 23:28:00 -0700 Subject: [PATCH 2/3] refine(ui): narrow PodView quickstart reload inputs Signed-off-by: Vedant-Mhatre --- .../components/utils.quickstart.test.tsx | 58 ++++++++++++++++--- ui/src/app/applications/components/utils.tsx | 54 ++++++++++------- 2 files changed, 82 insertions(+), 30 deletions(-) diff --git a/ui/src/app/applications/components/utils.quickstart.test.tsx b/ui/src/app/applications/components/utils.quickstart.test.tsx index 8d839c9649acf..74031649350c0 100644 --- a/ui/src/app/applications/components/utils.quickstart.test.tsx +++ b/ui/src/app/applications/components/utils.quickstart.test.tsx @@ -3,7 +3,7 @@ import * as renderer from 'react-test-renderer'; import {BehaviorSubject, Subscription} from 'rxjs'; import {ContextApis} from '../../shared/context'; -import {AbstractApplication, Application, ApplicationTree, ResourceNode} from '../../shared/models'; +import {AbstractApplication, Application, ApplicationTree, HealthStatuses, ResourceNode} from '../../shared/models'; import {services} from '../../shared/services'; import {renderResourceButtons} from './utils'; @@ -53,8 +53,14 @@ describe('renderResourceButtons', () => { jest.restoreAllMocks(); }); - function QuickStartWrapper(props: {resourceNode: ResourceNode; version: number; appChanged: BehaviorSubject}) { - return
{renderResourceButtons(props.resourceNode, application, tree, apis, props.appChanged)}
; + function QuickStartWrapper(props: { + resourceNode: ResourceNode; + version: number; + appChanged: BehaviorSubject; + app: Application; + appTree: ApplicationTree; + }) { + return
{renderResourceButtons(props.resourceNode, props.app, props.appTree, apis, props.appChanged)}
; } it('does not reload quickstart actions on unrelated parent rerenders', () => { @@ -65,13 +71,13 @@ describe('renderResourceButtons', () => { try { renderer.act(() => { - rendered = renderer.create(); + rendered = renderer.create(); }); expect(canISpy).toHaveBeenCalledTimes(1); renderer.act(() => { - rendered!.update(); + rendered!.update(); }); expect(canISpy).toHaveBeenCalledTimes(1); @@ -88,11 +94,11 @@ describe('renderResourceButtons', () => { try { renderer.act(() => { - rendered = renderer.create(); + rendered = renderer.create(); }); renderer.act(() => { - rendered!.update(); + rendered!.update(); }); expect(canISpy).toHaveBeenCalledTimes(2); @@ -111,11 +117,11 @@ describe('renderResourceButtons', () => { try { renderer.act(() => { - rendered = renderer.create(); + rendered = renderer.create(); }); renderer.act(() => { - rendered!.update(); + rendered!.update(); }); expect(canISpy).toHaveBeenCalledTimes(2); @@ -124,4 +130,38 @@ describe('renderResourceButtons', () => { secondSubscription.unsubscribe(); } }); + + it('does not reload quickstart actions when application and tree objects change without affecting quickstart behavior', () => { + const canISpy = jest.spyOn(services.accounts, 'canI').mockResolvedValue(false); + const appChanged = new BehaviorSubject(application); + const subscription: Subscription = appChanged.subscribe(() => undefined); + const refreshedApplication = { + ...application, + status: { + ...application.status, + health: {status: HealthStatuses.Healthy} + } + } as Application; + const refreshedTree = { + ...tree, + nodes: [...tree.nodes] + } as ApplicationTree; + let rendered: renderer.ReactTestRenderer | undefined; + + try { + renderer.act(() => { + rendered = renderer.create(); + }); + + renderer.act(() => { + rendered!.update( + + ); + }); + + expect(canISpy).toHaveBeenCalledTimes(1); + } finally { + subscription.unsubscribe(); + } + }); }); diff --git a/ui/src/app/applications/components/utils.tsx b/ui/src/app/applications/components/utils.tsx index 4ce3dbdc4912b..bc5a819acb3c7 100644 --- a/ui/src/app/applications/components/utils.tsx +++ b/ui/src/app/applications/components/utils.tsx @@ -693,6 +693,15 @@ export async function getResourceActionsMenuItems(resource: ResourceTreeNode, me .catch(() => [] as ActionMenuItem[]); } +function isTopLevelResource(resource: ResourceTreeNode, application: appModels.AbstractApplication): boolean { + const resourceID = `/${resource.namespace}/${resource.group}/${resource.kind}/${resource.name}`; + return ( + application.status?.resources?.some( + (resourceStatus: appModels.ResourceStatus) => `/${resourceStatus.namespace}/${resourceStatus.group}/${resourceStatus.kind}/${resourceStatus.name}` === resourceID + ) || false + ); +} + function getActionItems( resource: ResourceTreeNode, application: appModels.AbstractApplication, @@ -701,14 +710,6 @@ function getActionItems( appChanged: BehaviorSubject, isQuickStart: boolean ): Observable { - function isTopLevelResource(res: ResourceTreeNode, app: appModels.AbstractApplication): boolean { - const uniqRes = `/${res.namespace}/${res.group}/${res.kind}/${res.name}`; - return ( - app.status?.resources?.some((resStatus: appModels.ResourceStatus) => `/${resStatus.namespace}/${resStatus.group}/${resStatus.kind}/${resStatus.name}` === uniqRes) || - false - ); - } - const isPod = resource.kind === 'Pod'; const isManaged = isTopLevelResource(resource, application); const childResources = isApp(application) ? findChildResources(resource, tree as appModels.ApplicationTree) : []; @@ -899,14 +900,6 @@ interface ResourceButtonsProps { appChanged: BehaviorSubject; } -interface ResourceButtonsInput { - resource: ResourceTreeNode; - application: appModels.AbstractApplication; - tree: appModels.AbstractApplicationTree; - apisId: number; - appChangedId: number; -} - const objectIds = new WeakMap(); let nextObjectId = 1; @@ -921,19 +914,38 @@ function getObjectId(value: object) { // Keep quickstart action loading stable across unrelated PodView rerenders. const ResourceButtons = React.memo(({resource, application, tree, apis, appChanged}: ResourceButtonsProps) => { + const isManaged = React.useMemo(() => isTopLevelResource(resource, application), [resource, application]); + const childResourceKeys = React.useMemo( + () => + isApp(application) + ? findChildResources(resource, tree as appModels.ApplicationTree) + .map(node => nodeKey(node)) + .sort() + : [], + [resource, application, tree] + ); + const hasLogsTarget = React.useMemo( + () => resource.kind === 'Pod' || (isApp(application) && !!findChildPod(resource, tree as appModels.ApplicationTree)), + [resource, application, tree] + ); const input = React.useMemo( () => ({ - resource, - application, - tree, + resourceKey: nodeKey(resource), + applicationKind: application.kind, + applicationName: application.metadata.name, + applicationNamespace: application.metadata.namespace, + applicationProject: isApp(application) ? application.spec.project : '', + isManaged, + childResourceKeys, + hasLogsTarget, apisId: getObjectId(apis), appChangedId: getObjectId(appChanged) }), - [resource, application, tree, apis, appChanged] + [resource, application, isManaged, childResourceKeys, hasLogsTarget, apis, appChanged] ); return ( - getActionItems(value.resource, value.application, value.tree, apis, appChanged, true)}> + getActionItems(resource, application, tree, apis, appChanged, true)}> {items => (
{items.map((item, i) => ( From 1494ed400bf09d5dffa31b6151de94dcff0ecbf6 Mon Sep 17 00:00:00 2001 From: Vedant-Mhatre Date: Fri, 13 Mar 2026 23:34:46 -0700 Subject: [PATCH 3/3] refine(ui): avoid reloads on apis wrapper changes Signed-off-by: Vedant-Mhatre --- .../components/utils.quickstart.test.tsx | 27 +++++++++++++++++++ ui/src/app/applications/components/utils.tsx | 7 +++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/ui/src/app/applications/components/utils.quickstart.test.tsx b/ui/src/app/applications/components/utils.quickstart.test.tsx index 74031649350c0..c8054d7b6fa8c 100644 --- a/ui/src/app/applications/components/utils.quickstart.test.tsx +++ b/ui/src/app/applications/components/utils.quickstart.test.tsx @@ -164,4 +164,31 @@ describe('renderResourceButtons', () => { subscription.unsubscribe(); } }); + + it('does not reload quickstart actions when the apis wrapper object changes without changing the underlying managers', () => { + const canISpy = jest.spyOn(services.accounts, 'canI').mockResolvedValue(false); + const appChanged = new BehaviorSubject(application); + const subscription: Subscription = appChanged.subscribe(() => undefined); + const refreshedApis = { + popup: apis.popup, + notifications: apis.notifications, + navigation: apis.navigation, + baseHref: apis.baseHref + } as unknown as ContextApis; + let rendered: renderer.ReactTestRenderer | undefined; + + try { + renderer.act(() => { + rendered = renderer.create(
{renderResourceButtons(resource, application, tree, apis, appChanged)}
); + }); + + renderer.act(() => { + rendered!.update(
{renderResourceButtons(resource, application, tree, refreshedApis, appChanged)}
); + }); + + expect(canISpy).toHaveBeenCalledTimes(1); + } finally { + subscription.unsubscribe(); + } + }); }); diff --git a/ui/src/app/applications/components/utils.tsx b/ui/src/app/applications/components/utils.tsx index bc5a819acb3c7..09ec4886ec768 100644 --- a/ui/src/app/applications/components/utils.tsx +++ b/ui/src/app/applications/components/utils.tsx @@ -938,10 +938,13 @@ const ResourceButtons = React.memo(({resource, application, tree, apis, appChang isManaged, childResourceKeys, hasLogsTarget, - apisId: getObjectId(apis), + popupId: getObjectId(apis.popup), + notificationsId: getObjectId(apis.notifications), + navigationId: getObjectId(apis.navigation), + baseHref: apis.baseHref, appChangedId: getObjectId(appChanged) }), - [resource, application, isManaged, childResourceKeys, hasLogsTarget, apis, appChanged] + [resource, application, isManaged, childResourceKeys, hasLogsTarget, apis.popup, apis.notifications, apis.navigation, apis.baseHref, appChanged] ); return (