Skip to content

fix(studio): stop side panels closing twice after react-router v8 - #1731

Open
aray12 wants to merge 3 commits into
mainfrom
side-panel-flushsync-double-close/alray
Open

fix(studio): stop side panels closing twice after react-router v8#1731
aray12 wants to merge 3 commits into
mainfrom
side-panel-flushsync-double-close/alray

Conversation

@aray12

@aray12 aray12 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Side panels close twice: the panel animates out, snaps back open, then animates out again — visible as a flash. This regressed with the react-router v6 → v8 upgrade (a6904bd). Passing flushSync: true on the navigations that close these panels restores the pre-upgrade commit ordering, and switching RouterProvider to the react-router/dom entry point makes that option actually take effect.

Before

Screen.Recording.2026-09-02.at.1.58.06.PM.mov

After

Screen.Recording.2026-09-02.at.2.00.03.PM.mov

Changes

Root cause. Two deferrals stack on the close path:

  1. KUI's SidePanel passes animateOutDuration = 200 into useDialog, so onOpenChange(false) — and therefore the consumer's navigate() — fires from inside a setTimeout after the exit animation.
  2. react-router@8.3.0 wraps every router state update in React.startTransition by default (lib/components.js:348). v6 was synchronous.

React flushes the urgent setIsClosing(false) first, producing an intermediate commit where the open prop is still true but the native <dialog> is already closed. useDialog's layout effect takes its if (isOpen) branch and calls showNativeDialog(), re-opening the panel; when the transition finally lands, it closes a second time.

Fixes

  • App.tsx — import RouterProvider from react-router/dom, keeping createBrowserRouter on react-router. Only the DOM variant injects ReactDOM.flushSync. a6904bd83 moved both onto the bare entry point during the v8 upgrade, which silently turned the flushSync navigate option into a no-op that only logs a warning. This is a prerequisite for everything below.
  • DeploymentsListRouteflushSync: true on the details-panel close and on the post-delete close.
  • DeploymentsListRouteflushSync: true on the create-panel deep-link param cleanup. Same shape via setSearchParams: a deferred param clear let the createPrefill effect observe stale params and reopen the panel.
  • FilesetListRoute/PanelManagementflushSync: true on the dataset-panel close, file-panel close, and file-panel outside-click. The existing mirrored open flags are kept; the comment explaining them is corrected to say the two mechanisms are complementary.
  • MetricRunSidePanel — same treatment on its post-submit navigation. This component currently has no consumer outside its own directory and stories, so nothing exercises it; hardened so it is correct when wired up.
  • tests/util/render.tsx — shared harness uses the react-router/dom RouterProvider, so tests can exercise the flushed path. Note this only applies to the createMemoryRouter branch; the declarative MemoryRouter fallback ignores flushSync entirely.
  • FilesetNewRouteflushSync: true on the close navigation. This is the create-fileset panel, and the most pronounced instance: the route is the panel and is mounted with a literal open, so the prop can never go false and the navigation unmounting the route is the only thing that closes it. The re-show is therefore guaranteed rather than merely likely, and the panel flashes back up until the transition lands.
  • New regression tests at routes/DeploymentsListRoute/index.test.tsx and in routes/FilesetNewRoute/index.test.tsx.

Navigations that move between panel states rather than closing to a panel-less route were deliberately left alone, as were panels whose open is plain local state.

The flushSync: true options carry no explainer comments — the rationale lives here and in the commit history, with the regression tests as the executable record. One line is kept in the test harness: that the declarative MemoryRouter fallback silently ignores flushSync is not inferable from the code, and a test written against that path would pass vacuously.

Sweep coverage. The first pass looked for open={...} expressions derived from route params, which does not match a bare open attribute — that is how FilesetNewRoute was missed initially. Re-swept for the bare-open form: three other panels use it (SafeSynthesizerFilesetPreview/FilePreview, customizer/CustomizationFilesetSelect, PromptTuningForm/InContextLearningSection), but all three close via local setState, which is urgent and leaves no window for the re-show.

Type of Change

  • Code change (feature, bug fix, or refactor)
  • Code change with documentation updates
  • Documentation only
  • Contributor tooling or automation
  • CI, build, or test infrastructure

Quality Gates

  • Tests added or updated for changed behavior
  • Existing tests cover changed behavior — justification:
  • Tests not applicable — justification:
  • Documentation updated for user-visible behavior
  • Documentation not applicable — justification: internal bug fix with no user-facing documented behavior change; the reasoning is captured in code comments at each call site.

