diff --git a/packages/angular-table-devtools/src/TableDevtools.ts b/packages/angular-table-devtools/src/TableDevtools.ts index b88c366710..0ea790a3b4 100644 --- a/packages/angular-table-devtools/src/TableDevtools.ts +++ b/packages/angular-table-devtools/src/TableDevtools.ts @@ -1,12 +1,9 @@ import { TableDevtoolsCore } from '@tanstack/table-devtools' -import { createAngularPanel } from '@tanstack/devtools-utils/angular' +import { computed, effect } from '@angular/core' import type { DevtoolsPanelProps } from '@tanstack/devtools-utils/angular' export interface TableDevtoolsAngularInit extends Partial {} -const [TableDevtoolsPanelBase, TableDevtoolsPanelNoOpBase] = - createAngularPanel(TableDevtoolsCore) - function resolvePanelProps( props?: TableDevtoolsAngularInit, ): DevtoolsPanelProps { @@ -23,12 +20,26 @@ type TableDevtoolsPanelComponent = () => ( export const TableDevtoolsPanel: TableDevtoolsPanelComponent = () => (props, host) => { - const panel = TableDevtoolsPanelBase() - return panel(() => resolvePanelProps(props()), host) + const panel = host.ownerDocument.createElement('div') + panel.style.height = '100%' + host.appendChild(panel) + + const panelProps = computed(() => resolvePanelProps(props()), { + equal: (previous, next) => + previous.theme === next.theme && + previous.devtoolsOpen === next.devtoolsOpen, + }) + const panelEffect = effect((onCleanup) => { + const instance = new TableDevtoolsCore() + void instance.mount(panel, panelProps()) + onCleanup(() => instance.unmount()) + }) + + return () => { + panelEffect.destroy() + panel.remove() + } } export const TableDevtoolsPanelNoOp: TableDevtoolsPanelComponent = - () => (_props, _host) => { - const panel = TableDevtoolsPanelNoOpBase() - return () => panel - } + () => (_props, _host) => () => {} diff --git a/packages/angular-table-devtools/src/injectTanStackTableDevtools.ts b/packages/angular-table-devtools/src/injectTanStackTableDevtools.ts index eabca429b5..e8ca71c6e0 100644 --- a/packages/angular-table-devtools/src/injectTanStackTableDevtools.ts +++ b/packages/angular-table-devtools/src/injectTanStackTableDevtools.ts @@ -1,5 +1,6 @@ -import { upsertTableDevtoolsTarget } from '@tanstack/table-devtools' +import { createTableDevtoolsRegistrationManager } from '@tanstack/table-devtools' import { + DestroyRef, Injector, assertInInjectionContext, effect, @@ -30,17 +31,15 @@ export function injectTanStackTableDevtools< injector = inject(Injector) } + const destroyRef = injector.get(DestroyRef) + const registration = createTableDevtoolsRegistrationManager() + destroyRef.onDestroy(() => registration.dispose()) + effect( - (onCleanup) => { + () => { const { table } = options() const enabledValue = enabled() - if (!enabledValue || !table) { - return - } - const cleanup = untracked(() => upsertTableDevtoolsTarget({ table })) - onCleanup(() => { - cleanup?.() - }) + untracked(() => registration.update(table, enabledValue)) }, { injector }, ) diff --git a/packages/angular-table-devtools/src/production.ts b/packages/angular-table-devtools/src/production.ts index f3e96534ef..1256c41c5a 100644 --- a/packages/angular-table-devtools/src/production.ts +++ b/packages/angular-table-devtools/src/production.ts @@ -1,5 +1,5 @@ -export { TableDevtoolsPanel } from './TableDevtools' +export { TableDevtoolsPanel } from './production/TableDevtools' export type { TableDevtoolsAngularInit } from './TableDevtools' -export { tableDevtoolsPlugin } from './plugin' +export { tableDevtoolsPlugin } from './production/plugin' export { injectTanStackTableDevtools } from './injectTanStackTableDevtools' export type { InjectTanStackTableDevtoolsOptions } from './injectTanStackTableDevtools' diff --git a/packages/angular-table-devtools/src/production/TableDevtools.ts b/packages/angular-table-devtools/src/production/TableDevtools.ts new file mode 100644 index 0000000000..c9061540c8 --- /dev/null +++ b/packages/angular-table-devtools/src/production/TableDevtools.ts @@ -0,0 +1,41 @@ +import { computed, effect } from '@angular/core' +import { TableDevtoolsCore } from '@tanstack/table-devtools/production' +import type { DevtoolsPanelProps } from '@tanstack/devtools-utils/angular' +import type { TableDevtoolsAngularInit } from '../TableDevtools' + +function resolvePanelProps( + props?: TableDevtoolsAngularInit, +): DevtoolsPanelProps { + return { + theme: props?.theme ?? 'dark', + devtoolsOpen: props?.devtoolsOpen ?? false, + } +} + +type TableDevtoolsPanelComponent = () => ( + inputs: () => TableDevtoolsAngularInit, + hostElement: HTMLElement, +) => () => void + +export const TableDevtoolsPanel: TableDevtoolsPanelComponent = + () => (props, host) => { + const panel = host.ownerDocument.createElement('div') + panel.style.height = '100%' + host.appendChild(panel) + + const panelProps = computed(() => resolvePanelProps(props()), { + equal: (previous, next) => + previous.theme === next.theme && + previous.devtoolsOpen === next.devtoolsOpen, + }) + const panelEffect = effect((onCleanup) => { + const instance = new TableDevtoolsCore() + void instance.mount(panel, panelProps()) + onCleanup(() => instance.unmount()) + }) + + return () => { + panelEffect.destroy() + panel.remove() + } + } diff --git a/packages/angular-table-devtools/src/production/plugin.ts b/packages/angular-table-devtools/src/production/plugin.ts new file mode 100644 index 0000000000..73c8683158 --- /dev/null +++ b/packages/angular-table-devtools/src/production/plugin.ts @@ -0,0 +1,11 @@ +import { createAngularPlugin } from '@tanstack/devtools-utils/angular' +import { TableDevtoolsPanel } from './TableDevtools' + +type TableDevtoolsPluginFactory = ReturnType[0] + +const [plugin] = createAngularPlugin({ + name: 'TanStack Table', + render: TableDevtoolsPanel, +}) + +export const tableDevtoolsPlugin: TableDevtoolsPluginFactory = plugin diff --git a/packages/preact-table-devtools/src/PreactTableDevtools.tsx b/packages/preact-table-devtools/src/PreactTableDevtools.tsx index b84cae7ced..9e0f7e681b 100644 --- a/packages/preact-table-devtools/src/PreactTableDevtools.tsx +++ b/packages/preact-table-devtools/src/PreactTableDevtools.tsx @@ -1,4 +1,5 @@ import { h } from 'preact' +import { useMemo } from 'preact/hooks' import { createPreactPanel } from '@tanstack/devtools-utils/preact' import { TableDevtoolsCore } from '@tanstack/table-devtools' import type { DevtoolsPanelProps } from '@tanstack/devtools-utils/preact' @@ -22,8 +23,22 @@ function resolvePanelProps( } } -export const TableDevtoolsPanel: TableDevtoolsPanelComponent = (props) => - h(TableDevtoolsPanelBase, resolvePanelProps(props)) +export const TableDevtoolsPanel: TableDevtoolsPanelComponent = (props) => { + const theme = props?.theme + const devtoolsOpen = props?.devtoolsOpen + const panelProps = useMemo( + () => resolvePanelProps({ theme, devtoolsOpen }), + [devtoolsOpen, theme], + ) + return h(TableDevtoolsPanelBase, panelProps) +} -export const TableDevtoolsPanelNoOp: TableDevtoolsPanelComponent = (props) => - h(TableDevtoolsPanelNoOpBase, resolvePanelProps(props)) +export const TableDevtoolsPanelNoOp: TableDevtoolsPanelComponent = (props) => { + const theme = props?.theme + const devtoolsOpen = props?.devtoolsOpen + const panelProps = useMemo( + () => resolvePanelProps({ theme, devtoolsOpen }), + [devtoolsOpen, theme], + ) + return h(TableDevtoolsPanelNoOpBase, panelProps) +} diff --git a/packages/preact-table-devtools/src/production.ts b/packages/preact-table-devtools/src/production.ts index 2212d72fce..9d7e6de391 100644 --- a/packages/preact-table-devtools/src/production.ts +++ b/packages/preact-table-devtools/src/production.ts @@ -1,7 +1,7 @@ 'use client' -export { TableDevtoolsPanel } from './PreactTableDevtools' +export { TableDevtoolsPanel } from './production/PreactTableDevtools' export type { TableDevtoolsPreactInit } from './PreactTableDevtools' -export { tableDevtoolsPlugin } from './plugin' +export { tableDevtoolsPlugin } from './production/plugin' export { useTanStackTableDevtools } from './useTanStackTableDevtools' export type { UseTanStackTableDevtoolsOptions } from './useTanStackTableDevtools' diff --git a/packages/preact-table-devtools/src/production/PreactTableDevtools.tsx b/packages/preact-table-devtools/src/production/PreactTableDevtools.tsx new file mode 100644 index 0000000000..7606a20f5c --- /dev/null +++ b/packages/preact-table-devtools/src/production/PreactTableDevtools.tsx @@ -0,0 +1,32 @@ +import { h } from 'preact' +import { useMemo } from 'preact/hooks' +import { createPreactPanel } from '@tanstack/devtools-utils/preact' +import { TableDevtoolsCore } from '@tanstack/table-devtools/production' +import type { DevtoolsPanelProps } from '@tanstack/devtools-utils/preact' +import type { JSX } from 'preact' +import type { TableDevtoolsPreactInit } from '../PreactTableDevtools' + +type TableDevtoolsPanelComponent = ( + props?: TableDevtoolsPreactInit, +) => JSX.Element + +const [TableDevtoolsPanelBase] = createPreactPanel(TableDevtoolsCore) + +function resolvePanelProps( + props?: TableDevtoolsPreactInit, +): DevtoolsPanelProps { + return { + theme: props?.theme ?? 'dark', + devtoolsOpen: props?.devtoolsOpen ?? false, + } +} + +export const TableDevtoolsPanel: TableDevtoolsPanelComponent = (props) => { + const theme = props?.theme + const devtoolsOpen = props?.devtoolsOpen + const panelProps = useMemo( + () => resolvePanelProps({ theme, devtoolsOpen }), + [devtoolsOpen, theme], + ) + return h(TableDevtoolsPanelBase, panelProps) +} diff --git a/packages/preact-table-devtools/src/production/plugin.tsx b/packages/preact-table-devtools/src/production/plugin.tsx new file mode 100644 index 0000000000..a75f39c20d --- /dev/null +++ b/packages/preact-table-devtools/src/production/plugin.tsx @@ -0,0 +1,13 @@ +import { createPreactPlugin } from '@tanstack/devtools-utils/preact' +import { TableDevtoolsPanel } from './PreactTableDevtools' + +type PreactTableDevtoolsPlugin = ReturnType< + ReturnType[0] +> + +const [plugin] = createPreactPlugin({ + name: 'TanStack Table', + Component: TableDevtoolsPanel, +}) + +export const tableDevtoolsPlugin: () => PreactTableDevtoolsPlugin = plugin diff --git a/packages/preact-table-devtools/src/useTanStackTableDevtools.ts b/packages/preact-table-devtools/src/useTanStackTableDevtools.ts index 80b51f5ca4..52682f8044 100644 --- a/packages/preact-table-devtools/src/useTanStackTableDevtools.ts +++ b/packages/preact-table-devtools/src/useTanStackTableDevtools.ts @@ -1,7 +1,7 @@ 'use client' -import { useEffect } from 'preact/hooks' -import { upsertTableDevtoolsTarget } from '@tanstack/table-devtools' +import { useEffect, useRef } from 'preact/hooks' +import { createTableDevtoolsRegistrationManager } from '@tanstack/table-devtools' import type { RowData, Table, TableFeatures } from '@tanstack/table-core' export interface UseTanStackTableDevtoolsOptions { @@ -16,18 +16,18 @@ export function useTanStackTableDevtools< options?: UseTanStackTableDevtoolsOptions, ): void { const enabled = options?.enabled ?? true + const registrationRef = + useRef>() + registrationRef.current ??= createTableDevtoolsRegistrationManager() + const registration = registrationRef.current useEffect(() => { - if (!enabled || !table) { - return - } + return () => registration.dispose() + }, [registration]) - const cleanup = upsertTableDevtoolsTarget({ table }) - - return () => { - cleanup?.() - } - }, [enabled, table, table?.options.key]) + useEffect(() => { + registration.update(table, enabled) + }, [enabled, registration, table, table?.options.key]) } export function useTanStackTableDevtoolsNoOp< diff --git a/packages/react-table-devtools/src/ReactTableDevtools.tsx b/packages/react-table-devtools/src/ReactTableDevtools.tsx index 898c95f67d..4fbd67f137 100644 --- a/packages/react-table-devtools/src/ReactTableDevtools.tsx +++ b/packages/react-table-devtools/src/ReactTableDevtools.tsx @@ -1,4 +1,4 @@ -import { createElement } from 'react' +import { createElement, useMemo } from 'react' import { createReactPanel } from '@tanstack/devtools-utils/react' import { TableDevtoolsCore } from '@tanstack/table-devtools' import type { DevtoolsPanelProps } from '@tanstack/devtools-utils/react' @@ -20,8 +20,22 @@ function resolvePanelProps(props?: TableDevtoolsReactInit): DevtoolsPanelProps { } } -export const TableDevtoolsPanel: TableDevtoolsPanelComponent = (props) => - createElement(TableDevtoolsPanelBase, resolvePanelProps(props)) +export const TableDevtoolsPanel: TableDevtoolsPanelComponent = (props) => { + const theme = props?.theme + const devtoolsOpen = props?.devtoolsOpen + const panelProps = useMemo( + () => resolvePanelProps({ theme, devtoolsOpen }), + [devtoolsOpen, theme], + ) + return createElement(TableDevtoolsPanelBase, panelProps) +} -export const TableDevtoolsPanelNoOp: TableDevtoolsPanelComponent = (props) => - createElement(TableDevtoolsPanelNoOpBase, resolvePanelProps(props)) +export const TableDevtoolsPanelNoOp: TableDevtoolsPanelComponent = (props) => { + const theme = props?.theme + const devtoolsOpen = props?.devtoolsOpen + const panelProps = useMemo( + () => resolvePanelProps({ theme, devtoolsOpen }), + [devtoolsOpen, theme], + ) + return createElement(TableDevtoolsPanelNoOpBase, panelProps) +} diff --git a/packages/react-table-devtools/src/production.ts b/packages/react-table-devtools/src/production.ts index d31f8bce12..17fbb08574 100644 --- a/packages/react-table-devtools/src/production.ts +++ b/packages/react-table-devtools/src/production.ts @@ -1,7 +1,7 @@ 'use client' -export { TableDevtoolsPanel } from './ReactTableDevtools' +export { TableDevtoolsPanel } from './production/ReactTableDevtools' export type { TableDevtoolsReactInit } from './ReactTableDevtools' -export { tableDevtoolsPlugin } from './plugin' +export { tableDevtoolsPlugin } from './production/plugin' export { useTanStackTableDevtools } from './useTanStackTableDevtools' export type { UseTanStackTableDevtoolsOptions } from './useTanStackTableDevtools' diff --git a/packages/react-table-devtools/src/production/ReactTableDevtools.tsx b/packages/react-table-devtools/src/production/ReactTableDevtools.tsx new file mode 100644 index 0000000000..5d2f4e5dd8 --- /dev/null +++ b/packages/react-table-devtools/src/production/ReactTableDevtools.tsx @@ -0,0 +1,29 @@ +import { createElement, useMemo } from 'react' +import { createReactPanel } from '@tanstack/devtools-utils/react' +import { TableDevtoolsCore } from '@tanstack/table-devtools/production' +import type { DevtoolsPanelProps } from '@tanstack/devtools-utils/react' +import type { JSX } from 'react' +import type { TableDevtoolsReactInit } from '../ReactTableDevtools' + +type TableDevtoolsPanelComponent = ( + props?: TableDevtoolsReactInit, +) => JSX.Element + +const [TableDevtoolsPanelBase] = createReactPanel(TableDevtoolsCore) + +function resolvePanelProps(props?: TableDevtoolsReactInit): DevtoolsPanelProps { + return { + theme: props?.theme ?? 'dark', + devtoolsOpen: props?.devtoolsOpen ?? false, + } +} + +export const TableDevtoolsPanel: TableDevtoolsPanelComponent = (props) => { + const theme = props?.theme + const devtoolsOpen = props?.devtoolsOpen + const panelProps = useMemo( + () => resolvePanelProps({ theme, devtoolsOpen }), + [devtoolsOpen, theme], + ) + return createElement(TableDevtoolsPanelBase, panelProps) +} diff --git a/packages/react-table-devtools/src/production/plugin.tsx b/packages/react-table-devtools/src/production/plugin.tsx new file mode 100644 index 0000000000..af649b07db --- /dev/null +++ b/packages/react-table-devtools/src/production/plugin.tsx @@ -0,0 +1,11 @@ +import { createReactPlugin } from '@tanstack/devtools-utils/react' +import { TableDevtoolsPanel } from './ReactTableDevtools' + +type TableDevtoolsPluginFactory = ReturnType[0] + +const [plugin] = createReactPlugin({ + name: 'TanStack Table', + Component: TableDevtoolsPanel, +}) + +export const tableDevtoolsPlugin: TableDevtoolsPluginFactory = plugin diff --git a/packages/react-table-devtools/src/table-devtools.d.ts b/packages/react-table-devtools/src/table-devtools.d.ts index e8b6b1d122..e8db884dc5 100644 --- a/packages/react-table-devtools/src/table-devtools.d.ts +++ b/packages/react-table-devtools/src/table-devtools.d.ts @@ -31,4 +31,14 @@ declare module '@tanstack/table-devtools' { ): (() => void) | undefined export function removeTableDevtoolsTarget(id: string): void + + export interface TableDevtoolsRegistrationManager { + update: ( + table: Table | undefined, + enabled?: boolean, + ) => void + dispose: () => void + } + + export function createTableDevtoolsRegistrationManager(): TableDevtoolsRegistrationManager } diff --git a/packages/react-table-devtools/src/useTanStackTableDevtools.ts b/packages/react-table-devtools/src/useTanStackTableDevtools.ts index fed82bad9b..1375f57be2 100644 --- a/packages/react-table-devtools/src/useTanStackTableDevtools.ts +++ b/packages/react-table-devtools/src/useTanStackTableDevtools.ts @@ -1,7 +1,7 @@ 'use client' -import { upsertTableDevtoolsTarget } from '@tanstack/table-devtools' -import { useEffect } from 'react' +import { createTableDevtoolsRegistrationManager } from '@tanstack/table-devtools' +import { useEffect, useState } from 'react' import type { RowData, Table, TableFeatures } from '@tanstack/table-core' export interface UseTanStackTableDevtoolsOptions { @@ -16,18 +16,15 @@ export function useTanStackTableDevtools< options?: UseTanStackTableDevtoolsOptions, ): void { const enabled = options?.enabled ?? true + const [registration] = useState(createTableDevtoolsRegistrationManager) useEffect(() => { - if (!enabled || !table) { - return - } + return () => registration.dispose() + }, [registration]) - const cleanup = upsertTableDevtoolsTarget({ table }) - - return () => { - cleanup?.() - } - }, [enabled, table, table?.options.key]) + useEffect(() => { + registration.update(table, enabled) + }, [enabled, registration, table, table?.options.key]) } export function useTanStackTableDevtoolsNoOp< diff --git a/packages/solid-table-devtools/src/TableDevtools.tsx b/packages/solid-table-devtools/src/TableDevtools.tsx index 75b8f100ce..b20fea09f4 100644 --- a/packages/solid-table-devtools/src/TableDevtools.tsx +++ b/packages/solid-table-devtools/src/TableDevtools.tsx @@ -14,8 +14,12 @@ const [TableDevtoolsPanelBase, TableDevtoolsPanelNoOpBase] = function resolvePanelProps(props?: TableDevtoolsSolidInit): DevtoolsPanelProps { return { - theme: props?.theme ?? 'dark', - devtoolsOpen: props?.devtoolsOpen ?? false, + get theme() { + return props?.theme ?? 'dark' + }, + get devtoolsOpen() { + return props?.devtoolsOpen ?? false + }, } } diff --git a/packages/solid-table-devtools/src/production.ts b/packages/solid-table-devtools/src/production.ts index 010ae2139e..eddeb12413 100644 --- a/packages/solid-table-devtools/src/production.ts +++ b/packages/solid-table-devtools/src/production.ts @@ -1,4 +1,4 @@ -export { TableDevtoolsPanel } from './TableDevtools' +export { TableDevtoolsPanel } from './production/TableDevtools' export type { TableDevtoolsSolidInit } from './production/TableDevtools' diff --git a/packages/solid-table-devtools/src/production/TableDevtools.tsx b/packages/solid-table-devtools/src/production/TableDevtools.tsx index c6670acf6f..ec38ac5ae5 100644 --- a/packages/solid-table-devtools/src/production/TableDevtools.tsx +++ b/packages/solid-table-devtools/src/production/TableDevtools.tsx @@ -14,8 +14,12 @@ type TableDevtoolsPanelComponent = ( function resolvePanelProps(props?: TableDevtoolsSolidInit): DevtoolsPanelProps { return { - theme: props?.theme ?? 'dark', - devtoolsOpen: props?.devtoolsOpen ?? false, + get theme() { + return props?.theme ?? 'dark' + }, + get devtoolsOpen() { + return props?.devtoolsOpen ?? false + }, } } diff --git a/packages/solid-table-devtools/src/useTanStackTableDevtools.ts b/packages/solid-table-devtools/src/useTanStackTableDevtools.ts index 5c5fc60b40..160116b207 100644 --- a/packages/solid-table-devtools/src/useTanStackTableDevtools.ts +++ b/packages/solid-table-devtools/src/useTanStackTableDevtools.ts @@ -1,5 +1,5 @@ import { createRenderEffect, onCleanup } from 'solid-js' -import { upsertTableDevtoolsTarget } from '@tanstack/table-devtools' +import { createTableDevtoolsRegistrationManager } from '@tanstack/table-devtools' import type { RowData, Table, TableFeatures } from '@tanstack/table-core' export interface UseTanStackTableDevtoolsOptions { @@ -13,16 +13,11 @@ export function useTanStackTableDevtools< table: Table | undefined, options?: UseTanStackTableDevtoolsOptions, ): void { - createRenderEffect(() => { - if (!(options?.enabled ?? true) || !table) { - return - } - - const cleanup = upsertTableDevtoolsTarget({ table }) + const registration = createTableDevtoolsRegistrationManager() + onCleanup(() => registration.dispose()) - onCleanup(() => { - cleanup?.() - }) + createRenderEffect(() => { + registration.update(table, options?.enabled ?? true) }) } diff --git a/packages/table-devtools/src/TableDevtools.tsx b/packages/table-devtools/src/TableDevtools.tsx index a6e96ecec2..0f687b3557 100644 --- a/packages/table-devtools/src/TableDevtools.tsx +++ b/packages/table-devtools/src/TableDevtools.tsx @@ -1,3 +1,4 @@ +import { Show } from 'solid-js' import { ThemeContextProvider } from '@tanstack/devtools-ui' import { TableContextProvider } from './TableContextProvider' import { Shell } from './components/Shell' @@ -9,9 +10,11 @@ export default function TableDevtools(props: { }) { return ( - - - + + + + + ) } diff --git a/packages/table-devtools/src/components/ColumnsPanel.tsx b/packages/table-devtools/src/components/ColumnsPanel.tsx index 500e33a639..7434d6ad73 100644 --- a/packages/table-devtools/src/components/ColumnsPanel.tsx +++ b/packages/table-devtools/src/components/ColumnsPanel.tsx @@ -32,9 +32,9 @@ export function ColumnsPanel() { const styles = useStyles() const { table } = useTableDevtoolsContext() - const tableState = useTableStore( - () => table()?.store, - (state) => state, + const optionsStoreValue = useTableStore( + () => table()?.optionsStore, + (options) => options, ) const columns = createMemo>(() => { @@ -43,7 +43,7 @@ export function ColumnsPanel() { return [] } - tableState() + optionsStoreValue() const tableWithColumnFns = tableInstance as unknown as { getAllFlatColumns?: () => Array diff --git a/packages/table-devtools/src/components/FeaturesPanel.tsx b/packages/table-devtools/src/components/FeaturesPanel.tsx index 6c839ff0ae..49e2bfca38 100644 --- a/packages/table-devtools/src/components/FeaturesPanel.tsx +++ b/packages/table-devtools/src/components/FeaturesPanel.tsx @@ -157,19 +157,15 @@ export function FeaturesPanel() { () => table()?.store, (state) => state, ) - const tableOptions = useTableStore( - () => { - const tableInstance = table() - return tableInstance?.optionsStore ?? tableInstance?.store - }, - () => table()?.options as unknown, + const optionsStoreValue = useTableStore( + () => table()?.optionsStore, + (options) => options, ) const tableFeatures = createMemo((): Set => { const tableInstance = table() if (!tableInstance) return new Set() - tableState() return new Set(Object.keys(tableInstance._features)) }) @@ -177,8 +173,7 @@ export function FeaturesPanel() { const tableInstance = table() if (!tableInstance) return [] - tableState() - tableOptions() + optionsStoreValue() return Object.keys(tableInstance.options.features ?? {}).filter((key) => ROW_MODEL_FEATURE_SLOTS.includes(key), @@ -191,8 +186,7 @@ export function FeaturesPanel() { const tableInstance = table() if (!tableInstance) return [] - tableState() - tableOptions() + optionsStoreValue() const rowModelFns = toFnBuckets(tableInstance._rowModelFns) const optionFns = toFnBuckets(tableInstance.options) diff --git a/packages/table-devtools/src/components/OptionsPanel.tsx b/packages/table-devtools/src/components/OptionsPanel.tsx index e0753c4d0f..d9ef754c2a 100644 --- a/packages/table-devtools/src/components/OptionsPanel.tsx +++ b/packages/table-devtools/src/components/OptionsPanel.tsx @@ -20,17 +20,9 @@ export function OptionsPanel() { const styles = useStyles() const { table } = useTableDevtoolsContext() - const tableOptions = useTableStore( - () => { - const tableInstance = table() - return tableInstance?.optionsStore ?? tableInstance?.store - }, - () => { - const tableInstance = table() - return tableInstance - ? projectOptionsForTree(tableInstance.options) - : undefined - }, + const optionsStoreValue = useTableStore( + () => table()?.optionsStore, + (options) => options, ) const options = createMemo(() => { @@ -39,8 +31,7 @@ export function OptionsPanel() { return undefined } - tableOptions() - return projectOptionsForTree(tableInstance.options) + return projectOptionsForTree(optionsStoreValue() ?? tableInstance.options) }) return ( diff --git a/packages/table-devtools/src/components/ResizableSplit.tsx b/packages/table-devtools/src/components/ResizableSplit.tsx index c8abfa661c..a19a411e0a 100644 --- a/packages/table-devtools/src/components/ResizableSplit.tsx +++ b/packages/table-devtools/src/components/ResizableSplit.tsx @@ -1,4 +1,4 @@ -import { createSignal } from 'solid-js' +import { createSignal, onCleanup } from 'solid-js' import { useStyles } from '../styles/use-styles' import type { JSX } from 'solid-js' @@ -14,14 +14,22 @@ interface ResizableSplitProps { export function ResizableSplit(props: ResizableSplitProps) { const styles = useStyles() const [leftPercent, setLeftPercent] = createSignal(DEFAULT_LEFT_PERCENT) + let cleanupDrag: (() => void) | undefined + + onCleanup(() => { + cleanupDrag?.() + }) const handleMouseDown = (e: MouseEvent) => { e.preventDefault() + cleanupDrag?.() - const onMouseMove = (moveEvent: MouseEvent) => { - const container = (e.target as HTMLElement).parentElement - if (!container) return + const container = (e.currentTarget as HTMLElement).parentElement + if (!container) return + const previousCursor = document.body.style.cursor + const previousUserSelect = document.body.style.userSelect + const onMouseMove = (moveEvent: MouseEvent) => { const rect = container.getBoundingClientRect() const x = moveEvent.clientX - rect.left const percent = Math.max( @@ -31,12 +39,14 @@ export function ResizableSplit(props: ResizableSplitProps) { setLeftPercent(percent) } - const onMouseUp = () => { + cleanupDrag = () => { document.removeEventListener('mousemove', onMouseMove) document.removeEventListener('mouseup', onMouseUp) - document.body.style.cursor = '' - document.body.style.userSelect = '' + document.body.style.cursor = previousCursor + document.body.style.userSelect = previousUserSelect + cleanupDrag = undefined } + const onMouseUp = () => cleanupDrag?.() document.addEventListener('mousemove', onMouseMove) document.addEventListener('mouseup', onMouseUp) diff --git a/packages/table-devtools/src/components/RowsPanel.tsx b/packages/table-devtools/src/components/RowsPanel.tsx index feb9f39f1c..519e147f55 100644 --- a/packages/table-devtools/src/components/RowsPanel.tsx +++ b/packages/table-devtools/src/components/RowsPanel.tsx @@ -51,12 +51,9 @@ export function RowsPanel() { () => table()?.store, (state) => state, ) - const tableOptions = useTableStore( - () => { - const tableInstance = table() - return tableInstance?.optionsStore ?? tableInstance?.store - }, - () => table()?.options as unknown, + const optionsStoreValue = useTableStore( + () => table()?.optionsStore, + (options) => options, ) const [selectedRowModel, setSelectedRowModel] = @@ -66,8 +63,7 @@ export function RowsPanel() { const tableInstance = table() if (!tableInstance) return undefined - tableState() - tableOptions() + optionsStoreValue() const data = tableInstance.options.data as ReadonlyArray if (!Array.isArray(data)) return data @@ -79,8 +75,7 @@ export function RowsPanel() { const tableInstance = table() if (!tableInstance) return 0 - tableState() - tableOptions() + optionsStoreValue() const data = tableInstance.options.data as ReadonlyArray return Array.isArray(data) ? data.length : 0 @@ -91,7 +86,7 @@ export function RowsPanel() { if (!tableInstance) return [] tableState() - tableOptions() + optionsStoreValue() const tableWithColumnFns = tableInstance as unknown as { getVisibleLeafColumns?: () => Array @@ -220,18 +215,25 @@ export function RowsPanel() { - {(row) => ( - - {row.id} - - {(cell) => ( - - {stringifyValue(cell.getValue())} - - )} - - - )} + {(row) => { + const cells = createMemo(() => { + tableState() + return getCells(row) + }) + + return ( + + {row.id} + + {(cell) => ( + + {stringifyValue(cell.getValue())} + + )} + + + ) + }} diff --git a/packages/table-devtools/src/components/Shell.tsx b/packages/table-devtools/src/components/Shell.tsx index 3da49071db..56ecbdb32c 100644 --- a/packages/table-devtools/src/components/Shell.tsx +++ b/packages/table-devtools/src/components/Shell.tsx @@ -25,7 +25,6 @@ export function Shell() { setActiveTab, selectedTargetId, setSelectedTargetId, - table, targets, } = useTableDevtoolsContext() @@ -81,8 +80,8 @@ export function Shell() {
- - {(_table) => ( + + {(_selectedTargetId) => ( diff --git a/packages/table-devtools/src/components/StatePanel.tsx b/packages/table-devtools/src/components/StatePanel.tsx index 0095b61075..8e6c4efefb 100644 --- a/packages/table-devtools/src/components/StatePanel.tsx +++ b/packages/table-devtools/src/components/StatePanel.tsx @@ -6,15 +6,11 @@ import { useTableStore } from '../useTableStore' import { useStyles } from '../styles/use-styles' import { NoTableConnected } from './NoTableConnected' import { ThreeWayResizableSplit } from './ThreeWayResizableSplit' +import type { Accessor } from 'solid-js' +import type { TableDevtoolsStyles } from '../styles/use-styles' type AtomSource = 'external-atom' | 'external-state' | 'internal' -interface AtomSlice { - key: string - value: unknown - source: AtomSource -} - export function StatePanel() { const styles = useStyles() const { table } = useTableDevtoolsContext() @@ -22,75 +18,62 @@ export function StatePanel() { const [storeCopied, setStoreCopied] = createSignal(false) const [pasteError, setPasteError] = createSignal(null) - // Subscribe to both stores so the panel re-renders when either the table - // state or the options (e.g. options.atoms / options.state) change. const tableState = useTableStore( () => table()?.store, (state) => state, ) - const tableOptions = useTableStore( - () => { - const tableInstance = table() - return tableInstance?.optionsStore ?? tableInstance?.store - }, - () => table()?.options as unknown, + const optionsStoreValue = useTableStore( + () => table()?.optionsStore, + (options) => options, ) const initialState = createMemo((): unknown => { const tableInstance = table() if (!tableInstance) return undefined - tableState() - tableOptions() - return tableInstance.initialState }) - const storeState = createMemo((): unknown => { + const storeState = createMemo((): Record | undefined => { const tableInstance = table() if (!tableInstance) return undefined - tableState() - tableOptions() - - return tableInstance.store.state + return (tableState() ?? tableInstance.store.get()) as Record< + string, + unknown + > }) - const atomSlices = createMemo((): Array => { + const tableOptions = createMemo | undefined>(() => { const tableInstance = table() - if (!tableInstance) return [] + if (!tableInstance) return undefined + + return (optionsStoreValue() ?? tableInstance.options) as Record< + string, + unknown + > + }) - // Touch subscriptions so this recomputes on state or option change. - tableState() - tableOptions() + const atomKeys = createMemo(() => Object.keys(storeState() ?? {})) - const options = tableInstance.options as unknown as Record + const getAtomSource = (key: string): AtomSource => { + const options = tableOptions() ?? {} const externalAtoms = (options.atoms as Record | undefined) ?? {} const externalState = (options.state as Record | undefined) ?? {} - const storeState = tableInstance.store.state as Record - - return Object.keys(storeState).map((key) => { - const hasExternalAtom = externalAtoms[key] != null - const hasExternalState = - !hasExternalAtom && - key in externalState && - externalState[key] !== undefined - - const source: AtomSource = hasExternalAtom - ? 'external-atom' - : hasExternalState - ? 'external-state' - : 'internal' - - return { - key, - value: storeState[key], - source, - } - }) - }) + const hasExternalAtom = externalAtoms[key] != null + const hasExternalState = + !hasExternalAtom && + key in externalState && + externalState[key] !== undefined + + return hasExternalAtom + ? 'external-atom' + : hasExternalState + ? 'external-state' + : 'internal' + } const copyToClipboard = async ( value: unknown, @@ -177,8 +160,15 @@ export function StatePanel() { Reset to initialState
- - {(slice) => } + + {(key) => ( + getAtomSource(key)} + styles={styles} + value={() => storeState()?.[key]} + /> + )} } @@ -213,11 +203,14 @@ export function StatePanel() { ) } -function AtomRow(props: { slice: AtomSlice }) { - const styles = useStyles() - +function AtomRow(props: { + atomKey: string + source: Accessor + styles: Accessor + value: Accessor +}) { const badgeLabel = () => { - switch (props.slice.source) { + switch (props.source()) { case 'external-atom': return 'External Atom' case 'external-state': @@ -228,25 +221,25 @@ function AtomRow(props: { slice: AtomSlice }) { } const badgeClass = () => { - const base = styles().atomBadge - switch (props.slice.source) { + const base = props.styles().atomBadge + switch (props.source()) { case 'external-atom': - return `${base} ${styles().atomBadgeExternalAtom}` + return `${base} ${props.styles().atomBadgeExternalAtom}` case 'external-state': - return `${base} ${styles().atomBadgeExternalState}` + return `${base} ${props.styles().atomBadgeExternalState}` case 'internal': - return `${base} ${styles().atomBadgeInternal}` + return `${base} ${props.styles().atomBadgeInternal}` } } return ( -
-
- {props.slice.key} +
+
+ {props.atomKey} {badgeLabel()}
-
- +
+
) diff --git a/packages/table-devtools/src/components/ThreeWayResizableSplit.tsx b/packages/table-devtools/src/components/ThreeWayResizableSplit.tsx index 3a5803172e..098752c91b 100644 --- a/packages/table-devtools/src/components/ThreeWayResizableSplit.tsx +++ b/packages/table-devtools/src/components/ThreeWayResizableSplit.tsx @@ -1,4 +1,4 @@ -import { createSignal } from 'solid-js' +import { createSignal, onCleanup } from 'solid-js' import { useStyles } from '../styles/use-styles' import type { JSX } from 'solid-js' @@ -17,15 +17,24 @@ export function ThreeWayResizableSplit(props: ThreeWayResizableSplitProps) { const styles = useStyles() const [leftPercent, setLeftPercent] = createSignal(DEFAULT_LEFT_PERCENT) const [middlePercent, setMiddlePercent] = createSignal(DEFAULT_MIDDLE_PERCENT) + let cleanupDrag: (() => void) | undefined + + onCleanup(() => { + cleanupDrag?.() + }) const makeDragHandler = (which: 'left' | 'right'): ((e: MouseEvent) => void) => // eslint-disable-next-line solid/reactivity (e) => { e.preventDefault() + cleanupDrag?.() + const handleEl = e.currentTarget as HTMLElement const container = handleEl.parentElement if (!container) return + const previousCursor = document.body.style.cursor + const previousUserSelect = document.body.style.userSelect const startLeft = leftPercent() const startMiddle = middlePercent() @@ -56,12 +65,14 @@ export function ThreeWayResizableSplit(props: ThreeWayResizableSplitProps) { } } - const onMouseUp = () => { + cleanupDrag = () => { document.removeEventListener('mousemove', onMouseMove) document.removeEventListener('mouseup', onMouseUp) - document.body.style.cursor = '' - document.body.style.userSelect = '' + document.body.style.cursor = previousCursor + document.body.style.userSelect = previousUserSelect + cleanupDrag = undefined } + const onMouseUp = () => cleanupDrag?.() document.addEventListener('mousemove', onMouseMove) document.addEventListener('mouseup', onMouseUp) diff --git a/packages/table-devtools/src/devtoolsUpdateScheduler.ts b/packages/table-devtools/src/devtoolsUpdateScheduler.ts new file mode 100644 index 0000000000..25550b71bc --- /dev/null +++ b/packages/table-devtools/src/devtoolsUpdateScheduler.ts @@ -0,0 +1,77 @@ +import { batch } from 'solid-js' + +const UPDATE_DELAY_MS = 32 +const IDLE_TIMEOUT_MS = 100 + +type ScheduledUpdate = () => void + +const pendingUpdates = new Set() + +let delayHandle: ReturnType | undefined +let idleHandle: number | undefined + +function flushUpdates() { + delayHandle = undefined + idleHandle = undefined + + const updates = Array.from(pendingUpdates) + pendingUpdates.clear() + + batch(() => { + for (const update of updates) { + update() + } + }) +} + +function cancelScheduledFlush() { + if (delayHandle !== undefined) { + clearTimeout(delayHandle) + delayHandle = undefined + } + + if ( + idleHandle !== undefined && + typeof globalThis.cancelIdleCallback === 'function' + ) { + globalThis.cancelIdleCallback(idleHandle) + idleHandle = undefined + } +} + +function requestFlush() { + if (delayHandle !== undefined || idleHandle !== undefined) { + return + } + + delayHandle = setTimeout(() => { + delayHandle = undefined + + if (typeof globalThis.requestIdleCallback === 'function') { + idleHandle = globalThis.requestIdleCallback(flushUpdates, { + timeout: IDLE_TIMEOUT_MS, + }) + return + } + + delayHandle = setTimeout(flushUpdates, 0) + }, UPDATE_DELAY_MS) +} + +/** + * Coalesces devtools-only reactive work and yields to the inspected app before + * flushing it. The returned function removes this update if its owner unmounts + * or its source changes before the queue is flushed. + */ +export function scheduleDevtoolsUpdate(update: ScheduledUpdate) { + pendingUpdates.add(update) + requestFlush() + + return () => { + pendingUpdates.delete(update) + + if (pendingUpdates.size === 0) { + cancelScheduledFlush() + } + } +} diff --git a/packages/table-devtools/src/index.ts b/packages/table-devtools/src/index.ts index 1c5d82106e..a1ff6af3e8 100644 --- a/packages/table-devtools/src/index.ts +++ b/packages/table-devtools/src/index.ts @@ -10,6 +10,7 @@ export const TableDevtoolsCore: ClassType = export type { TableDevtoolsInit } from './core' export { + createTableDevtoolsRegistrationManager, getTableDevtoolsTargets, removeTableDevtoolsTarget, setTableDevtoolsTarget, @@ -18,6 +19,7 @@ export { } from './tableTarget' export type { TableDevtoolsRegistration, + TableDevtoolsRegistrationManager, TableDevtoolsStore, TableDevtoolsTable, UpsertTableDevtoolsTargetOptions, diff --git a/packages/table-devtools/src/production.ts b/packages/table-devtools/src/production.ts index df414118c9..d39c265b89 100644 --- a/packages/table-devtools/src/production.ts +++ b/packages/table-devtools/src/production.ts @@ -4,6 +4,7 @@ export { TableDevtoolsCore } from './core' export type { TableDevtoolsInit } from './core' export { + createTableDevtoolsRegistrationManager, getTableDevtoolsTargets, removeTableDevtoolsTarget, setTableDevtoolsTarget, @@ -12,6 +13,7 @@ export { } from './tableTarget' export type { TableDevtoolsRegistration, + TableDevtoolsRegistrationManager, TableDevtoolsStore, TableDevtoolsTable, UpsertTableDevtoolsTargetOptions, diff --git a/packages/table-devtools/src/styles/use-styles.ts b/packages/table-devtools/src/styles/use-styles.ts index 2793262938..1eae90b6e2 100644 --- a/packages/table-devtools/src/styles/use-styles.ts +++ b/packages/table-devtools/src/styles/use-styles.ts @@ -1,5 +1,5 @@ import * as goober from 'goober' -import { createEffect, createSignal } from 'solid-js' +import { createMemo } from 'solid-js' import { createTheme } from '@tanstack/devtools-ui' import { tokens } from './tokens' @@ -398,11 +398,21 @@ const stylesFactory = (theme: 'light' | 'dark') => { export function useStyles() { const { theme } = createTheme() - const [styles, setStyles] = createSignal(stylesFactory(theme())) + const styles = createMemo(() => getStyles(theme())) + return styles +} + +export type TableDevtoolsStyles = ReturnType - createEffect(() => { - setStyles(stylesFactory(theme())) - }) +const stylesByTheme = new Map<'light' | 'dark', TableDevtoolsStyles>() + +function getStyles(theme: 'light' | 'dark'): TableDevtoolsStyles { + const cached = stylesByTheme.get(theme) + if (cached) { + return cached + } + const styles = stylesFactory(theme) + stylesByTheme.set(theme, styles) return styles } diff --git a/packages/table-devtools/src/tableTarget.ts b/packages/table-devtools/src/tableTarget.ts index ab6d4c4a90..36f4a41c70 100644 --- a/packages/table-devtools/src/tableTarget.ts +++ b/packages/table-devtools/src/tableTarget.ts @@ -34,6 +34,11 @@ export interface TableDevtoolsRegistration { table: TableDevtoolsTable } +interface TableDevtoolsRegistrationEntry { + registration: TableDevtoolsRegistration + leases: Set +} + export interface UpsertTableDevtoolsTargetOptions< TFeatures extends TableFeatures, TData extends RowData, @@ -41,8 +46,16 @@ export interface UpsertTableDevtoolsTargetOptions< table: Table } +export interface TableDevtoolsRegistrationManager { + update: ( + table: Table | undefined, + enabled?: boolean, + ) => void + dispose: () => void +} + const [registrationsMap, setRegistrationsMap] = createSignal< - Map + Map >(new Map()) function getTableKey(table: TableDevtoolsTable) { @@ -63,35 +76,61 @@ export function upsertTableDevtoolsTarget< } const registrations = untrack(registrationsMap) - const existingRegistration = registrations.get(key) - - if (existingRegistration) { - if (existingRegistration.table === table) { + const existingEntry = registrations.get(key) + const lease = Symbol(key) + + if (existingEntry) { + if ( + existingEntry.registration.table === table || + existingEntry.registration.table.store === table.store + ) { + if (existingEntry.registration.table !== table) { + Object.assign(existingEntry.registration.table, table) + } + existingEntry.leases.add(lease) return () => { - removeTableDevtoolsTarget(key) + releaseTableDevtoolsTarget(key, lease) } } const nextRegistrations = new Map(registrations) nextRegistrations.set(key, { - id: key, - table, + registration: { + id: key, + table, + }, + leases: new Set([lease]), }) setRegistrationsMap(nextRegistrations) } else { const nextRegistrations = new Map(registrations) nextRegistrations.set(key, { - id: key, - table, + registration: { + id: key, + table, + }, + leases: new Set([lease]), }) setRegistrationsMap(nextRegistrations) } return () => { - removeTableDevtoolsTarget(key) + releaseTableDevtoolsTarget(key, lease) } } +function releaseTableDevtoolsTarget(id: string, lease: symbol) { + const registrations = untrack(registrationsMap) + const entry = registrations.get(id) + if (!entry?.leases.delete(lease) || entry.leases.size > 0) { + return + } + + const nextRegistrations = new Map(registrations) + nextRegistrations.delete(id) + setRegistrationsMap(nextRegistrations) +} + export function removeTableDevtoolsTarget(id: string) { const registrations = untrack(registrationsMap) if (!registrations.has(id)) { @@ -105,7 +144,7 @@ export function removeTableDevtoolsTarget(id: string) { } export function getTableDevtoolsTargets(): Array { - return Array.from(registrationsMap().values()) + return Array.from(registrationsMap().values(), (entry) => entry.registration) } export function subscribeTableDevtoolsTargets(listener: Listener) { @@ -129,3 +168,25 @@ export function setTableDevtoolsTarget< upsertTableDevtoolsTarget({ table }) } + +export function createTableDevtoolsRegistrationManager(): TableDevtoolsRegistrationManager { + let cleanup: (() => void) | undefined + + return { + update: (table, enabled = true) => { + if (!enabled || !table) { + cleanup?.() + cleanup = undefined + return + } + + const previousCleanup = cleanup + cleanup = upsertTableDevtoolsTarget({ table }) + previousCleanup?.() + }, + dispose: () => { + cleanup?.() + cleanup = undefined + }, + } +} diff --git a/packages/table-devtools/src/useTableStore.ts b/packages/table-devtools/src/useTableStore.ts index 70fbfa2ec4..f2af769a21 100644 --- a/packages/table-devtools/src/useTableStore.ts +++ b/packages/table-devtools/src/useTableStore.ts @@ -1,31 +1,60 @@ import { createEffect, createSignal, onCleanup } from 'solid-js' +import { scheduleDevtoolsUpdate } from './devtoolsUpdateScheduler' import type { Accessor } from 'solid-js' import type { Readable } from '@tanstack/solid-store' +interface UseTableStoreOptions { + equals?: false | ((previous: U | undefined, next: U | undefined) => boolean) +} + /** * Subscribes to a table store and returns a reactive signal. - * Handles both subscribe APIs: function return (store 0.8.x) and - * { unsubscribe } object return (store 0.9.x). + * Handles both function and `{ unsubscribe }` subscription results. */ export function useTableStore( storeAccessor: Accessor | null | undefined>, selector: (state: T) => U = (s) => s as unknown as U, + options?: UseTableStoreOptions, ): Accessor { - const initialValue = storeAccessor()?.get() - const [signal, setSignal] = createSignal( - initialValue ? selector(initialValue) : undefined, + const initialStore = storeAccessor() + const [signal, setSignal] = createSignal( + initialStore ? selector(initialStore.get()) : undefined, + { equals: options?.equals }, ) createEffect(() => { const store = storeAccessor() - if (!store) return + let cancelPendingUpdate: (() => void) | undefined - const subscription = store.subscribe(() => { - setSignal(() => selector(store.get())) - }) + if (!store) { + setSignal(() => undefined) + return + } + + setSignal(() => selector(store.get())) + + let latestValue: U + const subscription = store.subscribe((snapshot) => { + latestValue = selector(snapshot) + + if (cancelPendingUpdate) { + return + } + + cancelPendingUpdate = scheduleDevtoolsUpdate(() => { + cancelPendingUpdate = undefined + setSignal(() => latestValue) + }) + }) as unknown as { unsubscribe: () => void } | (() => void) onCleanup(() => { - subscription.unsubscribe() + cancelPendingUpdate?.() + + if (typeof subscription === 'function') { + subscription() + } else { + subscription.unsubscribe() + } }) }) diff --git a/packages/table-devtools/tests/panel-lifecycle.test.tsx b/packages/table-devtools/tests/panel-lifecycle.test.tsx new file mode 100644 index 0000000000..7ec8bdb399 --- /dev/null +++ b/packages/table-devtools/tests/panel-lifecycle.test.tsx @@ -0,0 +1,68 @@ +import { createSignal } from 'solid-js' +import { render } from 'solid-js/web' +import { afterEach, describe, expect, it, vi } from 'vitest' +import TableDevtools from '../src/TableDevtools' +import { + getTableDevtoolsTargets, + removeTableDevtoolsTarget, + upsertTableDevtoolsTarget, +} from '../src/tableTarget' +import type { TableDevtoolsTable } from '../src/tableTarget' + +function createDevtoolsTable() { + const unsubscribe = vi.fn() + const subscribe = vi.fn(() => ({ unsubscribe })) + const table = { + _features: {}, + _rowModelFns: {}, + baseAtoms: {}, + initialState: {}, + options: { + features: {}, + key: 'users-table', + }, + reset: vi.fn(), + store: { + get: () => ({}), + state: {}, + subscribe, + }, + } satisfies TableDevtoolsTable + + return { subscribe, table, unsubscribe } +} + +afterEach(() => { + for (const target of getTableDevtoolsTargets()) { + removeTableDevtoolsTarget(target.id) + } +}) + +describe('TableDevtools panel lifecycle', () => { + it('subscribes only while the devtools panel is open', async () => { + const { subscribe, table, unsubscribe } = createDevtoolsTable() + const cleanupTarget = upsertTableDevtoolsTarget({ + table: table as never, + }) + const [open, setOpen] = createSignal(false) + const element = document.createElement('div') + const dispose = render( + () => , + element, + ) + + await Promise.resolve() + expect(subscribe).not.toHaveBeenCalled() + + setOpen(true) + await Promise.resolve() + expect(subscribe).toHaveBeenCalledTimes(1) + + setOpen(false) + await Promise.resolve() + expect(unsubscribe).toHaveBeenCalledTimes(1) + + dispose() + cleanupTarget?.() + }) +}) diff --git a/packages/table-devtools/tests/styles.test.tsx b/packages/table-devtools/tests/styles.test.tsx new file mode 100644 index 0000000000..28dc5444f3 --- /dev/null +++ b/packages/table-devtools/tests/styles.test.tsx @@ -0,0 +1,51 @@ +import { createSignal } from 'solid-js' +import { render } from 'solid-js/web' +import { ThemeContextProvider } from '@tanstack/devtools-ui' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { css } = vi.hoisted(() => ({ + css: vi.fn(() => 'generated-class'), +})) + +vi.mock('goober', () => ({ + css, +})) + +beforeEach(() => { + css.mockClear() +}) + +describe('useStyles', () => { + it('generates each theme once across consumers', async () => { + const { useStyles } = await import('../src/styles/use-styles') + const [theme, setTheme] = createSignal<'light' | 'dark'>('dark') + + function Consumer() { + useStyles() + useStyles() + return null + } + + const element = document.createElement('div') + const dispose = render( + () => ( + + + + ), + element, + ) + + expect(css).toHaveBeenCalledTimes(51) + + setTheme('light') + await Promise.resolve() + expect(css).toHaveBeenCalledTimes(102) + + setTheme('dark') + await Promise.resolve() + expect(css).toHaveBeenCalledTimes(102) + + dispose() + }) +}) diff --git a/packages/table-devtools/tests/tableTarget.test.ts b/packages/table-devtools/tests/tableTarget.test.ts index 0f1d1082ea..4d92f4c6a3 100644 --- a/packages/table-devtools/tests/tableTarget.test.ts +++ b/packages/table-devtools/tests/tableTarget.test.ts @@ -2,8 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { constructTable, coreFeatures } from '@tanstack/table-core' import { storeReactivityBindings } from '@tanstack/table-core/store-reactivity-bindings' import { + createTableDevtoolsRegistrationManager, getTableDevtoolsTargets, removeTableDevtoolsTarget, + subscribeTableDevtoolsTargets, upsertTableDevtoolsTarget, } from '../src/tableTarget' @@ -64,6 +66,100 @@ describe('tableTarget', () => { expect(getTableDevtoolsTargets()).toEqual([]) }) + it('does not let stale cleanup remove a replacement table', () => { + const firstTable = createTable('users-table') + const nextTable = createTable('users-table') + + const cleanupFirst = upsertTableDevtoolsTarget({ table: firstTable }) + const cleanupNext = upsertTableDevtoolsTarget({ table: nextTable }) + + cleanupFirst?.() + + expect(getTableDevtoolsTargets()).toEqual([ + { + id: 'users-table', + table: nextTable, + }, + ]) + + cleanupNext?.() + expect(getTableDevtoolsTargets()).toEqual([]) + }) + + it('keeps a shared table registered until its final lease is released', () => { + const table = createTable('users-table') + + const cleanupFirst = upsertTableDevtoolsTarget({ table }) + const cleanupSecond = upsertTableDevtoolsTarget({ table }) + + cleanupFirst?.() + expect(getTableDevtoolsTargets()).toHaveLength(1) + + cleanupSecond?.() + expect(getTableDevtoolsTargets()).toEqual([]) + }) + + it('replaces a managed registration without publishing an empty registry', () => { + const manager = createTableDevtoolsRegistrationManager() + const firstTable = createTable('users-table') + const nextTable = createTable('users-table') + + manager.update(firstTable) + + const targetCounts: Array = [] + const unsubscribe = subscribeTableDevtoolsTargets((targets) => { + targetCounts.push(targets.length) + }) + + manager.update(nextTable) + + expect(targetCounts).toEqual([1, 1]) + expect(getTableDevtoolsTargets()).toEqual([ + { + id: 'users-table', + table: nextTable, + }, + ]) + + manager.dispose() + + expect(targetCounts).toEqual([1, 1, 0]) + unsubscribe() + }) + + it('updates transient wrappers without publishing a new target', () => { + const manager = createTableDevtoolsRegistrationManager() + const table = createTable('users-table') + const firstWrapper = { + ...table, + options: { + ...table.options, + debugAll: false, + }, + } + const nextWrapper = { + ...table, + options: { + ...table.options, + debugAll: true, + }, + } + + manager.update(firstWrapper) + + const listener = vi.fn() + const unsubscribe = subscribeTableDevtoolsTargets(listener) + + manager.update(nextWrapper) + + expect(listener).toHaveBeenCalledTimes(1) + expect(getTableDevtoolsTargets()[0]?.table).toBe(firstWrapper) + expect(getTableDevtoolsTargets()[0]?.table.options.debugAll).toBe(true) + + unsubscribe() + manager.dispose() + }) + it('logs and skips registration when the key is missing', () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) const table = createTable() diff --git a/packages/table-devtools/tests/useTableStore.test.tsx b/packages/table-devtools/tests/useTableStore.test.tsx new file mode 100644 index 0000000000..6c9269d7ae --- /dev/null +++ b/packages/table-devtools/tests/useTableStore.test.tsx @@ -0,0 +1,113 @@ +import { createRoot } from 'solid-js' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useTableStore } from '../src/useTableStore' +import type { Readable } from '@tanstack/solid-store' + +describe('useTableStore scheduling', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.runOnlyPendingTimers() + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it('coalesces snapshots and applies only the latest value', () => { + let listener: ((snapshot: number) => void) | undefined + const unsubscribe = vi.fn() + const store = { + get: () => 0, + subscribe: (nextListener) => { + listener = + typeof nextListener === 'function' ? nextListener : nextListener.next + return { unsubscribe } + }, + } satisfies Readable + + let dispose = () => {} + const value = createRoot((disposeRoot) => { + dispose = disposeRoot + return useTableStore(() => store) + }) + + expect(value()).toBe(0) + + listener?.(1) + listener?.(2) + listener?.(3) + + expect(value()).toBe(0) + + vi.runAllTimers() + + expect(value()).toBe(3) + + dispose() + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('cancels a queued update when its owner is disposed', () => { + let listener: ((snapshot: number) => void) | undefined + const store = { + get: () => 0, + subscribe: (nextListener) => { + listener = + typeof nextListener === 'function' ? nextListener : nextListener.next + return { unsubscribe: vi.fn() } + }, + } satisfies Readable + + let dispose = () => {} + const value = createRoot((disposeRoot) => { + dispose = disposeRoot + return useTableStore(() => store) + }) + + listener?.(1) + dispose() + vi.runAllTimers() + + expect(value()).toBe(0) + }) + + it('uses requestIdleCallback when the browser provides it', () => { + const requestIdleCallback = vi + .fn<(callback: IdleRequestCallback) => number>() + .mockImplementation((callback) => { + callback({ + didTimeout: false, + timeRemaining: () => 10, + }) + return 1 + }) + + vi.stubGlobal('requestIdleCallback', requestIdleCallback) + vi.stubGlobal('cancelIdleCallback', vi.fn()) + + let listener: ((snapshot: number) => void) | undefined + const store = { + get: () => 0, + subscribe: (nextListener) => { + listener = + typeof nextListener === 'function' ? nextListener : nextListener.next + return { unsubscribe: vi.fn() } + }, + } satisfies Readable + + let dispose = () => {} + const value = createRoot((disposeRoot) => { + dispose = disposeRoot + return useTableStore(() => store) + }) + + listener?.(1) + vi.advanceTimersByTime(32) + + expect(requestIdleCallback).toHaveBeenCalledTimes(1) + expect(value()).toBe(1) + + dispose() + }) +}) diff --git a/packages/vue-table-devtools/src/VueTableDevtools.tsx b/packages/vue-table-devtools/src/VueTableDevtools.tsx index ec9ba617d7..564229333e 100644 --- a/packages/vue-table-devtools/src/VueTableDevtools.tsx +++ b/packages/vue-table-devtools/src/VueTableDevtools.tsx @@ -36,16 +36,17 @@ function createPanelWrapper( name, props: ['theme', 'devtoolsOpen'], setup(props: TableDevtoolsVueInit) { - const devtoolsProps = { - theme: props.theme ?? 'dark', - devtoolsOpen: props.devtoolsOpen ?? false, - } - - return () => - h(Component, { + return () => { + const devtoolsProps = { + theme: props.theme ?? 'dark', + devtoolsOpen: props.devtoolsOpen ?? false, + } + return h(Component, { + key: `${devtoolsProps.theme}:${devtoolsProps.devtoolsOpen}`, props, devtoolsProps, }) + } }, }) as DefineComponent } diff --git a/packages/vue-table-devtools/src/production.ts b/packages/vue-table-devtools/src/production.ts index 5823644b5a..88724a9295 100644 --- a/packages/vue-table-devtools/src/production.ts +++ b/packages/vue-table-devtools/src/production.ts @@ -1,5 +1,5 @@ -export { TableDevtoolsPanel } from './VueTableDevtools' +export { TableDevtoolsPanel } from './production/VueTableDevtools' export type { TableDevtoolsVueInit } from './VueTableDevtools' -export { tableDevtoolsPlugin } from './plugin' +export { tableDevtoolsPlugin } from './production/plugin' export { useTanStackTableDevtools } from './useTanStackTableDevtools' export type { UseTanStackTableDevtoolsOptions } from './useTanStackTableDevtools' diff --git a/packages/vue-table-devtools/src/production/VueTableDevtools.tsx b/packages/vue-table-devtools/src/production/VueTableDevtools.tsx new file mode 100644 index 0000000000..d9ad422562 --- /dev/null +++ b/packages/vue-table-devtools/src/production/VueTableDevtools.tsx @@ -0,0 +1,46 @@ +import { createVuePanel } from '@tanstack/devtools-utils/vue' +import { TableDevtoolsCore } from '@tanstack/table-devtools/production' +import { defineComponent, h } from 'vue' +import type { DevtoolsPanelProps } from '@tanstack/devtools-utils/vue' +import type { DefineComponent } from 'vue' +import type { TableDevtoolsVueInit } from '../VueTableDevtools' + +class TableDevtoolsVueCore { + private readonly core = new TableDevtoolsCore() + + constructor(_props: DevtoolsPanelProps) {} + + mount(el: HTMLElement, props?: DevtoolsPanelProps) { + void this.core.mount(el, { + theme: props?.theme ?? 'dark', + devtoolsOpen: props?.devtoolsOpen ?? false, + }) + } + + unmount() { + this.core.unmount() + } +} + +const [TableDevtoolsPanelBase] = createVuePanel< + DevtoolsPanelProps, + TableDevtoolsVueCore +>(TableDevtoolsVueCore) + +export const TableDevtoolsPanel = defineComponent({ + name: 'TableDevtoolsPanel', + props: ['theme', 'devtoolsOpen'], + setup(props: TableDevtoolsVueInit) { + return () => { + const devtoolsProps = { + theme: props.theme ?? 'dark', + devtoolsOpen: props.devtoolsOpen ?? false, + } + return h(TableDevtoolsPanelBase, { + key: `${devtoolsProps.theme}:${devtoolsProps.devtoolsOpen}`, + props, + devtoolsProps, + }) + } + }, +}) as DefineComponent diff --git a/packages/vue-table-devtools/src/production/plugin.ts b/packages/vue-table-devtools/src/production/plugin.ts new file mode 100644 index 0000000000..c20a591a12 --- /dev/null +++ b/packages/vue-table-devtools/src/production/plugin.ts @@ -0,0 +1,9 @@ +import { createVuePlugin } from '@tanstack/devtools-utils/vue' +import { TableDevtoolsPanel } from './VueTableDevtools' + +const [tableDevtoolsPlugin] = createVuePlugin( + 'TanStack Table', + TableDevtoolsPanel, +) + +export { tableDevtoolsPlugin } diff --git a/packages/vue-table-devtools/src/useTanStackTableDevtools.ts b/packages/vue-table-devtools/src/useTanStackTableDevtools.ts index 23da5d8b59..feef50dbb0 100644 --- a/packages/vue-table-devtools/src/useTanStackTableDevtools.ts +++ b/packages/vue-table-devtools/src/useTanStackTableDevtools.ts @@ -1,5 +1,5 @@ -import { unref, watchEffect } from 'vue' -import { upsertTableDevtoolsTarget } from '@tanstack/table-devtools' +import { onScopeDispose, unref, watchEffect } from 'vue' +import { createTableDevtoolsRegistrationManager } from '@tanstack/table-devtools' import type { RowData, Table, TableFeatures } from '@tanstack/table-core' import type { MaybeRef } from 'vue' @@ -14,19 +14,14 @@ export function useTanStackTableDevtools< table: MaybeRef | undefined>, options?: MaybeRef, ): void { - watchEffect((onCleanup) => { + const registration = createTableDevtoolsRegistrationManager() + onScopeDispose(() => registration.dispose()) + + watchEffect(() => { const resolvedOptions = unref(options) const resolvedTable = unref(table) - if (!(resolvedOptions?.enabled ?? true) || !resolvedTable) { - return - } - - const cleanup = upsertTableDevtoolsTarget({ table: resolvedTable }) - - onCleanup(() => { - cleanup?.() - }) + registration.update(resolvedTable, resolvedOptions?.enabled ?? true) }) }