Skip to content
Open
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
8 changes: 8 additions & 0 deletions packages/scan/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@
"import": "./dist/lite/index.mjs",
"require": "./dist/lite/index.js"
},
"./tanstack-plugin": {
"types": "./dist/tanstack-plugin/index.d.ts",
"import": "./dist/tanstack-plugin/index.mjs",
"require": "./dist/tanstack-plugin/index.js"
},
"./auto": {
"production": {
"import": {
Expand Down Expand Up @@ -193,6 +198,9 @@
],
"react-component-name/loader": [
"./dist/react-component-name/loader.d.ts"
],
"tanstack-plugin": [
"./dist/tanstack-plugin/index.d.ts"
]
}
},
Expand Down
105 changes: 105 additions & 0 deletions packages/scan/src/tanstack-plugin/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { render } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { scan } from '~core/index';
import type { Options } from '~core/index';
import styles from '~web/assets/css/styles.css';
import { SvgSprite } from '~web/components/svg-sprite';
import { ToolbarElementContext } from '~web/toolbar-context';
import { Content } from '~web/views';
import { ScanOverlay } from '~web/views/inspector/overlay';

// Structural type matching TanStackDevtoolsPlugin — no runtime @tanstack/devtools dependency.
export interface ReactScanTanStackPlugin {
id?: string;
name: string;
defaultOpen?: boolean;
render: (el: HTMLDivElement, props: Record<string, unknown>) => void;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plugin interface type incompatible with TanStack DevTools

High Severity

The ReactScanTanStackPlugin interface comment says it structurally matches TanStackDevtoolsPlugin, but the render second parameter is typed as props: Record<string, unknown> while the actual TanStack interface uses theme: 'dark' | 'light'. A string literal union is not assignable to Record<string, unknown>, so under --strictFunctionTypes (default in strict mode), consumers passing createReactScanPlugin() to TanStack DevTools' plugins prop will get a TypeScript error. The interface fails its stated purpose of structural compatibility.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d8c398c. Configure here.

destroy?: (pluginId: string) => void;
}

const TanStackPanel = () => {
const containerRef = useRef<HTMLDivElement>(null);
// Force a re-render after mount so ToolbarElementContext receives the live DOM ref,
// enabling portal targets in popovers and other-visualization.
const [, forceUpdate] = useState(false);
useEffect(() => {
forceUpdate(true);
}, []);

return (
<ToolbarElementContext.Provider value={containerRef.current}>
<ScanOverlay />
<div
ref={containerRef}
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<Content />
</div>
</ToolbarElementContext.Provider>
);
};

/**
* Creates a TanStack DevTools plugin that embeds React Scan's inspector,
* notifications, and scan controls inside the TanStack DevTools panel.
*
* @example
* ```tsx
* import { TanStackDevtools } from '@tanstack/react-devtools';
* import { createReactScanPlugin } from 'react-scan/tanstack-plugin';
*
* <TanStackDevtools plugins={[createReactScanPlugin()]} />
* ```
*
* React Scan's render outline overlays continue to appear on the page as usual.
* The floating toolbar is suppressed — all controls live inside the TanStack panel.
*/
export function createReactScanPlugin(
options: Omit<Options, 'showToolbar'> = {},
): ReactScanTanStackPlugin {
let shadowRoot: ShadowRoot | null = null;
let container: HTMLDivElement | null = null;

return {
id: 'react-scan',
name: 'React Scan',
render(el) {
// Start scanning without the floating widget.
scan({ ...options, showToolbar: false });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TanStack DevTools plugin's render() method crashes on multiple calls due to attempting to attach shadow root twice to the same element

Fix on Vercel


// Create an isolated shadow root — same pattern as initRootContainer in core/index.ts.
shadowRoot = el.attachShadow({ mode: 'open' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

attachShadow crashes on repeated render calls

High Severity

The TanStack DevTools plugin lifecycle documentation states that render is "called again when the theme changes." Calling el.attachShadow({ mode: 'open' }) on an element that already hosts a shadow root throws a DOMException. The render method lacks a guard to check for an existing shadow root before attaching a new one—unlike initRootContainer in core/index.ts, which checks before calling attachShadow. This will crash the plugin panel whenever the user toggles the DevTools theme.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d8c398c. Configure here.


const styleEl = document.createElement('style');
styleEl.textContent = styles;
shadowRoot.appendChild(styleEl);

container = document.createElement('div');
container.style.cssText = 'height:100%;display:flex;flex-direction:column;';
shadowRoot.appendChild(container);

render(
<>
<SvgSprite />
<TanStackPanel />
</>,
container,
);
},
destroy() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
destroy() {
destroy(_pluginId?: string) {

The destroy method implementation doesn't match the interface signature - it doesn't accept the pluginId parameter that the interface defines

Fix on Vercel

if (container) {
// Double render(null) is required to fully unmount Preact components
// and flush internal VNode references and event listeners.
render(null, container);
render(null, container);
container = null;
shadowRoot = null;
}
},
};
}
3 changes: 3 additions & 0 deletions packages/scan/src/web/toolbar-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createContext } from 'preact';

export const ToolbarElementContext = createContext<HTMLElement | null>(null);
5 changes: 3 additions & 2 deletions packages/scan/src/web/widget/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createContext, type JSX } from "preact";
import type { JSX } from "preact";
import { ToolbarElementContext } from '~web/toolbar-context';
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { Store, ReactScanInternals } from "~core/index";
import {
Expand Down Expand Up @@ -757,4 +758,4 @@ export const Widget = () => {
);
};

export const ToolbarElementContext = createContext<HTMLElement | null>(null);
export { ToolbarElementContext } from '~web/toolbar-context';
2 changes: 2 additions & 0 deletions packages/scan/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export default defineConfig([
'./src/install-hook.ts',
'./src/core/all-environments.ts',
'./src/lite/index.ts',
'./src/tanstack-plugin/index.tsx',
],
banner: {
js: banner,
Expand All @@ -144,6 +145,7 @@ export default defineConfig([
await addDirectivesToChunkFiles(DIST_PATH);
await addDirectivesToChunkFiles(path.join(DIST_PATH, 'lite'));
await addDirectivesToChunkFiles(path.join(DIST_PATH, 'core'));
await addDirectivesToChunkFiles(path.join(DIST_PATH, 'tanstack-plugin'));
},
minify: false,
env: {
Expand Down