Verification

  • Pull request title follows the repository's Conventional Commit format
  • Every commit includes an appropriate Signed-off-by: trailer
  • uv run pre-commit run -a passes, or any blocked checks are identified below
  • Targeted tests pass, or tests are marked not applicable above
  • No secrets, API keys, or credentials are included

Targeted validation:

Command Result
pnpm --filter nemo-studio-ui typecheck pass
pnpm --filter nemo-studio-ui lint (--max-warnings 0) pass
pnpm --filter nemo-studio-ui format pass
pnpm --filter nemo-studio-ui test (full suite) 3 pre-existing flakes, see below
pnpm --filter nemo-studio-ui test src/routes/DeploymentsListRoute src/routes/FilesetListRoute pass — 7 files, 62 tests
pnpm --filter nemo-studio-ui test src/routes/FilesetNewRoute pass — 23 tests
uv run pre-commit run -a 2 environment-only failures, see below

Full-suite flakes. Two runs of the full suite on an identical tree produced different failure sets (4 files / 49 skipped, then 2 files / 0 skipped), and an earlier run of the same tree was fully green. The affected tests — components/Layouts/GlobalNav and routes/SafeSynthesizerNewRoute — pass in isolation, take ~11.5s when they fail (timeouts under load), build their own routers directly from react-router, and do not touch the shared test harness or any route changed here. Treating these as pre-existing load flakiness rather than a regression; CI is the arbiter.

Red/green check on the regression tests. With flushSync: true reverted from handleCloseDetailsPanel, the deployment test fails with expected "showModal" to not be called at all, but actually been called 1 times — exactly the predicted spurious re-show. It passes with the fix restored. The FilesetNewRoute test behaves identically — one spurious re-show without the fix, none with it. An earlier MutationObserver-based version of these tests passed against both the fixed and broken code; the shared test setup (packages/testing/src/react/setup.ts:50) stubs MutationObserver to a no-op suite-wide, so the test now spies on HTMLDialogElement.prototype.showModal instead. Worth knowing more broadly: that stub also makes KUI's own top-layer-heal MutationObserver inert under test.

uv run pre-commit run -a — two failures, both environment-only and unrelated to this change:

  • Helm Docshelm-docs is not installed locally. This PR changes no Helm files.
  • Run uv lock with platform uv — local uv is 0.9.28, the hook requires 0.9.14. This PR changes no pyproject.toml; the separate Check for uv.lock drift hook passed.

Both hooks reported (no files to check) Skipped on the actual commit, confirming they do not apply here. Every other hook passed.

Not verified. The FilesetNewRoute flash was reported from manual browser use and the fix is verified by test, not yet re-confirmed in a browser. Regression tests cover the deployment details panel's close button and the create-fileset panel's close button; Escape, outside click, the fileset list panels, the create-deployment deep link, and the focus-restoration behavior remain unverified. MetricRunSidePanel is unverifiable by construction since it has no consumer.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed deployment, dataset, and file details panels reopening or remaining visible after being closed.
    • Improved panel closing during animated transitions and deep-linked navigation.
    • Synchronized URL updates with panel visibility when closing panels or clearing deployment creation links.
    • Fixed navigation to metric evaluation results while closing the creation panel.
    • Prevented the fileset creation panel from reopening or triggering duplicate navigation after dismissal.

URL-driven side panels animate out, snap back open, then animate out
again. Two deferrals stack on the close path.

KUI's SidePanel passes animateOutDuration=200 into useDialog, so
onOpenChange(false) - and therefore the consumer's navigate() - fires
from inside a setTimeout after the exit animation. react-router 8 then
wraps that location update in React.startTransition by default, which
v6 did not do.

React flushes the urgent setIsClosing(false) first, producing a commit
where the open prop is still true but the native <dialog> is already
closed. useDialog's layout effect takes its `if (isOpen)` branch and
calls showNativeDialog(), re-opening the panel; when the transition
finally lands it closes a second time.

Pass flushSync: true on the navigations that close a URL-driven panel so
the param clear commits alongside the dialog close. This requires
RouterProvider from react-router/dom - only that variant injects
ReactDOM.flushSync. a6904bd moved both createBrowserRouter and
RouterProvider onto the bare react-router entry point during the v8
upgrade, which silently made the flushSync navigate option a no-op that
only logs a warning.

Also covers the create-deployment panel, where a deferred setSearchParams
let the createPrefill effect observe stale params and reopen the panel.

The regression test asserts showModal() is not called after dismissal;
it fails without the fix with exactly one spurious re-show. It spies on
showModal rather than watching data-state because the shared test setup
stubs MutationObserver to a no-op suite-wide.

