Skip to content

fix(router): browser back works through resource drawer + relationship links (SKY-849)#618

Merged
nadaverell merged 7 commits into
mainfrom
feat/stop-replace-nav-on-resource-and-parent-link-clicks
May 10, 2026
Merged

fix(router): browser back works through resource drawer + relationship links (SKY-849)#618
nadaverell merged 7 commits into
mainfrom
feat/stop-replace-nav-on-resource-and-parent-link-clicks

Conversation

@eliran-ops
Copy link
Copy Markdown
Contributor

@eliran-ops eliran-ops commented May 4, 2026

Summary

4 commits on this branch addressing two history bugs:

  1. Bug 8: clicking a resource in the list deep-linked, then clicking another resource opened a drawer — back button skipped both intermediate states.
  2. Bug 11: clicking "Parent Gateways" from a TCPRoute → Gateways list → back did nothing.

Changes:

  • 122c82f ResourcesView URL writer: pushes (instead of replaces) when pathname differs (cross-kind nav like Parent Gateways) or selectedResource A→B (same-list drawer-to-drawer). Initial open (null→X) and close (X→null) stay as replace.
  • 122c82f App.tsx L1294: dropped { replace: true } from onNavigateToResource (expanded-drawer related-resource navigation).
  • 796926e + c00e19a: POP-only URL→drawer state sync — without this, back changed the URL but the drawer still showed the previous resource. Restricted to NavigationType.Pop to avoid clobbering helm/audit programmatic nav.
  • 4487545: basename-aware path compare — window.location.pathname includes a host's basename (e.g. /c/{cluster} under embed) while targetPath is basename-relative; reading window directly would force every URL write to push, polluting history with filter keystrokes.

Why

SKY-849. The most invasive of the audit bugs — touches the URL writer directly. 4 review iterations across pre-pr + deep-deep.

Where to start

  • packages/k8s-ui/src/components/resources/ResourcesView.tsx lines 2466-2497 — push/replace decision logic.
  • web/src/App.tsx lines 684-712 — POP-only URL→state sync effect.
  • web/src/App.tsx line 1324 — onNavigateToResource push (no longer replace).

Risky vs mechanical

  • Risky: history strategy is fragile by nature. Walked all the paths in pre-pr review (sidebar click, keyboard, deep-link mount, Parent Gateways, drawer onClose, helm/audit programmatic nav) — all verified. POP-only sync uses useNavigationType() which reflects the LAST navigation; in extreme race conditions a non-POP nav between the back and the effect run could break sync, but no realistic trigger.
  • Risky: legitimate replace sites left intentionally — App.tsx L188 (auth redirect), L234 (canonical route), L535 (cluster switch), L661/L1227/L1308 (filter/helm param updates).
  • Mechanical: basename-aware compare matches the existing pattern in getInitialKindFromURL (line 1700-1705).

Reviewer focus

  • Walk the user repro for both bugs end-to-end on a real cluster before approving.
  • Confirm helm + audit pages still navigate correctly (POP-only sync was specifically tightened to avoid race against those flows).

🤖 Generated with Claude Code


Note

Medium Risk
Touches URL-writing and back/forward reconciliation logic in ResourcesView/App, which can subtly affect navigation history and embedded-basepath deployments. Scope is contained to resources-view URL updates and POP navigation handling.

Overview
Fixes browser Back/Forward behavior for the resource drawer by adjusting when URL updates push vs replace history entries.

ResourcesView now (1) avoids redundant navigations when the computed target URL already matches the address bar, and (2) pushes history on kind/path changes or drawer-to-drawer resource switches while keeping initial open/close as replaces (with basename-aware path comparisons).

App adds POP-only URL→state reconciliation using useNavigationType() so same-kind back/forward updates the selected resource drawer state even when ResourcesView doesn’t remount.

Reviewed by Cursor Bugbot for commit 990e9be. Bugbot is set up for automated code reviews on this repo. Configure here.

