fix(studio): make setRightPanelTab itself flat-aware, not just the direct tab click

Review feedback on #2497 (Rames D Jusso) found a real gap: the exclusivity
this PR introduced only applied to the direct in-panel tab click, which
calls setExclusiveRightInspectorPane. Every OTHER caller that reaches
setRightPanelTab("design"|"layers") — element select (useDomSelection.ts),
closing block-params (App.tsx), the header Inspector button
(StudioHeader.tsx), and even this PR's own "!inspectorTabActive" entry
branch in handleInspectorPaneButtonClick — went through
trackedSetRightPanelTab's old unconditional additive
`{...panes, [tab]: true}`, reproducing the exact "both tabs highlight, only
one renders" bug this PR claims to fix. Confirmed via the reviewer's traced
repro: fresh boot, click Layers tab while no inspector tab is yet active →
rightInspectorPanes ends up {design:true, layers:true}.

Fixed at the reviewer's preferred choke point: trackedSetRightPanelTab
itself is now flat-aware, applying the same exclusive-radio update
setExclusiveRightInspectorPane does whenever STUDIO_FLAT_INSPECTOR_ENABLED
is on, falling back to the legacy additive update otherwise. This closes
the gap for every current and future caller of setRightPanelTab, not just
the one call site this PR touched.

New usePanelLayout.test.ts cases pin both directions: setRightPanelTab
stays additive under flat=off (legacy split-view behavior unchanged), and
enforces exclusivity under flat=on even when called directly (not through
the tab-click handler) — using the vi.doMock(manualEditingAvailability)
pattern already established in PropertyPanel.test.tsx for flag-dependent
module state.

Full studio suite (2643 tests) green; typecheck/oxlint/oxfmt clean.
This commit is contained in:
Vance Ingalls
2026-07-15 20:53:47 -07:00
parent 79b688f204
commit fb24ecac22
2 changed files with 66 additions and 4 deletions
@@ -2,23 +2,25 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePanelLayout } from "./usePanelLayout";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
vi.doUnmock("../components/editor/manualEditingAvailability");
vi.resetModules();
});
function renderPanelLayout() {
function renderPanelLayoutWith(hook: typeof usePanelLayout) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
let current: ReturnType<typeof usePanelLayout> | null = null;
function Harness() {
current = usePanelLayout();
current = hook();
return null;
}
@@ -35,6 +37,10 @@ function renderPanelLayout() {
};
}
function renderPanelLayout() {
return renderPanelLayoutWith(usePanelLayout);
}
describe("usePanelLayout — right inspector panes", () => {
it("toggleRightInspectorPane independently flips one pane, allowing both open at once", () => {
const harness = renderPanelLayout();
@@ -67,4 +73,46 @@ describe("usePanelLayout — right inspector panes", () => {
harness.unmount();
});
it("setRightPanelTab additively opens a pane when the flat inspector is off (legacy behavior)", async () => {
vi.resetModules();
vi.doMock("../components/editor/manualEditingAvailability", async () => {
const actual = await vi.importActual<
typeof import("../components/editor/manualEditingAvailability")
>("../components/editor/manualEditingAvailability");
return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: false };
});
const { usePanelLayout: usePanelLayoutFlatOff } = await import("./usePanelLayout");
const harness = renderPanelLayoutWith(usePanelLayoutFlatOff);
expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
act(() => harness.getState().setRightPanelTab("layers"));
// Legacy (split-view) behavior: additive, both panes end up open.
expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: true });
harness.unmount();
});
it("setRightPanelTab is flat-aware: exclusivity holds for callers other than a direct in-panel tab click", async () => {
vi.resetModules();
vi.doMock("../components/editor/manualEditingAvailability", async () => {
const actual = await vi.importActual<
typeof import("../components/editor/manualEditingAvailability")
>("../components/editor/manualEditingAvailability");
return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: true };
});
const { usePanelLayout: usePanelLayoutFlatOn } = await import("./usePanelLayout");
const harness = renderPanelLayoutWith(usePanelLayoutFlatOn);
expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
// Element-select / block-params-close / header Inspector-button callers
// all reach setRightPanelTab directly, not through the in-panel tab
// click's own setExclusiveRightInspectorPane call — this must still
// enforce exclusivity under the flat flag, or both tabs end up
// highlighted while only one pane actually renders.
act(() => harness.getState().setRightPanelTab("layers"));
expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: false });
harness.unmount();
});
});
+15 -1
View File
@@ -6,6 +6,7 @@ import type {
} from "../utils/studioHelpers";
import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { STUDIO_FLAT_INSPECTOR_ENABLED } from "../components/editor/manualEditingAvailability";
export interface InitialPanelLayoutState {
rightCollapsed?: boolean | null;
@@ -80,7 +81,20 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
const trackedSetRightPanelTab = useCallback(
(tab: RightPanelTab) => {
if (tab === "design" || tab === "layers") {
setRightInspectorPanes((panes) => ({ ...panes, [tab]: true }));
// Flat inspector: Layers always renders full-height by itself (see
// StudioRightPanel's render gate), so this MUST land on the same
// radio-style exclusivity setExclusiveRightInspectorPane enforces for
// the direct in-panel tab click — every OTHER path that reaches here
// (element select, closing block-params, the header Inspector
// button, and this function's own callers outside an active
// inspector tab) would otherwise additively leave both panes `true`
// and reproduce the "both tabs highlight, only one renders" bug this
// still-additive branch used to cause under the flat flag.
setRightInspectorPanes(
STUDIO_FLAT_INSPECTOR_ENABLED
? { design: tab === "design", layers: tab === "layers" }
: (panes) => ({ ...panes, [tab]: true }),
);
}
setRightPanelTab(tab);
trackStudioEvent("tab_switch", { panel: "right_panel", tab });