From 00d187ea748eb115d60b19dd7a2d9cd1800d40cc Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 2 Oct 2026 16:31:44 +0200 Subject: [PATCH 1/4] feat(devtools): add hideInAutomation to keep devtools out of e2e tests With plugins open by default, end-to-end tests that select elements by id or text also matched the devtools panels. The workarounds were turning off defaultOpen for every plugin or hiding #tanstack_devtools with CSS. `hideInAutomation: true` skips the devtools DOM when navigator.webdriver is true, which Playwright, Cypress, and Selenium set. Local development does not change. The default is false, so suites that test the devtools themselves keep working. Fixes #318 --- .changeset/hide-in-automation.md | 5 ++ docs/configuration.md | 6 ++ e2e/apps/react-vite/src/main.tsx | 3 + .../tests/hide-in-automation.spec.ts | 24 ++++++ .../devtools/src/context/devtools-store.ts | 8 ++ packages/devtools/src/devtools.tsx | 83 ++++++++++--------- packages/devtools/tests/workbench.test.tsx | 30 +++++++ 7 files changed, 119 insertions(+), 40 deletions(-) create mode 100644 .changeset/hide-in-automation.md create mode 100644 e2e/apps/react-vite/tests/hide-in-automation.spec.ts diff --git a/.changeset/hide-in-automation.md b/.changeset/hide-in-automation.md new file mode 100644 index 000000000..1baa8a3bd --- /dev/null +++ b/.changeset/hide-in-automation.md @@ -0,0 +1,5 @@ +--- +'@tanstack/devtools': minor +--- + +Add the `hideInAutomation` config option. When it is `true`, the devtools do not render in a browser that automation drives (Playwright, Cypress, Selenium), detected by `navigator.webdriver`. This keeps the devtools out of end-to-end test selectors. The default is `false`. diff --git a/docs/configuration.md b/docs/configuration.md index a8fe98fff..6240d6162 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -71,6 +71,12 @@ type KeyboardKey = ModifierKey | (string & {}); { requireUrlFlag: boolean } ``` +- `hideInAutomation` - Does not render the devtools when a browser is driven by automation (Playwright, Cypress, Selenium). This keeps the devtools out of your end-to-end test selectors. The check uses `navigator.webdriver`. The default is `false`. + +```ts +{ hideInAutomation: boolean } +``` + - `triggerImage` - The image used for the dev tools trigger ```ts diff --git a/e2e/apps/react-vite/src/main.tsx b/e2e/apps/react-vite/src/main.tsx index e30abcda6..d7a3c0a84 100644 --- a/e2e/apps/react-vite/src/main.tsx +++ b/e2e/apps/react-vite/src/main.tsx @@ -16,6 +16,9 @@ function App() { config={{ theme: 'dark', requireUrlFlag: new URLSearchParams(location.search).has('gated'), + hideInAutomation: new URLSearchParams(location.search).has( + 'automation-hidden', + ), }} plugins={[ { diff --git a/e2e/apps/react-vite/tests/hide-in-automation.spec.ts b/e2e/apps/react-vite/tests/hide-in-automation.spec.ts new file mode 100644 index 000000000..b8091d587 --- /dev/null +++ b/e2e/apps/react-vite/tests/hide-in-automation.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from '@playwright/test' +import { DevtoolsPage } from '@tanstack/devtools-e2e' + +// Playwright sets navigator.webdriver to true. +test.describe('hideInAutomation', () => { + test('set → devtools not rendered', async ({ page }) => { + const dt = new DevtoolsPage(page) + await dt.goto('/?automation-hidden') + // Devtools set the theme on when they mount, also when hidden, so + // the check below runs after the mount and not before it. + await expect(page.locator('html')).toHaveAttribute( + 'data-tanstack-devtools-theme', + 'dark', + ) + await expect(page.getByTestId('tanstack_devtools')).toHaveCount(0) + await expect(dt.trigger()).toHaveCount(0) + }) + + test('not set → trigger visible', async ({ page }) => { + const dt = new DevtoolsPage(page) + await dt.goto('/') + await expect(dt.trigger()).toBeVisible() + }) +}) diff --git a/packages/devtools/src/context/devtools-store.ts b/packages/devtools/src/context/devtools-store.ts index 3aefe9966..abe16070a 100644 --- a/packages/devtools/src/context/devtools-store.ts +++ b/packages/devtools/src/context/devtools-store.ts @@ -110,6 +110,13 @@ export type DevtoolsStore = { * @default "tanstack-devtools" */ urlFlag: string + /** + * Whether to not render the dev tools in a browser driven by automation + * (Playwright, Cypress, Selenium), detected by `navigator.webdriver`. + * Keeps the dev tools out of end-to-end test selectors. + * @default false + */ + hideInAutomation: boolean /** * The theme of the dev tools * @default "dark" @@ -196,6 +203,7 @@ export const initialState: DevtoolsStore = { inspectHotkey: ['Shift', 'Alt', 'CtrlOrMeta'], requireUrlFlag: false, urlFlag: 'tanstack-devtools', + hideInAutomation: false, theme: typeof window !== 'undefined' && typeof window.matchMedia !== 'undefined' && diff --git a/packages/devtools/src/devtools.tsx b/packages/devtools/src/devtools.tsx index 54f01c6f7..21b215fed 100644 --- a/packages/devtools/src/devtools.tsx +++ b/packages/devtools/src/devtools.tsx @@ -182,48 +182,51 @@ export default function DevTools() { return ( - -
- - - + +
+ - (panelRef = ref)} - handleDragStart={(event) => handleDragStart(panelRef, event)} - handleHeightChange={updateHeight} + + - - {/* Stays mounted while folded so it can slide shut. */} - - - - - - - - -
-
+ (panelRef = ref)} + handleDragStart={(event) => handleDragStart(panelRef, event)} + handleHeightChange={updateHeight} + > + + {/* Stays mounted while folded so it can slide shut. */} + + + + + +
+
+ +
+
+
) } diff --git a/packages/devtools/tests/workbench.test.tsx b/packages/devtools/tests/workbench.test.tsx index 915cf1b79..a92eaa399 100644 --- a/packages/devtools/tests/workbench.test.tsx +++ b/packages/devtools/tests/workbench.test.tsx @@ -203,6 +203,36 @@ afterEach(() => { }) describe('workbench', { timeout: 30_000 }, () => { + describe('hideInAutomation', () => { + const devtoolsRoot = () => + document.querySelector('[data-testid="tanstack_devtools"]') + + beforeEach(() => { + // Playwright, Cypress, and Selenium set navigator.webdriver to true. + Object.defineProperty(navigator, 'webdriver', { + configurable: true, + value: true, + }) + }) + + afterEach(() => { + delete (navigator as { webdriver?: boolean }).webdriver + }) + + it('does not render in an automated browser when hideInAutomation is set', () => { + mountWorkbench([plugin('one')], { hideInAutomation: true }) + + expect(devtoolsRoot()).toBeNull() + expect(events).toEqual([]) + }) + + it('still renders in an automated browser by default', () => { + mountWorkbench([plugin('one')]) + + expect(devtoolsRoot()?.childElementCount).toBeGreaterThan(0) + }) + }) + it('keeps the secondary Plugins bar fixed-height without hover timers', () => { vi.useFakeTimers() expect({ From e36e7314cf29ea0ca7ad498c0b7da2567e0f1fc0 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 2 Oct 2026 17:39:20 +0200 Subject: [PATCH 2/4] fix(devtools): ignore the open hotkey while hidden in automation With hideInAutomation the devtools render nothing, but the open hotkey still called toggleOpen(). That saved persistOpen and emitted trigger-toggled for devtools that do not exist. The hotkey now uses the same check as the render. --- packages/devtools/src/devtools.tsx | 9 ++++++--- packages/devtools/tests/workbench.test.tsx | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/devtools/src/devtools.tsx b/packages/devtools/src/devtools.tsx index 21b215fed..1d0da2f11 100644 --- a/packages/devtools/src/devtools.tsx +++ b/packages/devtools/src/devtools.tsx @@ -77,6 +77,9 @@ export default function DevTools() { const { isCollapsed } = createCollapsed() const [showMarketplace, setShowMarketplace] = createSignal(false) const themeOwner = Symbol('tanstack-devtools-theme') + // Playwright, Cypress, and Selenium set navigator.webdriver. + const hiddenInAutomation = () => + settings().hideInAutomation && navigator.webdriver const updateHeight = (nextHeight: number) => { setHeight(nextHeight) @@ -168,7 +171,8 @@ export default function DevTools() { } for (const permutation of getHotkeyPermutations(settings().openHotkey)) { createShortcut(permutation, () => { - if (!isEditableTarget(document.activeElement)) toggleOpen() + if (!hiddenInAutomation() && !isEditableTarget(document.activeElement)) + toggleOpen() }) } }) @@ -182,8 +186,7 @@ export default function DevTools() { return ( - {/* Playwright, Cypress, and Selenium set navigator.webdriver. */} - +
{ expect(events).toEqual([]) }) + it('ignores the open hotkey while hidden in an automated browser', () => { + mountWorkbench([plugin('one')], { + hideInAutomation: true, + defaultOpen: false, + }) + + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: '~', + ctrlKey: true, + bubbles: true, + }), + ) + + const stored = JSON.parse( + localStorage.getItem(TANSTACK_DEVTOOLS_STATE) ?? '{}', + ) + expect(stored.persistOpen).not.toBe(true) + }) + it('still renders in an automated browser by default', () => { mountWorkbench([plugin('one')]) From f0b816ac8f8b3cbda68ae0421c24c2a72d7ea25a Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 29 Oct 2026 16:31:18 +0100 Subject: [PATCH 3/4] chore: raise the devtools size limit to 67 KB --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a0be938b2..9336392f6 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "size-limit": [ { "path": "packages/devtools/dist/index.js", - "limit": "66 KB" + "limit": "67 KB" }, { "path": "packages/event-bus-client/dist/esm/plugin.js", From d483c0b81ffa8a381349102d16e9c618b980f580 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 29 Oct 2026 16:43:44 +0100 Subject: [PATCH 4/4] fix(devtools): resolve docs merge, let config win for hideInAutomation, ignore Escape while hidden --- docs/configuration.md | 5 ++-- .../devtools/src/context/devtools-context.tsx | 3 ++ packages/devtools/src/devtools.tsx | 3 +- packages/devtools/tests/workbench.test.tsx | 28 ++++++++++++++++++- 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index cd8551cb1..041a1d31d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -71,17 +71,16 @@ type KeyboardKey = ModifierKey | (string & {}); { requireUrlFlag: boolean } ``` -<<<<<<< HEAD - `hideInAutomation` - Does not render the devtools when a browser is driven by automation (Playwright, Cypress, Selenium). This keeps the devtools out of your end-to-end test selectors. The check uses `navigator.webdriver`. The default is `false`. ```ts { hideInAutomation: boolean } -======= +``` + - `openAsModal` - Moves the open panel on top of a modal dialog of your app. A dialog that you open with `dialog.showModal()` makes the rest of the page inert, the devtools included. While the panel is on top, your dialog is inert. Close the panel to use your dialog again. The trigger is inert while your dialog is open, so open the panel with the open hotkey. The default is `false`. ```ts { openAsModal: boolean } ->>>>>>> origin/main ``` - `triggerImage` - The image used for the dev tools trigger diff --git a/packages/devtools/src/context/devtools-context.tsx b/packages/devtools/src/context/devtools-context.tsx index 2de2c92fb..f89c4b7bf 100644 --- a/packages/devtools/src/context/devtools-context.tsx +++ b/packages/devtools/src/context/devtools-context.tsx @@ -204,6 +204,9 @@ export const getExistingStateFromStorage = ( ...initialState.settings, ...config, ...settings, + // Not in the settings tab, so the config wins over a stored value. + hideInAutomation: + config?.hideInAutomation ?? initialState.settings.hideInAutomation, }, } return state diff --git a/packages/devtools/src/devtools.tsx b/packages/devtools/src/devtools.tsx index 6e2ff487a..a838922a0 100644 --- a/packages/devtools/src/devtools.tsx +++ b/packages/devtools/src/devtools.tsx @@ -148,7 +148,8 @@ export default function DevTools() { createEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape' && isOpen()) toggleOpen() + if (event.key === 'Escape' && isOpen() && !hiddenInAutomation()) + toggleOpen() } window.addEventListener('keydown', onKeyDown) onCleanup(() => window.removeEventListener('keydown', onKeyDown)) diff --git a/packages/devtools/tests/workbench.test.tsx b/packages/devtools/tests/workbench.test.tsx index f7a8334db..578cfe5a8 100644 --- a/packages/devtools/tests/workbench.test.tsx +++ b/packages/devtools/tests/workbench.test.tsx @@ -6,7 +6,10 @@ import { DevtoolsProvider } from '../src/context/devtools-context' import { PiPProvider } from '../src/context/pip-context' import { PluginSectionComponent } from '../src/tabs/marketplace/plugin-section' import { flattenTabs } from '../src/utils/layout-tree' -import { TANSTACK_DEVTOOLS_STATE } from '../src/utils/storage' +import { + TANSTACK_DEVTOOLS_SETTINGS, + TANSTACK_DEVTOOLS_STATE, +} from '../src/utils/storage' import { DEVTOOLS_FONT_STYLE_ID, DEVTOOLS_FORCED_COLORS_STYLE_ID, @@ -246,6 +249,29 @@ describe('workbench', { timeout: 30_000 }, () => { expect(stored.persistOpen).not.toBe(true) }) + it('uses the config over a stored hideInAutomation value', () => { + localStorage.setItem( + TANSTACK_DEVTOOLS_SETTINGS, + JSON.stringify({ hideInAutomation: false }), + ) + mountWorkbench([plugin('one')], { hideInAutomation: true }) + + expect(devtoolsRoot()).toBeNull() + }) + + it('ignores Escape while hidden in an automated browser', () => { + localStorage.setItem( + TANSTACK_DEVTOOLS_STATE, + JSON.stringify({ persistOpen: true }), + ) + mountWorkbench([plugin('one')], { hideInAutomation: true }) + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + + const stored = JSON.parse(localStorage.getItem(TANSTACK_DEVTOOLS_STATE)!) + expect(stored.persistOpen).toBe(true) + }) + it('still renders in an automated browser by default', () => { mountWorkbench([plugin('one')])