Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/devtools-panel-theme.md
Original file line number Diff line number Diff line change
@@ -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 }) => <ParamourDevtoolsPanel theme={theme} />`.

**Breaking (types):** `ParamourDevtoolsPluginEntry.render` changes from `ReactElement` to `(el: HTMLElement, props: { readonly theme: "dark" | "light" }) => ReactElement`. Passing the entry to `<TanStackDevtools plugins={[paramourDevtoolsPlugin()]} />` is unchanged. Only code that reads `entry.render` as an element needs updating. Call it with the shell's theme or render `<ParamourDevtoolsPanel theme={…} />` directly.
3 changes: 2 additions & 1 deletion docs/content/docs/guides/devtools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <ParamourDevtoolsPanel /> }`.
the plugin helper is just
`{ id, name, render: (_el, { theme }) => <ParamourDevtoolsPanel theme={theme} /> }`.

## Production cost: none

Expand Down
18 changes: 12 additions & 6 deletions docs/content/docs/reference/devtools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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

Expand All @@ -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";
Expand Down
6 changes: 3 additions & 3 deletions packages/devtools/src/components/panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down
14 changes: 11 additions & 3 deletions packages/devtools/src/plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -31,6 +39,6 @@ export function paramourDevtoolsPlugin(
: { defaultOpen: options.defaultOpen }),
id: "paramour-devtools",
name: "Paramour",
render: <ParamourDevtoolsPanel />,
render: (_el, { theme }) => <ParamourDevtoolsPanel theme={theme} />,
};
}
9 changes: 8 additions & 1 deletion packages/devtools/test/public-api.tst.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,14 @@ test("plugin helper returns the entry shape", () => {
expect(
paramourDevtoolsPlugin({ defaultOpen: true }),
).type.toBe<ParamourDevtoolsPluginEntry>();
expect(paramourDevtoolsPlugin().render).type.toBeAssignableTo<ReactElement>();
// 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", () => {
Expand Down
18 changes: 18 additions & 0 deletions packages/devtools/test/theme.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", {});
Expand All @@ -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(<ParamourDevtoolsPanel />);
Expand Down
Loading