diff --git a/frontend/src/components/common/Link.tsx b/frontend/src/components/common/Link.tsx
index 90fe82e236a..8a4f6cab5a4 100644
--- a/frontend/src/components/common/Link.tsx
+++ b/frontend/src/components/common/Link.tsx
@@ -19,7 +19,8 @@ import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { formatClusterPathParam, getCluster, getSelectedClusters } from '../../lib/cluster';
-import { kubeObjectQueryKey, useEndpoints } from '../../lib/k8s/api/v2/hooks';
+import { useEndpoints } from '../../lib/k8s/api/v2/hooks';
+import { getKubeObjectClassCacheKey, kubeObjectQueryKey } from '../../lib/k8s/api/v2/queryKeys';
import type { KubeObject } from '../../lib/k8s/KubeObject';
import type { RouteURLProps } from '../../lib/router/createRouteURL';
import { createRouteURL } from '../../lib/router/createRouteURL';
@@ -74,6 +75,7 @@ function KubeObjectLink(props: {
endpoint,
namespace,
name,
+ kubeObjectClassCacheKey: getKubeObjectClassCacheKey(kubeObject._class()),
});
// prepopulate the query cache with existing object
client.setQueryData(key, kubeObject);
diff --git a/frontend/src/lib/k8s/api/v2/hooks.test.tsx b/frontend/src/lib/k8s/api/v2/hooks.test.tsx
index d91d8a4b61d..c34e3a4dbd4 100644
--- a/frontend/src/lib/k8s/api/v2/hooks.test.tsx
+++ b/frontend/src/lib/k8s/api/v2/hooks.test.tsx
@@ -18,10 +18,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, type MockedFunction, vi } from 'vitest';
+import type { QueryParameters } from '../v1/queryParameters';
import { ApiError } from './ApiError';
import { clusterFetch } from './fetch';
-import { useEndpoints, useKubeObject } from './hooks';
+import { useEndpoints, useKubeObject, useStableCleanedQueryParams } from './hooks';
import type { KubeObjectEndpoint } from './KubeObjectEndpoint';
+import { getKubeObjectClassCacheKey } from './queryKeys';
vi.mock('./fetch', () => ({
clusterFetch: vi.fn(),
@@ -397,3 +399,164 @@ describe('useKubeObject watch wiring', () => {
});
});
});
+
+describe('useKubeObject class-identity cache key', () => {
+ beforeEach(() => vi.clearAllMocks());
+ afterEach(() => vi.unstubAllEnvs());
+
+ it('produces distinct query entries for two classes with the same JS name and endpoint', async () => {
+ vi.stubEnv('REACT_APP_ENABLE_WEBSOCKET_MULTIPLEXER', 'false');
+ mockClusterFetch.mockResolvedValue(
+ mockJsonResponse({
+ apiVersion: 'v1',
+ kind: 'Pod',
+ metadata: { name: 'my-pod', namespace: 'my-ns' },
+ })
+ );
+
+ const PodA = class Pod {
+ static apiEndpoint = {
+ apiInfo: [{ version: 'v1', resource: 'pods' }] as KubeObjectEndpoint[],
+ };
+ constructor(public jsonData: any, public cluster?: string) {}
+ } as any;
+ const PodB = class Pod {
+ static apiEndpoint = {
+ apiInfo: [{ version: 'v1', resource: 'pods' }] as KubeObjectEndpoint[],
+ };
+ constructor(public jsonData: any, public cluster?: string) {}
+ } as any;
+
+ expect(PodA.name).toBe(PodB.name);
+ expect(PodA).not.toBe(PodB);
+ expect(getKubeObjectClassCacheKey(PodA)).not.toBe(getKubeObjectClassCacheKey(PodB));
+
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ refetchOnWindowFocus: false,
+ refetchOnMount: false,
+ refetchOnReconnect: false,
+ },
+ },
+ });
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+
+ const resultA = renderHook(
+ () =>
+ useKubeObject({
+ kubeObjectClass: PodA,
+ name: 'my-pod',
+ namespace: 'my-ns',
+ cluster: 'test',
+ }),
+ { wrapper }
+ );
+ const resultB = renderHook(
+ () =>
+ useKubeObject({
+ kubeObjectClass: PodB,
+ name: 'my-pod',
+ namespace: 'my-ns',
+ cluster: 'test',
+ }),
+ { wrapper }
+ );
+
+ await waitFor(() => {
+ expect(resultA.result.current.data).toBeTruthy();
+ expect(resultB.result.current.data).toBeTruthy();
+ });
+
+ expect(resultA.result.current.data).toBeInstanceOf(PodA);
+ expect(resultB.result.current.data).toBeInstanceOf(PodB);
+ expect(resultA.result.current.data).not.toBeInstanceOf(PodB);
+ expect(resultB.result.current.data).not.toBeInstanceOf(PodA);
+
+ const objectQueries = queryClient
+ .getQueryCache()
+ .getAll()
+ .filter(q => Array.isArray(q.queryKey) && q.queryKey[0] === 'object');
+ expect(objectQueries).toHaveLength(2);
+ });
+
+ it('agrees on the same discriminator across separate module instances of queryKeys', async () => {
+ // Surrogate for the plugin SDK shipping its own copy of `frontend/src/lib/**`:
+ // re-import `queryKeys` from a fresh module instance and verify that calling
+ // `getKubeObjectClassCacheKey(Cls)` from either instance yields the same id
+ // for the same class. The globalThis-stored registry under `Symbol.for(...)`
+ // means the second call reads the value the first call stamped, regardless
+ // of module identity.
+ const SharedCls = class Shared {} as any;
+ const first = getKubeObjectClassCacheKey(SharedCls);
+
+ vi.resetModules();
+ const fresh = await import('./queryKeys');
+ expect(fresh.getKubeObjectClassCacheKey).not.toBe(getKubeObjectClassCacheKey);
+ const second = fresh.getKubeObjectClassCacheKey(SharedCls);
+
+ expect(second).toBe(first);
+ });
+});
+
+describe('useStableCleanedQueryParams', () => {
+ it('returns the same reference when the cleaned entries are unchanged across rerenders', () => {
+ const { result, rerender } = renderHook<
+ ReturnType,
+ { qp: QueryParameters | undefined }
+ >(({ qp }) => useStableCleanedQueryParams(qp), {
+ initialProps: { qp: { labelSelector: 'a=b' } },
+ });
+ const first = result.current;
+
+ // New input object, structurally equal content → same returned reference.
+ rerender({ qp: { labelSelector: 'a=b' } });
+ expect(result.current).toBe(first);
+ });
+
+ it('returns the same reference regardless of key insertion order', () => {
+ const { result, rerender } = renderHook<
+ ReturnType,
+ { qp: QueryParameters | undefined }
+ >(({ qp }) => useStableCleanedQueryParams(qp), {
+ initialProps: { qp: { labelSelector: 'a=b', fieldSelector: 'metadata.name=foo' } },
+ });
+ const first = result.current;
+
+ rerender({ qp: { fieldSelector: 'metadata.name=foo', labelSelector: 'a=b' } });
+ expect(result.current).toBe(first);
+ });
+
+ it('treats undefined/empty values as equivalent to the entry being absent', () => {
+ const { result, rerender } = renderHook<
+ ReturnType,
+ { qp: QueryParameters | undefined }
+ >(({ qp }) => useStableCleanedQueryParams(qp), {
+ initialProps: { qp: { labelSelector: 'a=b' } },
+ });
+ const first = result.current;
+
+ rerender({ qp: { labelSelector: 'a=b', fieldSelector: '' } });
+ expect(result.current).toBe(first);
+
+ rerender({ qp: { labelSelector: 'a=b', fieldSelector: undefined } });
+ expect(result.current).toBe(first);
+ });
+
+ it('returns a new reference when an effective value actually changes', () => {
+ const { result, rerender } = renderHook<
+ ReturnType,
+ { qp: QueryParameters | undefined }
+ >(({ qp }) => useStableCleanedQueryParams(qp), {
+ initialProps: { qp: { labelSelector: 'a=b' } },
+ });
+ const first = result.current;
+
+ rerender({ qp: { labelSelector: 'a=c' } });
+ expect(result.current).not.toBe(first);
+ expect(result.current).toEqual({ labelSelector: 'a=c' });
+ });
+});
diff --git a/frontend/src/lib/k8s/api/v2/hooks.ts b/frontend/src/lib/k8s/api/v2/hooks.ts
index 70519c26744..b851a42b878 100644
--- a/frontend/src/lib/k8s/api/v2/hooks.ts
+++ b/frontend/src/lib/k8s/api/v2/hooks.ts
@@ -25,8 +25,12 @@ import type { KubeListUpdateEvent } from './KubeList';
import { KubeObjectEndpoint } from './KubeObjectEndpoint';
import { makeUrl } from './makeUrl';
import { useWebSocket } from './multiplexer';
+import {
+ getKubeObjectClassCacheKey,
+ getWebsocketMultiplexerEnabled,
+ kubeObjectQueryKey,
+} from './queryKeys';
import { kubeRequestRetry } from './retry';
-import { getWebsocketMultiplexerEnabled } from './useKubeObjectList';
import { useWebSockets } from './webSocket';
export type QueryStatus = 'pending' | 'success' | 'error';
@@ -82,20 +86,6 @@ export interface QueryListResponse
errors: ApiError[] | null;
}
-export const kubeObjectQueryKey = ({
- cluster,
- endpoint,
- namespace,
- name,
- queryParams,
-}: {
- cluster: string;
- endpoint?: KubeObjectEndpoint | null;
- namespace?: string;
- name: string;
- queryParams?: QueryParameters;
-}) => ['object', cluster, endpoint, namespace ?? '', name, queryParams ?? {}];
-
/**
* Returns a single KubeObject.
*/
@@ -124,15 +114,19 @@ export function useKubeObject({
name
);
- const cleanedUpQueryParams = Object.fromEntries(
- Object.entries(queryParams ?? {}).filter(([, value]) => value !== undefined && value !== '')
- );
+ const cleanedUpQueryParams = useStableCleanedQueryParams(queryParams);
const queryKey = useMemo(
() =>
- kubeObjectQueryKey({ cluster, name, namespace, endpoint, queryParams: cleanedUpQueryParams }),
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [endpoint, namespace, name]
+ kubeObjectQueryKey({
+ cluster,
+ name,
+ namespace,
+ endpoint,
+ queryParams: cleanedUpQueryParams,
+ kubeObjectClassCacheKey: getKubeObjectClassCacheKey(kubeObjectClass),
+ }),
+ [cluster, endpoint, namespace, name, kubeObjectClass, cleanedUpQueryParams]
);
const client = useQueryClient();
@@ -174,8 +168,7 @@ export function useKubeObject({
},
},
];
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [endpoint]);
+ }, [endpoint, namespace, name, cluster, cleanedUpQueryParams, kubeObjectClass, queryKey, client]);
const multiplexerEnabled = getWebsocketMultiplexerEnabled();
@@ -284,3 +277,35 @@ export const useEndpoints = (
return { endpoint, error };
};
+
+/**
+ * Filters `queryParams` to drop undefined/empty values and returns a reference
+ * that's stable across renders when the resulting set of entries is unchanged
+ * (regardless of input reference identity or key insertion order). The
+ * stability matters for downstream effect deps (the legacy WebSocket
+ * connection in `useKubeObject`) that would otherwise churn on each render.
+ *
+ * Exported so the contract (stability + reference equality across reorders)
+ * can be exercised directly in unit tests.
+ */
+export function useStableCleanedQueryParams(
+ queryParams: QueryParameters | undefined
+): QueryParameters {
+ const key = useMemo(
+ () =>
+ JSON.stringify(
+ Object.entries(queryParams ?? {})
+ .filter(([, value]) => value !== undefined && value !== '')
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
+ ),
+ [queryParams]
+ );
+ return useMemo(
+ () =>
+ Object.fromEntries(
+ Object.entries(queryParams ?? {}).filter(([, value]) => value !== undefined && value !== '')
+ ) as QueryParameters,
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- proxied via the stringified key above
+ [key]
+ );
+}
diff --git a/frontend/src/lib/k8s/api/v2/queryKeys.ts b/frontend/src/lib/k8s/api/v2/queryKeys.ts
new file mode 100644
index 00000000000..aa5a4b1e664
--- /dev/null
+++ b/frontend/src/lib/k8s/api/v2/queryKeys.ts
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2025 The Kubernetes Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { KubeObjectClass } from '../../KubeObject';
+import type { QueryParameters } from '../v1/queryParameters';
+import type { KubeObjectEndpoint } from './KubeObjectEndpoint';
+
+/**
+ * Feature flag for the websocket multiplexer. Reads
+ * `REACT_APP_ENABLE_WEBSOCKET_MULTIPLEXER`; only the literal string `'true'`
+ * enables it, so the multiplexer is opt-in and defaults to disabled when the
+ * env var is unset or any other value.
+ */
+export function getWebsocketMultiplexerEnabled(): boolean {
+ return import.meta.env.REACT_APP_ENABLE_WEBSOCKET_MULTIPLEXER === 'true';
+}
+
+/**
+ * Stable cache-key discriminator per KubeObject class so two classes hitting
+ * the same API endpoint (e.g. plugin MyPod vs built-in Pod) get separate
+ * react-query cache entries (#4780). The class→id map is stored on `globalThis`
+ * under a `Symbol.for(...)` key so the plugin SDK's bundled copy of
+ * `frontend/src/lib/**` shares the same lookup as the app; a WeakMap is keyed
+ * by the class constructor reference (so subclasses don't inherit the parent's
+ * id and unused classes can be GC'd); a manual `randomUUID` fallback covers
+ * environments where `crypto.randomUUID` isn't present.
+ */
+const REGISTRY_KEY = Symbol.for('headlamp.kubeObjectClassCacheKey');
+
+function getClassIdRegistry(): WeakMap {
+ const slot = globalThis as unknown as { [key: symbol]: unknown };
+ let registry = slot[REGISTRY_KEY] as WeakMap | undefined;
+ if (!registry) {
+ registry = new WeakMap();
+ slot[REGISTRY_KEY] = registry;
+ }
+ return registry;
+}
+
+function randomId(): string {
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
+ if (typeof c?.randomUUID === 'function') {
+ return c.randomUUID();
+ }
+ // Fallback for environments without `crypto.randomUUID`. Not cryptographically
+ // secure, but we only need per-class uniqueness within a session.
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
+}
+
+export function getKubeObjectClassCacheKey(cls: KubeObjectClass): string {
+ const registry = getClassIdRegistry();
+ let id = registry.get(cls);
+ if (id === undefined) {
+ id = `cls-${randomId()}-${cls.name || 'anon'}`;
+ registry.set(cls, id);
+ }
+ return id;
+}
+
+/**
+ * Builds the react-query cache key for a single KubeObject GET.
+ */
+export const kubeObjectQueryKey = ({
+ cluster,
+ endpoint,
+ namespace,
+ name,
+ queryParams,
+ kubeObjectClassCacheKey,
+}: {
+ cluster: string;
+ endpoint?: KubeObjectEndpoint | null;
+ namespace?: string;
+ name: string;
+ queryParams?: QueryParameters;
+ /**
+ * Stable per-class cache discriminator from `getKubeObjectClassCacheKey`.
+ * Used to separate cache entries between two classes targeting the same
+ * API endpoint (e.g. a plugin's MyPod and the built-in Pod), so `_class()`
+ * resolves correctly on each (#4780).
+ */
+ kubeObjectClassCacheKey?: string;
+}) => [
+ 'object',
+ cluster,
+ endpoint,
+ namespace ?? '',
+ name,
+ kubeObjectClassCacheKey ?? '',
+ queryParams ?? {},
+];
diff --git a/frontend/src/lib/k8s/api/v2/useKubeObjectList.test.tsx b/frontend/src/lib/k8s/api/v2/useKubeObjectList.test.tsx
index a4f8d8c41b0..81bfb42c83f 100644
--- a/frontend/src/lib/k8s/api/v2/useKubeObjectList.test.tsx
+++ b/frontend/src/lib/k8s/api/v2/useKubeObjectList.test.tsx
@@ -314,16 +314,23 @@ describe('useKubeObjectList', () => {
const spy = vi.spyOn(websocket, 'useWebSockets');
const queryClient = new QueryClient();
- queryClient.setQueryData(['kubeObject', 'list', 'v1', 'pods', 'default', 'a', {}], {
- list: { items: [], metadata: { resourceVersion: '0' } },
- cluster: 'default',
- namespace: 'a',
- });
- queryClient.setQueryData(['kubeObject', 'list', 'v1', 'pods', 'default', 'b', {}], {
- list: { items: [], metadata: { resourceVersion: '0' } },
- cluster: 'default',
- namespace: 'b',
- });
+ const podsEndpoint = { version: 'v1', resource: 'pods' };
+ queryClient.setQueryData(
+ kubeObjectListQuery(mockClass, podsEndpoint, 'a', 'default', {}).queryKey,
+ {
+ list: { items: [], metadata: { resourceVersion: '0' } },
+ cluster: 'default',
+ namespace: 'a',
+ }
+ );
+ queryClient.setQueryData(
+ kubeObjectListQuery(mockClass, podsEndpoint, 'b', 'default', {}).queryKey,
+ {
+ list: { items: [], metadata: { resourceVersion: '0' } },
+ cluster: 'default',
+ namespace: 'b',
+ }
+ );
const result = renderHook(
(props: {}) =>
@@ -351,14 +358,21 @@ describe('useKubeObjectList', () => {
const spy = vi.spyOn(websocket, 'useWebSockets');
const queryClient = new QueryClient();
- queryClient.setQueryData(['kubeObject', 'list', 'v1', 'nodes', 'cluster-1', '', {}], {
- list: { items: [], metadata: { resourceVersion: '0' } },
- cluster: 'cluster-1',
- });
- queryClient.setQueryData(['kubeObject', 'list', 'v1', 'nodes', 'cluster-2', '', {}], {
- list: { items: [], metadata: { resourceVersion: '0' } },
- cluster: 'cluster-2',
- });
+ const nodesEndpoint = { version: 'v1', resource: 'nodes' };
+ queryClient.setQueryData(
+ kubeObjectListQuery(mockNodeClass, nodesEndpoint, undefined, 'cluster-1', {}).queryKey,
+ {
+ list: { items: [], metadata: { resourceVersion: '0' } },
+ cluster: 'cluster-1',
+ }
+ );
+ queryClient.setQueryData(
+ kubeObjectListQuery(mockNodeClass, nodesEndpoint, undefined, 'cluster-2', {}).queryKey,
+ {
+ list: { items: [], metadata: { resourceVersion: '0' } },
+ cluster: 'cluster-2',
+ }
+ );
const result = renderHook(
(props: { requests: Array<{ cluster: string; namespaces?: string[] }> }) =>
@@ -498,3 +512,68 @@ describe('useWatchKubeObjectLists (Multiplexer)', () => {
expect(spy).toHaveBeenCalledWith({ enabled: false, connections: [] });
});
});
+
+describe('kubeObjectListQuery query key', () => {
+ const endpoint = { version: 'v1', resource: 'pods' };
+ const cluster = 'default';
+ const namespace = 'ns';
+ const queryParams = {};
+
+ it('separates cache entries for distinct classes that target the same API (#4780)', () => {
+ const builtInPodClass = class {
+ static apiVersion = 'v1';
+ static apiName = 'pods';
+ } as any;
+ const pluginPodClass = class {
+ static apiVersion = 'v1';
+ static apiName = 'pods';
+ } as any;
+
+ const builtInKey = kubeObjectListQuery(
+ builtInPodClass,
+ endpoint,
+ namespace,
+ cluster,
+ queryParams
+ ).queryKey;
+ const pluginKey = kubeObjectListQuery(
+ pluginPodClass,
+ endpoint,
+ namespace,
+ cluster,
+ queryParams
+ ).queryKey;
+
+ expect(builtInKey).not.toEqual(pluginKey);
+ // Both keys still share the apiVersion + apiName segments; only the
+ // per-class discriminator differentiates them.
+ expect(builtInKey).toContain('v1');
+ expect(builtInKey).toContain('pods');
+ expect(pluginKey).toContain('v1');
+ expect(pluginKey).toContain('pods');
+ });
+
+ it('separates cache entries even when two distinct classes share a JS name (#4780)', () => {
+ const A = class Pod {
+ static apiVersion = 'v1';
+ static apiName = 'pods';
+ } as any;
+ const B = class Pod {
+ static apiVersion = 'v1';
+ static apiName = 'pods';
+ } as any;
+ expect(A).not.toBe(B);
+ expect(A.name).toBe(B.name);
+
+ const keyA = kubeObjectListQuery(A, endpoint, namespace, cluster, queryParams).queryKey;
+ const keyB = kubeObjectListQuery(B, endpoint, namespace, cluster, queryParams).queryKey;
+ expect(keyA).not.toEqual(keyB);
+ });
+
+ it('produces the same cache key for the same class and arguments', () => {
+ const keyA = kubeObjectListQuery(mockClass, endpoint, namespace, cluster, queryParams).queryKey;
+ const keyB = kubeObjectListQuery(mockClass, endpoint, namespace, cluster, queryParams).queryKey;
+
+ expect(keyA).toEqual(keyB);
+ });
+});
diff --git a/frontend/src/lib/k8s/api/v2/useKubeObjectList.ts b/frontend/src/lib/k8s/api/v2/useKubeObjectList.ts
index c6e2fdf1ddd..7af749d821b 100644
--- a/frontend/src/lib/k8s/api/v2/useKubeObjectList.ts
+++ b/frontend/src/lib/k8s/api/v2/useKubeObjectList.ts
@@ -28,17 +28,10 @@ import { KubeList } from './KubeList';
import { KubeObjectEndpoint } from './KubeObjectEndpoint';
import { makeUrl } from './makeUrl';
import { WebSocketManager } from './multiplexer';
+import { getKubeObjectClassCacheKey, getWebsocketMultiplexerEnabled } from './queryKeys';
import { kubeRequestRetry } from './retry';
import { BASE_WS_URL, useWebSockets } from './webSocket';
-/**
- * @returns true if the websocket multiplexer is enabled.
- * defaults to true. This is a feature flag to enable the websocket multiplexer.
- */
-export function getWebsocketMultiplexerEnabled(): boolean {
- return import.meta.env.REACT_APP_ENABLE_WEBSOCKET_MULTIPLEXER === 'true';
-}
-
/**
* Object representing a List of Kube object
* with information about which cluster and namespace it came from
@@ -79,6 +72,7 @@ export function kubeObjectListQuery(
'list',
kubeObjectClass.apiVersion,
kubeObjectClass.apiName,
+ getKubeObjectClassCacheKey(kubeObjectClass),
cluster,
namespace ?? '',
queryParams,