Signed-off-by: Alex Ray <alray@nvidia.com>
@aray12
aray12 requested review from a team as code owners September 2, 2026 19:15
@github-actions github-actions Bot added the fix label Sep 2, 2026
@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: e735ca97-3793-427d-9353-077858aeb67e

📥 Commits

Reviewing files that changed from the base of the PR and between 34478fd and 4730a76.

📒 Files selected for processing (6)
  • web/packages/studio/src/App.tsx
  • web/packages/studio/src/components/sidePanels/MetricRunSidePanel/index.tsx
  • web/packages/studio/src/routes/DeploymentsListRoute/index.tsx
  • web/packages/studio/src/routes/FilesetListRoute/PanelManagement/index.tsx
  • web/packages/studio/src/routes/FilesetNewRoute/index.tsx
  • web/packages/studio/src/tests/util/render.tsx
💤 Files with no reviewable changes (4)
  • web/packages/studio/src/components/sidePanels/MetricRunSidePanel/index.tsx
  • web/packages/studio/src/App.tsx
  • web/packages/studio/src/routes/FilesetNewRoute/index.tsx
  • web/packages/studio/src/routes/DeploymentsListRoute/index.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • web/packages/studio/src/tests/util/render.tsx
  • web/packages/studio/src/routes/FilesetListRoute/PanelManagement/index.tsx

Included review availability: Your plan provides up to 12 included reviews per hour; 10 remain after this review.


📝 Walkthrough

Walkthrough

The change enables DOM-backed flushSync support and applies synchronous navigation to side-panel close, submission, deletion, and deep-link cleanup flows. Regression tests verify that dismissed panels do not reopen.

Changes

Side-panel navigation

Layer / File(s) Summary
Router provider support
web/packages/studio/src/App.tsx, web/packages/studio/src/tests/util/render.tsx
RouterProvider now comes from react-router/dom. Test utilities document which router branch honors flushSync.
Synchronous panel navigation
web/packages/studio/src/components/sidePanels/MetricRunSidePanel/index.tsx, web/packages/studio/src/routes/DeploymentsListRoute/index.tsx, web/packages/studio/src/routes/FilesetListRoute/PanelManagement/index.tsx, web/packages/studio/src/routes/FilesetNewRoute/index.tsx
Panel close, submission, deletion, outside-click, and deep-link cleanup navigations now use flushSync: true.
Panel close regression coverage
web/packages/studio/src/routes/DeploymentsListRoute/index.test.tsx, web/packages/studio/src/routes/FilesetNewRoute/index.test.tsx
Adds asynchronous tests that verify dismissed deployment and fileset panels do not reopen during animation and deferred updates.

Merge Risk: ⚪ Minimal · up to 4730a

This PR applies localized navigation fixes with targeted validation, and no actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 8…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing Studio side panels from closing twice after the React Router v8 upgrade.
Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 8 files.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch side-panel-flushsync-double-close/alray

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor
Suite Lines Covered Line Rate Branch Rate
Unit Tests 38443/49020 78.4% 62.5%
Integration Tests 23134/46258 50.0% 22.7%

FilesetNewRoute is a whole route rendered as a side panel, mounted with a
literal `open` rather than a state-derived one. The prop can never go
false, so the navigation that unmounts the route is the only thing that
closes the panel.

That makes it the most pronounced instance of the same stale-prop race:
KUI's useDialog emits onOpenChange from inside its 200ms animate-out
timeout, react-router v8 defers the resulting navigation into a
transition, and on the urgent commit the layout effect sees a permanently
true `open` against an already-closed <dialog> and calls
showNativeDialog(). The panel flashes back up and only disappears when
the transition finally unmounts the route.

The earlier sweep missed this because it looked for `open={...}`
expressions derived from route params; a bare `open` attribute does not
match that shape. Re-swept for the bare-`open` form: three other panels
use it, but all three close via local setState, which is urgent and
leaves no window for the re-show.

The regression test asserts showModal() is not called after dismissal and
fails without the fix with exactly one spurious re-show. It restores the
real router hooks, since the existing suite in this file mocks
useNavigate and so never runs a genuine navigation.

Signed-off-by: Alex Ray <alray@nvidia.com>
The navigate options read clearly enough on their own, and the rationale
lives in the commit history and the regression tests. Restores the
pre-existing comment on PanelManagement's open flags verbatim, so that
file is now a pure three-line change.

Keeps one line in the test harness: that the declarative MemoryRouter
fallback silently ignores flushSync is not inferable from the code, and a
test written against that path would pass vacuously.

Signed-off-by: Alex Ray <alray@nvidia.com>

@marcusds marcusds left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Image

@aray12
aray12 added this pull request to the merge queue Sep 3, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants