Skip to content

frontend: api/v2: Include class identity in kube object query keys#5716

Open
WasThatRudy wants to merge 1 commit into
kubernetes-sigs:mainfrom
WasThatRudy:fix/query-cache-class-identity
Open

frontend: api/v2: Include class identity in kube object query keys#5716
WasThatRudy wants to merge 1 commit into
kubernetes-sigs:mainfrom
WasThatRudy:fix/query-cache-class-identity

Conversation

@WasThatRudy
Copy link
Copy Markdown
Contributor

@WasThatRudy WasThatRudy commented May 17, 2026

Summary

useKubeObjectList and useKubeObject build their react-query cache keys from apiVersion + apiName + cluster + namespace + name + queryParams. The keys do not include the class itself, so two distinct KubeObject subclasses that target the same API endpoint share a cache entry. Whichever class's useList() resolves first wins, and the second class reads back items wrapped as the wrong class. _class(), detailsRoute, and getDetailsLink() then resolve incorrectly for the second caller.

The reported repro: a plugin defines class MyPod extends KubeObject with apiVersion = 'v1' and apiName = 'pods' and its own detail route. Visiting the built-in Pods list first, then the plugin's MyPods list, makes the plugin's row links point at the built-in's detail page.

Related Issue

Fixes #4780.

