diff --git a/.changeset/devtools-panel-theme.md b/.changeset/devtools-panel-theme.md new file mode 100644 index 0000000..3e503df --- /dev/null +++ b/.changeset/devtools-panel-theme.md @@ -0,0 +1,7 @@ +--- +"@paramour-js/devtools-panel": major +--- + +The devtools panel now follows the TanStack Devtools theme. Before this fix it always rendered light, even in a dark shell. The shell passes its theme only to a plugin's `render` function and renders an element `render` as-is, so `paramourDevtoolsPlugin()` now returns `render` as `(el, { theme }) => `. + +**Breaking (types):** `ParamourDevtoolsPluginEntry.render` changes from `ReactElement` to `(el: HTMLElement, props: { readonly theme: "dark" | "light" }) => ReactElement`. Passing the entry to `` is unchanged. Only code that reads `entry.render` as an element needs updating. Call it with the shell's theme or render `` directly. diff --git a/docs/content/docs/guides/devtools.mdx b/docs/content/docs/guides/devtools.mdx index b82d34c..5663d7e 100644 --- a/docs/content/docs/guides/devtools.mdx +++ b/docs/content/docs/guides/devtools.mdx @@ -73,7 +73,8 @@ nothing to register per route. `paramourDevtoolsPlugin({ defaultOpen: true })` opens the panel on load if you want it in your face during a debugging session. If you're composing a custom devtools shell, `ParamourDevtoolsPanel` is also exported directly — -the plugin helper is just `{ id, name, render: }`. +the plugin helper is just +`{ id, name, render: (_el, { theme }) => }`. ## Production cost: none diff --git a/docs/content/docs/reference/devtools.mdx b/docs/content/docs/reference/devtools.mdx index a4ac2bb..cede90f 100644 --- a/docs/content/docs/reference/devtools.mdx +++ b/docs/content/docs/reference/devtools.mdx @@ -46,8 +46,8 @@ export function Devtools() { `defaultOpen: true` opens the panel on load — useful when you want it in your face during a debugging session. That's the only option; the helper -just builds `{ id, name, render }` with the panel component as `render`, -plus the flag. +just builds `{ id, name, render }`, where `render` mounts the panel with the +shell's theme, plus the flag. Gotchas: @@ -87,8 +87,9 @@ export function CustomShellPanel({ theme }: { theme: "dark" | "light" }) { `theme` selects the panel's token set via a `data-theme` attribute (no theme context, no remount) and defaults to `"light"`. When the panel is mounted through [`paramourDevtoolsPlugin`](#paramourdevtoolsplugin) you -never pass it — the TanStack shell clones the rendered element and injects -`theme` itself; the prop exists for shells that don't. +never pass it: the TanStack shell calls the plugin's `render` function with +its current theme, and the panel follows the shell's theme setting live. +In a custom shell, pass `theme` yourself. ## ParamourDevtoolsPluginEntry @@ -99,14 +100,19 @@ interface ParamourDevtoolsPluginEntry { readonly defaultOpen?: boolean; readonly id: string; readonly name: string; - readonly render: ReactElement; + readonly render: ( + el: HTMLElement, + props: { readonly theme: "dark" | "light" }, + ) => ReactElement; } ``` Declared *structurally* — the shell's contract is just `name`/`render`/`id`/`defaultOpen` — so this package never imports `@tanstack/react-devtools` at runtime; assignability to the real plugin -type is certified by the package's type tests. +type is certified by the package's type tests. `render` is a function +because the shell passes its theme only to function renders. It renders +an element `render` as-is. ```ts twoslash import { paramourDevtoolsPlugin } from "@paramour-js/devtools-panel"; diff --git a/packages/devtools/src/components/panel.tsx b/packages/devtools/src/components/panel.tsx index 9cd95ea..815523f 100644 --- a/packages/devtools/src/components/panel.tsx +++ b/packages/devtools/src/components/panel.tsx @@ -10,9 +10,9 @@ import { Sidebar } from "./sidebar.js"; export interface ParamourDevtoolsPanelProps { /** - * Injected by the TanStack shell (the one contract it guarantees every - * panel, regardless of framework); selects the stylesheet's token set via - * a `data-theme` attribute — no theme context, no remount. + * The shell's theme, passed through by `paramourDevtoolsPlugin()`'s + * render function; selects the stylesheet's token set via a `data-theme` + * attribute — no theme context, no remount. */ readonly theme?: "dark" | "light"; } diff --git a/packages/devtools/src/plugin.tsx b/packages/devtools/src/plugin.tsx index 5672cb8..fbf2a0a 100644 --- a/packages/devtools/src/plugin.tsx +++ b/packages/devtools/src/plugin.tsx @@ -9,13 +9,21 @@ import { ParamourDevtoolsPanel } from "./components/panel.js"; * entry type is declared STRUCTURALLY (the shell's contract is just * name/render/id/defaultOpen) so this module never imports the shell at * runtime; assignability to the real plugin type is certified by the - * package's type tests. The shell clones `render` and injects `theme`. + * package's type tests. + * + * `render` is a function because that is the only way the panel receives + * the shell's theme: the shell calls a function render as + * `render(el, { theme, devtoolsOpen })` whenever its theme changes, and + * renders an element as-is with no props injected. */ export interface ParamourDevtoolsPluginEntry { readonly defaultOpen?: boolean; readonly id: string; readonly name: string; - readonly render: ReactElement; + readonly render: ( + el: HTMLElement, + props: { readonly theme: "dark" | "light" }, + ) => ReactElement; } export interface ParamourDevtoolsPluginOptions { @@ -31,6 +39,6 @@ export function paramourDevtoolsPlugin( : { defaultOpen: options.defaultOpen }), id: "paramour-devtools", name: "Paramour", - render: , + render: (_el, { theme }) => , }; } diff --git a/packages/devtools/test/public-api.tst.tsx b/packages/devtools/test/public-api.tst.tsx index 19b53f2..006a3b4 100644 --- a/packages/devtools/test/public-api.tst.tsx +++ b/packages/devtools/test/public-api.tst.tsx @@ -48,7 +48,14 @@ test("plugin helper returns the entry shape", () => { expect( paramourDevtoolsPlugin({ defaultOpen: true }), ).type.toBe(); - expect(paramourDevtoolsPlugin().render).type.toBeAssignableTo(); + // A function, not an element: the shell passes the theme only to a + // function render. + expect(paramourDevtoolsPlugin().render).type.toBe< + ( + el: HTMLElement, + props: { readonly theme: "dark" | "light" }, + ) => ReactElement + >(); }); test("the entry is assignable to the REAL shell's plugins array", () => { diff --git a/packages/devtools/test/theme.test.tsx b/packages/devtools/test/theme.test.tsx index f3a1b2a..8b6875b 100644 --- a/packages/devtools/test/theme.test.tsx +++ b/packages/devtools/test/theme.test.tsx @@ -4,6 +4,7 @@ import { defineAppRoute } from "paramour"; import { afterEach, describe, expect, it } from "vitest"; import { ParamourDevtoolsPanel } from "../src/components/panel.js"; +import { paramourDevtoolsPlugin } from "../src/plugin.js"; import { freshSeam, paramsObservation, setUrl } from "./helpers.js"; const route = defineAppRoute("/home", {}); @@ -28,6 +29,23 @@ describe("theme switching", () => { expect(root?.getAttribute("data-theme")).toBe("dark"); }); + it("the plugin entry renders the theme the shell passes to render()", () => { + // TanStack's shell calls a function render as render(el, { theme, … }) + // on every theme change; an element render never receives the theme. + freshSeam(); + const entry = paramourDevtoolsPlugin(); + const host = document.createElement("div"); + const { container, rerender } = render( + entry.render(host, { theme: "dark" }), + ); + const root = container.querySelector(".pmr-root"); + expect(root?.getAttribute("data-theme")).toBe("dark"); + + rerender(entry.render(host, { theme: "light" })); + expect(container.querySelector(".pmr-root")).toBe(root); + expect(root?.getAttribute("data-theme")).toBe("light"); + }); + it("defaults to light when the shell passes no theme", () => { freshSeam(); const { container } = render();