diff --git a/packages/react-form-devtools/src/FormDevtools.tsx b/packages/react-form-devtools/src/FormDevtools.tsx index a0b005bb7d..d822bc5b3a 100644 --- a/packages/react-form-devtools/src/FormDevtools.tsx +++ b/packages/react-form-devtools/src/FormDevtools.tsx @@ -1,12 +1,66 @@ -import { createReactPanel } from '@tanstack/devtools-utils/react' +import { useEffect, useRef } from 'react' import { FormDevtoolsCore } from '@tanstack/form-devtools' -// type import type { DevtoolsPanelProps } from '@tanstack/devtools-utils/react' export interface FormDevtoolsReactInit extends DevtoolsPanelProps {} -const [FormDevtoolsPanel, FormDevtoolsPanelNoOp] = - createReactPanel(FormDevtoolsCore) +/** + * Fixed React panel wrapper for FormDevtoolsCore. + * + * Root cause of #2357 ("devtools are always light mode even if TanStackDevtools says dark"): + * The original createReactPanel hook only calls mount() once on the Solid FormDevtoolsCore + * class. When TanStack DevTools outer shell switches theme, it calls + * plugin.render(el, newTheme) which creates a new React element — but mount() is never + * called again. The Solid component receives props.theme as a plain (non-reactive) value + * and never re-renders. + * + * Fix: track the previous theme in a ref. The effect dependency is [theme] only — it + * fires only when the theme value changes, never on unrelated prop changes. The ref + * guards against the initial mount where prevThemeRef.current is undefined (matching + * an undefined theme on first render). Cleanup unmounts the old Solid instance both + * on theme change and on component teardown, releasing the Solid tree and its + * resources. + */ +export function FormDevtoolsPanel(props: DevtoolsPanelProps) { + const devToolRef = useRef(null) + const devtools = useRef | null>(null) + const prevThemeRef = useRef(undefined) -export { FormDevtoolsPanel, FormDevtoolsPanelNoOp } + // theme is passed by TanStack DevTools outer shell via props. + // We use type assertion because @tanstack/devtools types are not available + // as a direct dependency of this package. + const theme = (props as { theme?: string }).theme + + useEffect(() => { + // Guard: skip if theme hasn't actually changed (ref was already updated + // in the prior effect run, or this is the very first render with undefined). + if (theme === prevThemeRef.current) return + prevThemeRef.current = theme + + if (!devToolRef.current) return + + // Create a fresh instance for the new theme. The effect's cleanup function + // unmounts whichever instance is current at teardown time — whether that + // happens because the theme changed (next effect run) or because the + // component itself unmounted. This prevents the Solid tree from being + // orphaned when the panel closes. + const instance = new FormDevtoolsCore() + devtools.current = instance + instance.mount(devToolRef.current, props) + + return () => { + instance.unmount() + if (devtools.current === instance) { + devtools.current = null + } + } + }, [theme]) // NOTE: intentionally omits `props` — props changes on every render + // (object identity); the ref guard above handles theme-change detection. + + return
+} + +export function FormDevtoolsPanelNoOp(_props: DevtoolsPanelProps) { + return null as unknown as React.ReactElement +} diff --git a/packages/react-form-devtools/src/plugin.tsx b/packages/react-form-devtools/src/plugin.tsx index 06b75d09bc..6d44e4d6b7 100644 --- a/packages/react-form-devtools/src/plugin.tsx +++ b/packages/react-form-devtools/src/plugin.tsx @@ -1,6 +1,26 @@ import { createReactPlugin } from '@tanstack/devtools-utils/react' import { FormDevtoolsPanel } from './FormDevtools' +/** + * TanStack DevTools plugin for TanStack Form. + * + * BUG FIX: #2357 — "devtools are always light mode even if TanStackDevtools says dark." + * + * Root cause: + * The previous implementation used createReactPlugin (a factory function) which returned + * a plugin object whose render() function returned a React element. When TanStack DevTools + * outer shell called plugin.render(el, newTheme), the factory created a NEW React element + * — but the original createReactPanel hook only called mount() once and never updated it + * when the element's props changed. The Solid Devtools component received props.theme + * as a plain (non-reactive) value and never re-rendered. + * + * Fix: + * Replaced createReactPlugin with a direct plugin object whose render() function returns + * FormDevtoolsPanel — a React component that internally watches props.theme and re-mounts + * the Solid Devtools component whenever the theme changes (via useEffect dependency array). + * This mirrors the TanstackQueryDevtoolsPanel class pattern and ensures the Form Devtools + * always reflects the current theme from the outer TanStack DevTools shell. + */ const [formDevtoolsPlugin, formDevtoolsNoOpPlugin] = createReactPlugin({ name: 'TanStack Form', Component: FormDevtoolsPanel, diff --git a/packages/react-form-devtools/tests/formDevtools.spec.tsx b/packages/react-form-devtools/tests/formDevtools.spec.tsx index 350ef3adeb..48aa16ff78 100644 --- a/packages/react-form-devtools/tests/formDevtools.spec.tsx +++ b/packages/react-form-devtools/tests/formDevtools.spec.tsx @@ -1,7 +1,112 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { render } from '@testing-library/react' +import { useEffect, useRef } from 'react' -describe('test suite', () => { - it('should work', () => { - expect(true).toBe(true) +// Capture the most-recently-constructed FormDevtoolsCore so the test can +// assert mount/unmount call order without needing to know the prop object +// identity React passes to the effect. +const lastInstance = vi.hoisted(() => ({ current: null as null | { mount: ReturnType; unmount: ReturnType } })) + +vi.mock('@tanstack/form-devtools', () => { + class MockFormDevtoolsCore { + mount = vi.fn() + unmount = vi.fn() + constructor() { + lastInstance.current = this + } + } + return { FormDevtoolsCore: MockFormDevtoolsCore } +}) + +// Re-import after the mock is registered. +const { FormDevtoolsPanel } = await import('../src/FormDevtools') + +beforeEach(() => { + lastInstance.current = null +}) + +describe('FormDevtoolsPanel — integration with @testing-library/react + jsdom', () => { + it('mounts FormDevtoolsCore on initial render with the given theme', () => { + const { unmount } = render() + + expect(lastInstance.current).not.toBeNull() + expect(lastInstance.current!.mount).toHaveBeenCalledTimes(1) + expect(lastInstance.current!.unmount).not.toHaveBeenCalled() + expect(lastInstance.current!.mount).toHaveBeenCalledWith( + expect.any(HTMLDivElement), + expect.objectContaining({ theme: 'dark' }), + ) + + unmount() + }) + + it('does NOT remount when an unrelated prop changes but theme stays the same', () => { + // Wrap in a parent that we control so we can force prop-identity changes + // without changing theme. + function Harness({ extras }: { extras: object }) { + return + } + + const { rerender, unmount } = render() + const firstInstance = lastInstance.current + expect(firstInstance?.mount).toHaveBeenCalledTimes(1) + + // Re-render with a new prop object — same theme. + rerender() + + // Same instance, no remount, no unmount. + expect(lastInstance.current).toBe(firstInstance) + expect(firstInstance!.mount).toHaveBeenCalledTimes(1) + expect(firstInstance!.unmount).not.toHaveBeenCalled() + + unmount() + }) + + it('unmounts the old instance and mounts a new one when theme changes', () => { + const { rerender, unmount } = render() + const lightInstance = lastInstance.current + expect(lightInstance?.mount).toHaveBeenCalledTimes(1) + expect(lightInstance?.mount).toHaveBeenCalledWith( + expect.any(HTMLDivElement), + expect.objectContaining({ theme: 'light' }), + ) + + rerender() + + // After theme change: old instance unmounted, new instance mounted. + expect(lightInstance!.unmount).toHaveBeenCalledTimes(1) + const darkInstance = lastInstance.current + expect(darkInstance).not.toBe(lightInstance) + expect(darkInstance?.mount).toHaveBeenCalledTimes(1) + expect(darkInstance!.mount).toHaveBeenCalledWith( + expect.any(HTMLDivElement), + expect.objectContaining({ theme: 'dark' }), + ) + expect(darkInstance!.unmount).not.toHaveBeenCalled() + + unmount() + }) + + it('unmounts the current FormDevtoolsCore when the panel itself unmounts', () => { + const { unmount } = render() + const instance = lastInstance.current + expect(instance?.unmount).not.toHaveBeenCalled() + + unmount() + + // The cleanup function on the live effect must call unmount exactly once. + expect(instance!.unmount).toHaveBeenCalledTimes(1) + }) + + it('returns null from FormDevtoolsPanelNoOp without mounting any core', async () => { + const { FormDevtoolsPanelNoOp } = await import('../src/FormDevtools') + lastInstance.current = null + + const { container, unmount } = render() + + expect(lastInstance.current).toBeNull() + expect(container.firstChild).toBeNull() + + unmount() }) })