Changes

  • frontend/src/lib/k8s/api/v2/queryKeys.ts (new): houses kubeObjectQueryKey, getKubeObjectClassCacheKey, and getWebsocketMultiplexerEnabled so non-hook callers don't have to import from hook-centric modules. The discriminator is identity-based: a WeakMap<KubeObjectClass, string> stored on globalThis under Symbol.for('headlamp.kubeObjectClassCacheKey') maps each class constructor to a cls-<uuid>-<displayName> string. Symbol.for keys the registry in the global Symbol registry, so the plugin SDK's bundled copy of frontend/src/lib/** reads and writes the same WeakMap as the app. crypto.randomUUID() is used when available, with a Date.now() + Math.random() fallback for environments that lack it.
  • frontend/src/lib/k8s/api/v2/useKubeObjectList.ts: threads getKubeObjectClassCacheKey(kubeObjectClass) into the queryKey produced by kubeObjectListQuery. Two distinct constructors that happen to share a JS .name get distinct discriminators.
  • frontend/src/lib/k8s/api/v2/hooks.ts: extends kubeObjectQueryKey with an optional kubeObjectClassCacheKey field; useKubeObject passes getKubeObjectClassCacheKey(kubeObjectClass). The queryKey useMemo lists [cluster, endpoint, namespace, name, kubeObjectClass, cleanedUpQueryParams] as deps. cleanedUpQueryParams is built by a new useStableCleanedQueryParams helper that proxies through a stringified key derived from cleaned + sorted entries, so structurally equal queryParams (different ref / same content; key-order differences; filtered-out empty values) don't churn the downstream legacy WebSocket. The legacy connectionsRequests memo lists every value it captures so the watch callback can't drift away from the active key.
  • frontend/src/components/common/Link.tsx: updated the existing kubeObjectQueryKey call site to pass getKubeObjectClassCacheKey(kubeObject._class()) when prepopulating the cache from a known instance, so the prepopulated entry lives at the same key the corresponding useKubeObject hook reads from.
  • frontend/src/lib/k8s/api/v2/useKubeObjectList.test.tsx: refactored the four hardcoded setQueryData array literals to call the exported kubeObjectListQuery(...) so the test no longer hard-codes the key shape. Added a describe('kubeObjectListQuery query key', ...) block: two distinct classes that share a JS class name targeting the same endpoint produce distinct keys, and the same class with the same arguments produces identical keys.
  • frontend/src/lib/k8s/api/v2/hooks.test.tsx: added a regression test for the single-object path — two class Pod constructors with the same .name and the same v1/pods endpoint produce distinct 'object' cache entries, and each useKubeObject's data is an instanceof the class that requested it (not the other). Added a vi.resetModules-based test that the discriminator agrees across separate module instances of queryKeys (surrogate for the plugin SDK bundle duplication). Added four tests for useStableCleanedQueryParams covering: same reference when content unchanged, key-order independence, undefined/empty-value equivalence, and reference change on real content change.

Steps to Test

  1. cd frontend && npm install
  2. npm run lint: passes.
  3. npm run tsc: passes.
  4. npx vitest run src/lib/k8s/api/v2/useKubeObjectList.test.tsx src/lib/k8s/api/v2/hooks.test.tsx: all tests pass (existing + new regressions).
  5. Manual repro of the reported bug:
    • Build a small plugin with a custom Pod class as described in Query cache sharing causes wrong class instantiation for resources with the same API endpoint #4780, register a list route under it, build, install, run Headlamp.
    • Before this PR: visit built-in /c/<cluster>/pods first, then the plugin's /my-plugin/pods. Row name links in the plugin's table point at /c/<cluster>/pods/... (the built-in detail route).
    • After this PR: row name links point at the plugin's own detail route as defined by MyPod.detailsRoute.

Screenshots

N/A. The fix is in the cache layer; the user-visible effect is correct detail-route links.

Notes for the Reviewer

  • DCO sign-off applied in the commit.
  • The registry lives on globalThis under Symbol.for(...) rather than module-local state so that the plugin SDK's bundled copy of frontend/src/lib/** shares the same class→id mapping as the app — otherwise each bundle would get its own counter/map and two distinct constructors in different bundles could collide on the same discriminator string.
  • The cache-shape change invalidates existing list-query entries for one fetch cycle after upgrade. Negligible.

@k8s-ci-robot
Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: WasThatRudy
Once this PR has been reviewed and has the lgtm label, please assign joaquimrocha for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@k8s-ci-robot k8s-ci-robot added size/L Denotes a PR that changes 100-499 lines, ignoring generated files. cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. labels May 17, 2026
@illume illume requested a review from Copilot May 17, 2026 18:55
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates frontend Kubernetes API v2 React Query cache keys so KubeObject subclasses targeting the same Kubernetes endpoint do not unintentionally share cached objects, addressing wrong _class()/details route behavior for plugin-defined resource classes.

Changes:

  • Adds a class-name discriminator to list and single-object query keys.
  • Threads the discriminator through object-link cache prepopulation.
  • Updates list-query tests and adds cache-key coverage for distinct classes.

CI status and PR commit history were not available in this review context.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
frontend/src/lib/k8s/api/v2/useKubeObjectList.ts Adds class-name slot to list query keys.
frontend/src/lib/k8s/api/v2/useKubeObjectList.test.tsx Updates expected list keys and adds key identity tests.
frontend/src/lib/k8s/api/v2/hooks.ts Adds optional class-name slot to single-object query keys.
frontend/src/components/common/Link.tsx Uses the object class name when prepopulating object cache entries.
Comments suppressed due to low confidence (1)

frontend/src/lib/k8s/api/v2/hooks.ts:157

  • The memoized key now depends on kubeObjectClass.name, but the dependency list was not updated. If a component re-renders useKubeObject with a different class for the same endpoint/namespace/name, React will keep using the previous class-specific query key, so this change will not separate the object cache for that render path.
      kubeObjectQueryKey({
        cluster,
        name,
        namespace,
        endpoint,
        queryParams: cleanedUpQueryParams,
        kubeObjectClassName: kubeObjectClass.name,
      }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [endpoint, namespace, name]

Comment thread frontend/src/lib/k8s/api/v2/useKubeObjectList.ts Outdated
Comment thread frontend/src/lib/k8s/api/v2/hooks.ts Outdated
@WasThatRudy WasThatRudy force-pushed the fix/query-cache-class-identity branch from 24044ef to 4749d06 Compare May 17, 2026 19:10
@WasThatRudy
Copy link
Copy Markdown
Contributor Author

Pushed 4749d0632 addressing @copilot's review.

Replaced kubeObjectClass.name with a WeakMap-keyed, per-constructor discriminator. New helper getKubeObjectClassCacheKey(cls) returns a string like cls-N-Pod and is stable across renders for the same class reference. The WeakMap is keyed by the class constructor itself, so two distinct classes with the same JS .name (including after minification, or when a plugin author defines class Pod extends KubeObject) get separate cache entries. Used in both kubeObjectListQuery's key and kubeObjectQueryKey's kubeObjectClassCacheKey field; both call sites (useKubeObject and Link) updated to pass it.

Added a regression test ('separates cache entries even when two distinct classes share a JS name (#4780)') that constructs two anonymous class Pod declarations with identical .name = 'Pod' and verifies the keys still differ. Refactored the manual setQueryData array literals in two existing tests to build keys via kubeObjectListQuery(...) rather than hardcoding, so they auto-track future key-shape changes. 21/21 tests pass.

@WasThatRudy
Copy link
Copy Markdown
Contributor Author

cc @illume — both @copilot comments addressed in 4749d0632 (WeakMap-keyed class discriminator that survives same-JS-name collisions, plus a regression test). Ready for a look whenever you have a moment.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread frontend/src/lib/k8s/api/v2/hooks.ts
Comment thread frontend/src/lib/k8s/api/v2/hooks.ts Outdated
@WasThatRudy WasThatRudy force-pushed the fix/query-cache-class-identity branch from 4749d06 to a9e9b64 Compare May 18, 2026 08:02
@WasThatRudy
Copy link
Copy Markdown
Contributor Author

WasThatRudy commented May 18, 2026

both follow-ups are addressed in a9e9b64:

  • hooks.ts useMemo deps now include kubeObjectClass, so a hook rerendered with a different class on the same endpoint/name/namespace will rebuild the query key and stop using the previous discriminator.
  • Added a regression test in hooks.test.tsx that defines two distinct class Pod constructors with the same .name and the same v1/pods endpoint, then asserts getKubeObjectClassCacheKey yields different keys, useKubeObject ends up with two separate 'object' cache entries, and each data is an instance of the class that requested it (and not the other).

cc @illume — small follow-up push, no behavior change to the list-side fix.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread frontend/src/lib/k8s/api/v2/hooks.ts Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread frontend/src/lib/k8s/api/v2/hooks.ts
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread frontend/src/lib/k8s/api/v2/useKubeObjectList.ts Outdated
@WasThatRudy WasThatRudy requested a review from Copilot May 18, 2026 12:19
@WasThatRudy WasThatRudy force-pushed the fix/query-cache-class-identity branch from ea87ee3 to aafefa6 Compare May 19, 2026 06:25
@WasThatRudy WasThatRudy requested a review from Copilot May 19, 2026 07:31
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comment thread frontend/src/lib/k8s/api/v2/queryKeys.ts
Comment thread frontend/src/lib/k8s/api/v2/hooks.ts Outdated
@WasThatRudy WasThatRudy force-pushed the fix/query-cache-class-identity branch from aafefa6 to aaed0dc Compare May 19, 2026 07:46
@illume illume requested a review from Copilot May 19, 2026 08:34
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comment thread frontend/src/lib/k8s/api/v2/queryKeys.ts Outdated
@WasThatRudy WasThatRudy force-pushed the fix/query-cache-class-identity branch 2 times, most recently from 64eabb9 to bfab3d9 Compare May 19, 2026 08:56
@WasThatRudy WasThatRudy requested a review from Copilot May 19, 2026 09:00
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Comment thread frontend/src/lib/k8s/api/v2/queryKeys.ts
Comment thread frontend/src/lib/k8s/api/v2/queryKeys.ts Outdated
Comment thread frontend/src/lib/k8s/api/v2/hooks.ts Outdated
Comment thread frontend/src/lib/k8s/api/v2/hooks.ts Outdated
@WasThatRudy WasThatRudy force-pushed the fix/query-cache-class-identity branch from bfab3d9 to 6e2748c Compare May 19, 2026 09:10
@illume illume requested a review from Copilot May 19, 2026 14:32
Copy link
Copy Markdown
Contributor

@illume illume left a comment

Choose a reason for hiding this comment

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

Thanks for the contribution.

The GitHub CI test job has snapshot failures. Run cd frontend && npm run test -- -u to regenerate the snapshots.

How to update snapshots

Run cd frontend && npm run test -- -u to regenerate all snapshots. Review the diff to make sure the visual changes are intentional, then commit the updated snapshot files.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

The query keys used by `useKubeObjectList` and `useKubeObject` are
derived from `apiVersion` + `apiName` + cluster + namespace + name +
queryParams. They omit the class itself, so two distinct KubeObject
subclasses that target the same API endpoint share a cache entry.

Concretely: a plugin defines `class MyPod extends KubeObject` with
`apiVersion = 'v1'` and `apiName = 'pods'` to render its own detail
route. Headlamp's built-in Pod uses the same `apiVersion` and
`apiName`. The two `useList()` calls produce the same query key, so
react-query runs only one queryFn and serves both call sites from the
first class's cached items. `_class()`, `detailsRoute`, and
`getDetailsLink()` then resolve to whichever class loaded first
rather than the class the caller actually used.

Adds `kubeObjectClass.name` to the list query key in
`kubeObjectListQuery` and threads a new `kubeObjectClassName` field
through `kubeObjectQueryKey` for the single-object hook. Updates the
two callers of `kubeObjectQueryKey` (`useKubeObject` and `Link`) to
pass the class name. The hardcoded query-key arrays in
`useKubeObjectList.test.tsx` are updated to match the new shape.

Adds a `kubeObjectListQuery query key` describe block covering two
cases: two distinct classes targeting the same endpoint produce
distinct keys, and the same class with the same arguments produces
identical keys.

Note: this fix relies on plugin-authored classes carrying a JS class
name that differs from the built-in's (the reported repro uses
`MyPod` vs `Pod`). Plugins that define a custom class with the same
JS name as a built-in will still collide; a follow-up could expose
an explicit static cache-key field on `KubeObject` for that edge
case.

Bug report: kubernetes-sigs#4780.

Signed-off-by: Rudraksha Singh Sengar <rudraksharss@gmail.com>
@WasThatRudy WasThatRudy force-pushed the fix/query-cache-class-identity branch from 6e2748c to b3598e0 Compare May 19, 2026 15:47
@k8s-ci-robot k8s-ci-robot added size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. and removed size/L Denotes a PR that changes 100-499 lines, ignoring generated files. labels May 19, 2026
Copy link
Copy Markdown
Contributor

@sniok sniok left a comment

Choose a reason for hiding this comment

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

this change currently breaks all the snapshot tests, rendering things like No data to be shown.

please make sure that snapshots stay the same

I'm also not sure if this is a good direction for us to take, we should avoid situations where same resource is represented by different class implementations

@WasThatRudy WasThatRudy force-pushed the fix/query-cache-class-identity branch from b3598e0 to 847373c Compare May 19, 2026 16:01
@k8s-ci-robot k8s-ci-robot added size/L Denotes a PR that changes 100-499 lines, ignoring generated files. and removed size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. labels May 19, 2026
@WasThatRudy
Copy link
Copy Markdown
Contributor Author

WasThatRudy commented May 19, 2026

Hi @illume @sniok, two updates:

Snapshots: the breakage sniok saw was self-inflicted by an earlier commit of mine that regenerated snapshots on Node 24 locally. Node 24's stricter undici/AbortSignal type check breaks MSW interception for stories that pass an AbortSignal to fetch (e.g. the ReleaseNotes story), so the regen captured failure-state output instead of the mocked-success state. I switched to Node 22 (matches CI), reverted that bad commit, and re-ran npm run test: all 1563 tests pass against the original snapshots. Current sha 847373c15 should be clean. Sorry for the noise.

Architectural concern @sniok raised: today the cache silently swaps a plugin's class for the built-in one whenever they share an endpoint (the #4780 repro: class MyPod extends KubeObject with apiVersion='v1' apiName='pods' and its own detail route ends up reading rows wrapped as built-in Pod, so _class() and getDetailsLink() return the wrong route). This PR makes the cache discriminate per class identity so each class reads back its own instances; it doesn't introduce duplicate class implementations, it stops one class's data from silently hijacking another's.

If the longer-term direction is "plugins shouldn't be allowed to define a second class against an endpoint that already has one", that's an additive guard (warn or reject at registration time) and worth doing separately, but it doesn't subsume this fix, because users who already shipped such plugins still hit the wrong-class bug today. Happy to follow up with a registration-time guard in a separate PR if that's the direction you want. Let me know your thoughts.

@sniok
Copy link
Copy Markdown
Contributor

sniok commented May 19, 2026

because users who already shipped such plugins still hit the wrong-class bug today

No plugins have hit this issue. Redefining built-in classes doesn't really make sense although technically possible today

@WasThatRudy
Copy link
Copy Markdown
Contributor Author

@sniok you're right, sorry for pushing back. I leaned on a user impact claim I didn't actually have.

The component level approach covers what someone would reach for a subclass to do, without the collision in the first place: use the built in Pod class for data, render your own list rows with <Link> pointing at a plugin registered route, and use registerDetailsViewSection or DetailsGrid to customise the detail view. No class override needed.

Plan: close this PR. If it's useful I can follow up with a small change that warns or rejects at registration time when a plugin tries to register a second class against an endpoint that already has one, plus a docs note pointing plugin authors at the component pattern. Or just close and skip the follow up if that's noise. Let me know which you prefer.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. size/L Denotes a PR that changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Query cache sharing causes wrong class instantiation for resources with the same API endpoint

5 participants