eliran-mic and others added 4 commits May 3, 2026 14:47
Two browser-back regressions fixed:
- Clicking a Parent Gateway from a TCPRoute drawer rewrote the URL with
  history.replace, so back did nothing on the resulting page.
- Clicking a different resource in the same list while a drawer was open
  also replaced, so back skipped the previously selected resource and
  jumped to whatever preceded the list view.

ResourcesView's URL writer now pushes history when the pathname is
about to change (external setSelectedResource targeting a different
kind) or when selectedResource transitions A -> B (both non-null,
different) -- a real navigation. Initial open and close stay as URL
replaces. App.tsx onNavigateToResource (drawer expanded path) now
pushes too so back from a related-resource workload returns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pushing drawer-to-drawer history entries gives the browser back button a
target, but ResourcesView only re-reads ?resource= on mount. Same-kind
back (e.g. /resources/pods?resource=ns/A from B) doesn't change pathname,
so React doesn't re-mount and selectedResource stays stale, leaving the
URL pointing at A while the drawer renders B. Reconcile selectedResource
from the URL alongside the existing helm/namespace sync so back/forward
within /resources/{kind} restores the matching drawer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reading ?resource= on every URL change clobbers deliberate
setSelectedResource calls that race the URL writer - e.g. the helm
drawer's "navigate to resource" calls navigate() then setSelectedResource
in the same tick, but the writer only adds ?resource= on the next render,
so the App-level sync momentarily sees a URL without the param and nulls
the selection. Gate the sync on useNavigationType() === POP so it only
runs for browser back/forward, where the URL is the actual source of
truth and no concurrent setSelectedResource is in flight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hosts that embed the app under a non-empty basename inject the
basename-stripped pathname via the locationPathname prop. Reading
window.location.pathname directly would include the basename and never
match the basename-relative targetPath, so pathChanged would be true on
every URL write and every filter keystroke would push a history entry
instead of replacing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread packages/k8s-ui/src/components/resources/ResourcesView.tsx Outdated
Two CRDs can share a kind name across different API groups (e.g.
Service in serving.knative.dev vs core). Switching between them
yielded drawerSwitched=false and replaced history instead of
pushing — back button couldn't return to the prior resource. Aligns
with the App-level POP sync equality check which already includes
group.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread web/src/App.tsx Outdated
handleCollapseFromExpanded calls navigate(-1) to return to the pre-
expand state. With push semantics on related-resource clicks inside
the expanded drawer, navigate(-1) only popped one drawer-internal
step instead of returning to the pre-expand list view.

Replace semantics here match the user mental model of "I'm exploring
inside the expanded drawer; collapse closes it" without breaking
bug 8: the load-bearing fix lives in the ResourcesView URL writer
(push when pathname differs or selectedResource A->B), which is
unrelated to this flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@eliran-ops
Copy link
Copy Markdown
Contributor Author

eliran-ops commented May 5, 2026

Visual test - Bug 8 verified

Tested locally on gke_koalabackend_us-east1-b_nonprod-cluster-us-east1.

Repro path (the user repro from the PR description):

  1. Navigated to /resources/pods.
  2. Clicked row A (kourier-system/3scale-kourier-gateway-...) - drawer A opened, URL = ?resource=kourier-system%2F....
  3. Clicked row B (dev/app-...) - drawer B opened, URL = ?resource=dev%2Fapp-....
  4. Pressed browser Back - URL reverted to A and the drawer correctly switched back to showing resource A. Pre-fix this would have skipped to the bare list.

State A (after click row A)

state-A

State B (after click row B - drawer-to-drawer switch via push)

state-B

After Back (POP-only URL→drawer sync swaps drawer back to A)

back-to-A

URL on each step matches what the PR describes:

  • After A: ?resource=kourier-system%2F3scale-kourier-gateway-9f4bdf747-tpp4b
  • After B: ?resource=dev%2Fapp-6dfc8f5cbb-k4trj
  • After Back: ?resource=kourier-system%2F3scale-kourier-gateway-9f4bdf747-tpp4b and the drawer heading reads 3scale-kourier-gateway-9f4bdf747-tpp4b.

Bug 11 (Parent Gateways)

verified manually
pare, and the onNavigateToResource push are mechanical changes that follow the same logic verified above.

The post-POP state catch-up was pushing a duplicate history entry on top of
the popped state. After clicking a "Parent Gateways" badge from a TCPRoute
drawer to a Gateway drawer, browser Back appeared to do nothing — or in
clusters where the route's parentRef namespace defaults to a non-existent
gateway, the URL flipped to a sibling resource via auto-resolution.

Root cause: when App's POP→state sync re-set selectedResource/selectedKind to
match the popped URL, the URL writer effect re-fired with the new state. Its
drawerSwitched check (prev != current) was true (gateway -> tcproute), so it
pushed/replaced — but the URL was already correct, so the write was a no-op
on path but still bumped history (or, when the resource didn't exist in the
target list, it wrote whatever the auto-resolver picked).

Guard: before navigating, compare target newPath/queryStr to
window.location's pathname/search. If they already match, return without
calling navigate. Path comparison treats suffix matches as equal so
basename-mounted hosts (Radar Cloud at /c/{cluster}) aren't false-triggered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@eliran-ops
Copy link
Copy Markdown
Contributor Author

@nadaverell ready

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 990e9be. Configure here.

Comment thread web/src/App.tsx
} else if (kindFromPath && !resourceParam) {
setSelectedResource(prev => (prev === null ? prev : null))
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

POP-triggered state change bypasses URL sync guard

Medium Severity

When the App.tsx POP sync updates selectedResource, the ResourcesView URL-write effect doesn't know this was POP-triggered because isSyncingFromURL is never set (it's only set by the locationPathname/locationSearch sync effect, which App.tsx doesn't use). If filter state (e.g. searchTerm) diverged from the popped URL's params, the no-op guard in updateURL fails to match, and drawerSwitched being true causes a push of stale filter state on top of the popped entry — creating the same "back does nothing" history pollution the PR is fixing.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 990e9be. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

False positive — the flag-rAF gate and the no-op guard already cover this.

Trace for the worst case (POP changes both ?resource= and a filter param):

  1. POP changes useLocation().search. Since ?resource= is in search, any POP that causes App to call setSelectedResource (App.tsx#L688-L711) necessarily also changes locationSearch.
  2. ResourcesView render 1: locationSearch prop changed. The locationPathname/Search sync effect (packages/k8s-ui/src/components/resources/ResourcesView.tsx#L2332-L2372) runs first in declaration order, sets isSyncingFromURL.current = true synchronously, calls setSearchTerm/etc, schedules requestAnimationFrame to clear the flag.
  3. App POP-sync queues setSelectedResource. Render 2.
  4. Render 2: URL-write effect (L2467-L2518) runs. isSyncingFromURL.current is still true because rAF runs after paint, and effects run before paint. Early return at L2470. prevSelectedResourceRef is updated to the post-POP selection so future writes compare against the right baseline.
  5. After paint, rAF clears the flag.

No-op guard is the second safety net. Even if the flag missed, L2447-L2456 reads window.location.pathname + window.location.search (post-POP) and compares against the target built from current state. After the locationSearch effect synced filter state to popped values, queryStr matches window.location.search → return without navigating.

There is no scenario in the current code where a POP changes selectedResource while locationSearch stays the same — POP-driven setSelectedResource is gated on navigationType === NavigationType.Pop && mainView === 'resources' (App.tsx#L689) and reads ?resource= straight from the URL it's reconciling against.

Verified: cd web && npm run tsc clean; cd packages/k8s-ui && npm test 128/128 pass on HEAD 990e9be. The end-to-end fix for Bug 11 was visually tested as documented in the PR description. Not changing the code.

@eliran-ops
Copy link
Copy Markdown
Contributor Author

@nadaverell ready

@nadaverell nadaverell merged commit becb871 into main May 10, 2026
8 checks passed
@nadaverell nadaverell deleted the feat/stop-replace-nav-on-resource-and-parent-link-clicks branch May 10, 2026 21:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants