diff --git a/examples/angular/basic-app-table/package.json b/examples/angular/basic-app-table/package.json index 4b4b7cb703..dc3da4ee65 100644 --- a/examples/angular/basic-app-table/package.json +++ b/examples/angular/basic-app-table/package.json @@ -17,7 +17,9 @@ "@angular/forms": "^21.2.13", "@angular/platform-browser": "^21.2.13", "@faker-js/faker": "^10.4.0", + "@tanstack/angular-devtools": "^0.0.4", "@tanstack/angular-table": "^9.0.0-alpha.49", + "@tanstack/angular-table-devtools": "^9.0.0-alpha.43", "rxjs": "~7.8.2", "tslib": "^2.8.1" }, diff --git a/examples/angular/basic-app-table/src/app/app.config.ts b/examples/angular/basic-app-table/src/app/app.config.ts index cbb47d366c..00db520fb7 100644 --- a/examples/angular/basic-app-table/src/app/app.config.ts +++ b/examples/angular/basic-app-table/src/app/app.config.ts @@ -1,6 +1,22 @@ -import { provideBrowserGlobalErrorListeners } from '@angular/core' +import { isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core' +import { provideTanStackDevtools } from '@tanstack/angular-devtools/provider' import type { ApplicationConfig } from '@angular/core' export const appConfig: ApplicationConfig = { - providers: [provideBrowserGlobalErrorListeners()], + providers: [ + provideBrowserGlobalErrorListeners(), + isDevMode() + ? provideTanStackDevtools(() => ({ + plugins: [ + { + name: 'TanStack Table', + render: () => + import('@tanstack/angular-table-devtools').then((m) => + m.TableDevtoolsPanel(), + ), + }, + ], + })) + : [], + ], } diff --git a/examples/angular/basic-app-table/src/app/app.html b/examples/angular/basic-app-table/src/app/app.html index bb2de8903e..a5eff916f6 100644 --- a/examples/angular/basic-app-table/src/app/app.html +++ b/examples/angular/basic-app-table/src/app/app.html @@ -1,17 +1,16 @@
{{ stateJson() }}
Rows per page
{JSON.stringify(table.store.state, null, 2)}
{JSON.stringify(table.state(), null, 2)}
- {JSON.stringify(table.store.state, null, 2)} + {JSON.stringify(table.state(), null, 2)}
- {JSON.stringify(table.store.state, null, 2)} -
{JSON.stringify(table.store.state, null, 2) + {JSON.stringify(table.state, null, 2) } diff --git a/examples/svelte/column-ordering/src/App.svelte b/examples/svelte/column-ordering/src/App.svelte index f1048ecd87..54c1e31879 100644 --- a/examples/svelte/column-ordering/src/App.svelte +++ b/examples/svelte/column-ordering/src/App.svelte @@ -179,6 +179,6 @@ {/each} - {JSON.stringify(table.store.state.columnOrder, null, 2) + {JSON.stringify(table.state.columnOrder, null, 2) } diff --git a/examples/svelte/column-pinning/src/App.svelte b/examples/svelte/column-pinning/src/App.svelte index 11dcf6c044..aae9ecf419 100644 --- a/examples/svelte/column-pinning/src/App.svelte +++ b/examples/svelte/column-pinning/src/App.svelte @@ -303,5 +303,5 @@ } - {JSON.stringify(table.store.state.columnPinning, null, 2)} + {JSON.stringify(table.state.columnPinning, null, 2)} diff --git a/examples/svelte/column-resizing-performant/src/App.svelte b/examples/svelte/column-resizing-performant/src/App.svelte index 7fe5d770fc..905373dae2 100644 --- a/examples/svelte/column-resizing-performant/src/App.svelte +++ b/examples/svelte/column-resizing-performant/src/App.svelte @@ -111,7 +111,7 @@ - {JSON.stringify(table.store.state, null, 2)} + {JSON.stringify(table.state, null, 2)} ({data.length.toLocaleString()} rows) diff --git a/examples/svelte/column-resizing/src/App.svelte b/examples/svelte/column-resizing/src/App.svelte index acd70ce285..1f32cda8a3 100644 --- a/examples/svelte/column-resizing/src/App.svelte +++ b/examples/svelte/column-resizing/src/App.svelte @@ -99,7 +99,7 @@ header: ReturnType[number]['headers'][number], ) { if (columnResizeMode === 'onEnd' && header.column.getIsResizing()) { - const delta = table.store.state.columnResizing.deltaOffset ?? 0 + const delta = table.state.columnResizing.deltaOffset ?? 0 const dir = table.options.columnResizeDirection === 'rtl' ? -1 : 1 return `translateX(${dir * delta }px)` @@ -271,5 +271,5 @@ - {JSON.stringify(table.store.state, null, 2)} + {JSON.stringify(table.state, null, 2)} diff --git a/examples/svelte/column-sizing/src/App.svelte b/examples/svelte/column-sizing/src/App.svelte index a547912812..beefd91afe 100644 --- a/examples/svelte/column-sizing/src/App.svelte +++ b/examples/svelte/column-sizing/src/App.svelte @@ -85,7 +85,7 @@ value={column.getSize()} oninput={(e) => { table.setColumnSizing({ - ...table.store.state.columnSizing, + ...table.state.columnSizing, [column.id]: Number((e.target as HTMLInputElement).value), }) }} @@ -201,5 +201,5 @@ - {JSON.stringify(table.store.state, null, 2)} + {JSON.stringify(table.state, null, 2)} diff --git a/examples/svelte/column-visibility/src/App.svelte b/examples/svelte/column-visibility/src/App.svelte index e9b3dcd0e1..3fdc5fa7d8 100644 --- a/examples/svelte/column-visibility/src/App.svelte +++ b/examples/svelte/column-visibility/src/App.svelte @@ -170,6 +170,6 @@ - {JSON.stringify(table.store.state.columnVisibility, null, 2) + {JSON.stringify(table.state.columnVisibility, null, 2) } diff --git a/examples/svelte/composable-tables/src/components/PaginationControls.svelte b/examples/svelte/composable-tables/src/components/PaginationControls.svelte index ee6f9ad636..e56b58121f 100644 --- a/examples/svelte/composable-tables/src/components/PaginationControls.svelte +++ b/examples/svelte/composable-tables/src/components/PaginationControls.svelte @@ -38,7 +38,7 @@ Page - {(table.store.state.pagination.pageIndex + 1).toLocaleString()} of {table.getPageCount().toLocaleString()} + {(table.state.pagination.pageIndex + 1).toLocaleString()} of {table.getPageCount().toLocaleString()} @@ -47,7 +47,7 @@ type="number" min="1" max={table.getPageCount()} - value={table.store.state.pagination.pageIndex + 1} + value={table.state.pagination.pageIndex + 1} onchange={(e) => { const page = e.currentTarget.value ? Number(e.currentTarget.value) - 1 : 0 table.setPageIndex(page) @@ -55,7 +55,7 @@ /> { table.setPageSize(Number(e.currentTarget.value)) }} diff --git a/examples/svelte/composable-tables/src/components/ProductsTable.svelte b/examples/svelte/composable-tables/src/components/ProductsTable.svelte index eba8efbdf6..596db98a56 100644 --- a/examples/svelte/composable-tables/src/components/ProductsTable.svelte +++ b/examples/svelte/composable-tables/src/components/ProductsTable.svelte @@ -62,14 +62,14 @@ }) // Reactive derived values from table state - let sorting = $derived(table.store.state.sorting) - let columnFilters = $derived(table.store.state.columnFilters) + let sorting = $derived(table.state.sorting) + let columnFilters = $derived(table.state.columnFilters) // IMPORTANT: Derive rows from table state so Svelte tracks the dependency. // We must read a $state value that changes on every table update. // JSON.stringify forces a deep read, ensuring Svelte sees the dependency. const rows = $derived.by(() => { - JSON.stringify(table.store.state) + JSON.stringify(table.state) return table.getRowModel().rows }) diff --git a/examples/svelte/composable-tables/src/components/UsersTable.svelte b/examples/svelte/composable-tables/src/components/UsersTable.svelte index ed85bff648..a83910e657 100644 --- a/examples/svelte/composable-tables/src/components/UsersTable.svelte +++ b/examples/svelte/composable-tables/src/components/UsersTable.svelte @@ -72,16 +72,16 @@ }) // Reactive derived values from table state. - // Reading table.store.state creates a $state dependency (via the notifier) + // Reading table.state creates a $state dependency (via the notifier) // that triggers re-renders when any table state changes. - let sorting = $derived(table.store.state.sorting) - let columnFilters = $derived(table.store.state.columnFilters) + let sorting = $derived(table.state.sorting) + let columnFilters = $derived(table.state.columnFilters) // IMPORTANT: Derive rows from table state so Svelte tracks the dependency. // We must read a $state value that changes on every table update. // JSON.stringify forces a deep read, ensuring Svelte sees the dependency. const rows = $derived.by(() => { - JSON.stringify(table.store.state) + JSON.stringify(table.state) return table.getRowModel().rows }) diff --git a/examples/svelte/filters-fuzzy/src/App.svelte b/examples/svelte/filters-fuzzy/src/App.svelte index 6034180890..e562313191 100644 --- a/examples/svelte/filters-fuzzy/src/App.svelte +++ b/examples/svelte/filters-fuzzy/src/App.svelte @@ -107,8 +107,8 @@ ) $effect(() => { - if (table.store.state.columnFilters[0]?.id === 'fullName') { - if (table.store.state.sorting[0]?.id !== 'fullName') { + if (table.state.columnFilters[0]?.id === 'fullName') { + if (table.state.sorting[0]?.id !== 'fullName') { table.setSorting([{ id: 'fullName', desc: false }]) } diff --git a/examples/vue/column-visibility/src/App.tsx b/examples/vue/column-visibility/src/App.tsx index 0d400685be..faca800839 100644 --- a/examples/vue/column-visibility/src/App.tsx +++ b/examples/vue/column-visibility/src/App.tsx @@ -182,7 +182,7 @@ export default defineComponent({ - {JSON.stringify(table.store.state, null, 2)} + {JSON.stringify(table.state, null, 2)} ) }, diff --git a/examples/vue/expanding/src/App.tsx b/examples/vue/expanding/src/App.tsx index 4bad74a9ba..a72af6bb64 100644 --- a/examples/vue/expanding/src/App.tsx +++ b/examples/vue/expanding/src/App.tsx @@ -294,7 +294,7 @@ export default defineComponent({ Page - {(table.store.state.pagination.pageIndex + 1).toLocaleString()} of{' '} + {(table.state.pagination.pageIndex + 1).toLocaleString()} of{' '} {table.getPageCount().toLocaleString()} @@ -304,7 +304,7 @@ export default defineComponent({ type="number" min="1" max={table.getPageCount()} - value={table.store.state.pagination.pageIndex + 1} + value={table.state.pagination.pageIndex + 1} onInput={(event: Event) => { const target = event.currentTarget as HTMLInputElement const page = target.value ? Number(target.value) - 1 : 0 @@ -314,7 +314,7 @@ export default defineComponent({ /> { const target = event.currentTarget as HTMLSelectElement table.setPageSize(Number(target.value)) @@ -328,7 +328,7 @@ export default defineComponent({ {table.getRowModel().rows.length.toLocaleString()} Rows - {JSON.stringify(table.store.state, null, 2)} + {JSON.stringify(table.state, null, 2)} ) }, diff --git a/packages/angular-table-devtools/eslint.config.js b/packages/angular-table-devtools/eslint.config.js new file mode 100644 index 0000000000..892f5314df --- /dev/null +++ b/packages/angular-table-devtools/eslint.config.js @@ -0,0 +1,8 @@ +// @ts-check + +import rootConfig from '../../eslint.config.js' + +/** @type {any} */ +const config = [...rootConfig] + +export default config diff --git a/packages/angular-table-devtools/package.json b/packages/angular-table-devtools/package.json new file mode 100644 index 0000000000..6d5d25e4c4 --- /dev/null +++ b/packages/angular-table-devtools/package.json @@ -0,0 +1,54 @@ +{ + "name": "@tanstack/angular-table-devtools", + "version": "9.0.0-alpha.43", + "description": "Angular devtools for TanStack Table.", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/table.git", + "directory": "packages/angular-table-devtools" + }, + "homepage": "https://tanstack.com/table", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "keywords": [ + "angular", + "tanstack", + "table", + "devtools" + ], + "scripts": { + "clean": "rimraf ./build && rimraf ./dist", + "test:eslint": "eslint ./src", + "test:lib": "vitest --passWithNoTests", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc", + "test:build": "publint --strict", + "build": "tsdown" + }, + "type": "module", + "types": "dist/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./production": "./dist/production.js", + "./package.json": "./package.json" + }, + "sideEffects": false, + "engines": { + "node": ">=20" + }, + "files": [ + "dist", + "src" + ], + "dependencies": { + "@tanstack/devtools-utils": "^0.5.0", + "@tanstack/table-core": "workspace:*", + "@tanstack/table-devtools": "workspace:*" + }, + "peerDependencies": { + "@angular/core": ">=21.0.0" + } +} diff --git a/packages/angular-table-devtools/src/TableDevtools.ts b/packages/angular-table-devtools/src/TableDevtools.ts new file mode 100644 index 0000000000..f8c9ff90d7 --- /dev/null +++ b/packages/angular-table-devtools/src/TableDevtools.ts @@ -0,0 +1,34 @@ +import { TableDevtoolsCore } from '@tanstack/table-devtools' +import { createAngularPanel } from '@tanstack/devtools-utils/angular' +import type { DevtoolsPanelProps } from '@tanstack/devtools-utils/angular' + +export interface TableDevtoolsAngularInit extends Partial {} + +const [TableDevtoolsPanelBase, TableDevtoolsPanelNoOpBase] = + createAngularPanel(TableDevtoolsCore) + +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 = TableDevtoolsPanelBase() + return panel(() => resolvePanelProps(props()), host) + } + +export const TableDevtoolsPanelNoOp: TableDevtoolsPanelComponent = + () => (props, host) => { + const panel = TableDevtoolsPanelNoOpBase() + return () => panel + } diff --git a/packages/angular-table-devtools/src/index.ts b/packages/angular-table-devtools/src/index.ts new file mode 100644 index 0000000000..224ae9fee8 --- /dev/null +++ b/packages/angular-table-devtools/src/index.ts @@ -0,0 +1,20 @@ +import { isDevMode } from '@angular/core' +import * as plugin from './plugin' +import * as Devtools from './TableDevtools' +import * as inject from './injectTanStackTableDevtools' + +export const TableDevtoolsPanel = isDevMode() + ? Devtools.TableDevtoolsPanel + : Devtools.TableDevtoolsPanelNoOp + +export const tableDevtoolsPlugin = isDevMode() + ? plugin.tableDevtoolsPlugin + : plugin.tableDevtoolsNoOpPlugin + +export type { TableDevtoolsAngularInit } from './TableDevtools' + +export type { InjectTanStackTableDevtoolsOptions } from './injectTanStackTableDevtools' + +export const injectTanStackTableDevtools = isDevMode() + ? inject.injectTanStackTableDevtools + : inject.injectTanStackTableDevtoolsNoOp diff --git a/packages/angular-table-devtools/src/injectTanStackTableDevtools.ts b/packages/angular-table-devtools/src/injectTanStackTableDevtools.ts new file mode 100644 index 0000000000..d21be1abf6 --- /dev/null +++ b/packages/angular-table-devtools/src/injectTanStackTableDevtools.ts @@ -0,0 +1,68 @@ +import { + removeTableDevtoolsTarget, + upsertTableDevtoolsTarget, +} from '@tanstack/table-devtools' +import { + APP_ID, + DestroyRef, + Injector, + assertInInjectionContext, + effect, + inject, +} from '@angular/core' +import type { Table } from '@tanstack/table-core' + +function normalizeName(name?: string) { + const trimmedName = name?.trim() + return trimmedName ? trimmedName : undefined +} + +let autoId = 0 +function generateId(): string { + const appId = inject(APP_ID) + return `tanstacktable-${appId}_${autoId++}${Date.now().toString(36)}` +} + +export interface InjectTanStackTableDevtoolsOptions { + table: Table | undefined + name: string + enabled?: () => boolean + injector?: Injector +} + +export function injectTanStackTableDevtools( + options: () => InjectTanStackTableDevtoolsOptions, +): void { + const registrationId = generateId() + const enabled = () => options().enabled?.() ?? true + assertInInjectionContext(injectTanStackTableDevtools) + const injector = options().injector ?? inject(Injector) + const destroyRef = inject(DestroyRef) + + effect( + (onCleanup) => { + const { table, name } = options() + const enabledValue = enabled() + if (!enabledValue || !table) { + removeTableDevtoolsTarget(registrationId) + } + upsertTableDevtoolsTarget({ + id: registrationId, + table: table, + name: normalizeName(name), + }) + onCleanup(() => { + removeTableDevtoolsTarget(registrationId) + }) + }, + { injector }, + ) + + destroyRef.onDestroy(() => { + removeTableDevtoolsTarget(registrationId) + }) +} + +export function injectTanStackTableDevtoolsNoOp( + options: () => InjectTanStackTableDevtoolsOptions, +): void {} diff --git a/packages/angular-table-devtools/src/plugin.ts b/packages/angular-table-devtools/src/plugin.ts new file mode 100644 index 0000000000..4db67c28b9 --- /dev/null +++ b/packages/angular-table-devtools/src/plugin.ts @@ -0,0 +1,13 @@ +import { createAngularPlugin } from '@tanstack/devtools-utils/angular' +import { TableDevtoolsPanel } from './TableDevtools' + +type TableDevtoolsPluginFactory = ReturnType[0] + +const plugins = createAngularPlugin({ + name: 'TanStack Table', + render: TableDevtoolsPanel, +}) + +export const tableDevtoolsPlugin: TableDevtoolsPluginFactory = plugins[0] +export const tableDevtoolsNoOpPlugin: TableDevtoolsPluginFactory = + plugins[1] as any diff --git a/packages/angular-table-devtools/src/production.ts b/packages/angular-table-devtools/src/production.ts new file mode 100644 index 0000000000..f3e96534ef --- /dev/null +++ b/packages/angular-table-devtools/src/production.ts @@ -0,0 +1,5 @@ +export { TableDevtoolsPanel } from './TableDevtools' +export type { TableDevtoolsAngularInit } from './TableDevtools' +export { tableDevtoolsPlugin } from './plugin' +export { injectTanStackTableDevtools } from './injectTanStackTableDevtools' +export type { InjectTanStackTableDevtoolsOptions } from './injectTanStackTableDevtools' diff --git a/packages/angular-table-devtools/tsconfig.json b/packages/angular-table-devtools/tsconfig.json new file mode 100644 index 0000000000..7cd68e0598 --- /dev/null +++ b/packages/angular-table-devtools/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": [ + "src", + "tests", + "eslint.config.js", + "vite.config.ts", + "tsdown.config.ts" + ] +} diff --git a/packages/angular-table-devtools/tsdown.config.ts b/packages/angular-table-devtools/tsdown.config.ts new file mode 100644 index 0000000000..63b0b0bd20 --- /dev/null +++ b/packages/angular-table-devtools/tsdown.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + plugins: [], + entry: ['./src/index.ts', './src/production.ts'], + format: ['esm'], + unbundle: true, + dts: true, + sourcemap: true, + clean: true, + minify: false, + fixedExtension: false, + exports: true, + publint: { + strict: true, + }, +}) diff --git a/packages/angular-table-devtools/vite.config.ts b/packages/angular-table-devtools/vite.config.ts new file mode 100644 index 0000000000..8feed8cff3 --- /dev/null +++ b/packages/angular-table-devtools/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import packageJson from './package.json' + +const config = defineConfig({ + plugins: [], + test: { + name: packageJson.name, + dir: './', + watch: false, + environment: 'jsdom', + globals: true, + }, +}) + +export default config diff --git a/packages/angular-table/skills/angular/compose-with-tanstack-store/SKILL.md b/packages/angular-table/skills/angular/compose-with-tanstack-store/SKILL.md index ef5391d9fd..4ce220dcb8 100644 --- a/packages/angular-table/skills/angular/compose-with-tanstack-store/SKILL.md +++ b/packages/angular-table/skills/angular/compose-with-tanstack-store/SKILL.md @@ -60,7 +60,7 @@ tracks the dependency. ```ts this.table.atoms.pagination.get() // current value (reactive) this.table.atoms.pagination.subscribe(obs) // RxJS observer form -this.table.store.state.pagination // flat snapshot read +this.table.state.pagination // flat snapshot read this.table.baseAtoms.pagination.set(...) // direct internal write (avoid) ``` diff --git a/packages/angular-table/skills/angular/migrate-v8-to-v9/SKILL.md b/packages/angular-table/skills/angular/migrate-v8-to-v9/SKILL.md index 3d0603c35c..35e9269a50 100644 --- a/packages/angular-table/skills/angular/migrate-v8-to-v9/SKILL.md +++ b/packages/angular-table/skills/angular/migrate-v8-to-v9/SKILL.md @@ -3,7 +3,7 @@ name: angular/migrate-v8-to-v9 description: > Mechanical v8 → v9 migration for `@tanstack/angular-table`: `createAngularTable` → `injectTable`, `get*RowModel()` options → `_rowModels` factories with explicit `*Fns`, - required `_features` via `tableFeatures()`, `state` access via `table.store.state` instead + required `_features` via `tableFeatures()`, `state` access via `table.state` instead of `table.getState()`, `createColumnHelper()` generic-order flip, every type now requires `TFeatures`, `enablePinning` split into `enableColumnPinning` / `enableRowPinning`, `sortingFn` → `sortFn` rename pile, `ColumnSizingInfo` → `ColumnResizing` @@ -124,21 +124,21 @@ Row-model and feature lookup tables → [`references/v8-to-v9-mapping.md`](refer --- -## 3. State access: `getState()` → `table.store.state` (and atoms) +## 3. State access: `getState()` → `table.state` (and atoms) ```ts // v8 const { sorting, pagination } = table.getState() // v9 — flat snapshot -const { sorting, pagination } = table.store.state +const { sorting, pagination } = table.state // v9 — per slice (signal-backed in Angular) const sorting = table.atoms.sorting.get() const pagination = table.atoms.pagination.get() ``` -In Angular, all three (`table.atoms.`, `table.store.state`, +In Angular, all three (`table.atoms.`, `table.state`, `table.baseAtoms.`) are signal-backed — reading them inside a template, `computed(...)`, or `effect(...)` registers an Angular dependency automatically. No `toSignal(...)` wrappers needed. @@ -275,7 +275,7 @@ v8 backed reactivity with manual memoized getters. v9's adapter `computed` and every writable atom with an Angular `signal`. Consequences: - **No `toSignal(...)` adapters around table state.** Read `table.atoms.x.get()` - / `table.store.state.x` directly inside templates, `computed`, `effect`. + / `table.state.x` directly inside templates, `computed`, `effect`. - **`computed(...)` is for derivation / equality, not for "make it reactive".** Use `{ equal: shallow }` from `@tanstack/angular-table` on object/array slices to skip downstream work on no-op updates. @@ -306,7 +306,7 @@ v8 backed reactivity with manual memoized getters. v9's adapter - [ ] Update `createColumnHelper()` → `createColumnHelper()`. - [ ] Update every `ColumnDef` / `Cell` etc. to include `TFeatures`. -- [ ] Replace `table.getState()` reads with `table.store.state` (or +- [ ] Replace `table.getState()` reads with `table.state` (or `table.atoms..get()` for per-slice reactivity). - [ ] Remove any usage of the v8 single `onStateChange` — split into per-slice `on[State]Change`. @@ -366,9 +366,9 @@ _rowModels: { Same for sorting, pagination, expanding, grouping, faceting. Selection, visibility, ordering, pinning, sizing, resizing do **not** need a row model. -### 4. (HIGH) `getState()` → `table.store.state` text replacement loses reactivity +### 4. (HIGH) `getState()` → `table.state` text replacement loses reactivity -Bulk-replacing `table.getState().x` with `table.store.state.x` works for _current +Bulk-replacing `table.getState().x` with `table.state.x` works for _current value_ reads, but if you used a `computed`/`memo` around `getState()` for reactivity, switch to `table.atoms.x.get()` — it's already signal-backed and needs no wrapper. diff --git a/packages/angular-table/skills/angular/production-readiness/SKILL.md b/packages/angular-table/skills/angular/production-readiness/SKILL.md index 8f0b5764c8..a5caaa42a2 100644 --- a/packages/angular-table/skills/angular/production-readiness/SKILL.md +++ b/packages/angular-table/skills/angular/production-readiness/SKILL.md @@ -6,7 +6,7 @@ description: > stable references OUTSIDE the `injectTable` initializer; pass only the `*Fns` your data needs to `createSortedRowModel` / `createFilteredRowModel` / `createGroupedRowModel`; use `ChangeDetectionStrategy.OnPush`; lean on signal-backed atoms (`table.atoms..get()`) - instead of broad `table.store.state` reads where granularity matters; use `{ equal: shallow }` + instead of broad `table.state` reads where granularity matters; use `{ equal: shallow }` on object/array `computed` selectors; set `getRowId` for stable identity; track by `id` in every `@for`; defer cell components with `flexRenderComponent` only when you need its options; scope DI tokens via `[tanStackTable*]` directives to kill prop drilling. @@ -148,13 +148,13 @@ All `examples/angular/*` use `OnPush`. Match that. --- -## 4. Read narrowly — `table.atoms..get()` over `table.store.state` +## 4. Read narrowly — `table.atoms..get()` over `table.state` Both surfaces are signal-backed. The difference is _which signal_ gets read. ```ts // Wider — depends on the flat snapshot signal (recomputes when ANY registered slice changes) -const pageIndex = computed(() => this.table.store.state.pagination.pageIndex) +const pageIndex = computed(() => this.table.state.pagination.pageIndex) // Narrower — depends only on the pagination atom const pageIndex = computed(() => this.table.atoms.pagination.get().pageIndex) diff --git a/packages/angular-table/skills/angular/table-state/SKILL.md b/packages/angular-table/skills/angular/table-state/SKILL.md index 441a19ddfa..4c09c5a000 100644 --- a/packages/angular-table/skills/angular/table-state/SKILL.md +++ b/packages/angular-table/skills/angular/table-state/SKILL.md @@ -145,7 +145,7 @@ A table instance has three ways to look at its state: | ------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- | | `table.baseAtoms.` | writable TanStack Store atom (always exists for registered slices) | low-level direct write; rare | | `table.atoms.` | **readonly** derived atom per registered feature; backed by Angular `computed` | reading current value or driving reactivity | -| `table.store.state` | flat snapshot object of every registered slice; backed by Angular `computed` | reading multiple slices at once, devtools | +| `table.state` | flat snapshot object of every registered slice; backed by Angular `computed` | reading multiple slices at once, devtools | All three are signal-backed in Angular. Reading any of them inside a template, `computed(...)`, or `effect(...)` registers an Angular dependency. @@ -155,7 +155,7 @@ All three are signal-backed in Angular. Reading any of them inside a template, const pagination = this.table.atoms.pagination.get() // Same value, flat shape -const pagination2 = this.table.store.state.pagination +const pagination2 = this.table.state.pagination // Reactive derivation with custom equality import { computed } from '@angular/core' diff --git a/packages/angular-table/src/injectTable.ts b/packages/angular-table/src/injectTable.ts index a60e18910d..85dd1fb569 100644 --- a/packages/angular-table/src/injectTable.ts +++ b/packages/angular-table/src/injectTable.ts @@ -6,6 +6,7 @@ import { inject, untracked, } from '@angular/core' +import { injectSelector } from '@tanstack/angular-store' import { constructTable } from '@tanstack/table-core' import { lazyInit } from './lazySignalInitializer' import { angularReactivity } from './reactivity' @@ -20,6 +21,7 @@ import type { Table, TableFeatures, TableOptions, + TableState, } from '@tanstack/table-core' export type SubscribeSource = @@ -31,7 +33,19 @@ export type SubscribeSource = export type AngularTable< TFeatures extends TableFeatures, TData extends RowData, -> = Table +> = Table & { + /** + * @deprecated Prefer `table.state` for template/render reads, + * `table.atoms..get()` for slice snapshots, or Angular computed values + * around explicit selectors. `table.state` is a current-value snapshot + * and is easy to misuse in render code. + */ + readonly store: Table['store'] + /** + * The current table state exposed for template/render reads. + */ + readonly state: Readonly> +} /** * Creates and returns an Angular-reactive table instance. @@ -107,6 +121,15 @@ export function injectTable< coreReativityFeature: angularReactivity(injector), ...options()._features, }, + }) as AngularTable + const stateSignal = injectSelector(table.store, undefined, { injector }) + + Object.defineProperty(table, 'state', { + get() { + return stateSignal() + }, + configurable: true, + enumerable: true, }) let isMount = true diff --git a/packages/angular-table/src/reactivity.ts b/packages/angular-table/src/reactivity.ts index ad6907c124..09834cdab6 100644 --- a/packages/angular-table/src/reactivity.ts +++ b/packages/angular-table/src/reactivity.ts @@ -1,5 +1,6 @@ -import { NgZone, computed, signal, untracked } from '@angular/core' +import { DestroyRef, NgZone, computed, signal, untracked } from '@angular/core' import { toObservable } from '@angular/core/rxjs-interop' +import { batch, createAtom } from '@tanstack/angular-store' import type { Atom, Observer, ReadonlyAtom } from '@tanstack/angular-store' import type { TableAtomOptions, @@ -7,6 +8,8 @@ import type { } from '@tanstack/table-core/reactivity' import type { Injector, Signal, WritableSignal } from '@angular/core' +const optionsStoreDebugName = 'table/optionsStore' + function signalToReadonlyAtom( signal: Signal, injector: Injector, @@ -14,9 +17,13 @@ function signalToReadonlyAtom( return Object.assign(signal, { get: () => signal(), subscribe: (observer: Observer) => { - return toObservable(computed(signal), { injector: injector }).subscribe( - observer, - ) + const subscription = toObservable(computed(signal), { + injector: injector, + }).subscribe(observer) + + return { + unsubscribe: () => subscription.unsubscribe(), + } }, }) } @@ -33,9 +40,13 @@ function signalToWritableAtom( }, get: () => signal(), subscribe: (observer: Observer) => { - return toObservable(computed(signal), { injector: injector }).subscribe( - observer, - ) + const subscription = toObservable(computed(signal), { + injector: injector, + }).subscribe(observer) + + return { + unsubscribe: () => subscription.unsubscribe(), + } }, }) } @@ -43,34 +54,59 @@ function signalToWritableAtom( /** * Creates the table-core reactivity bindings used by the Angular adapter. * - * Readonly table atoms are backed by Angular `computed` signals and writable - * atoms by Angular `signal`. Subscriptions bridge through `toObservable` with - * the caller's injector so table APIs can be consumed from Angular `computed` - * and `effect` calls. + * Table state atoms are backed by TanStack Store atoms. The options store stays + * framework-native because row-model APIs read `table.options` directly during + * render. Readonly table atoms bridge Store dependency tracking into Angular + * computed signals. */ export function angularReactivity(injector: Injector): TableReactivityBindings { const ngZone = injector.get(NgZone) + const destroyRef = injector.get(DestroyRef) + return { createOptionsStore: true, schedule: (fn) => ngZone.runOutsideAngular(() => queueMicrotask(fn)), createReadonlyAtom: (fn: () => T, options?: TableAtomOptions) => { - const signal = computed(() => fn(), { - equal: options?.compare, - debugName: options?.debugName, + const storeAtom = createAtom(() => fn(), { + compare: options?.compare, }) - return signalToReadonlyAtom(signal, injector) + const version = signal(0, { + equal: () => false, + }) + const subscription = storeAtom.subscribe(() => { + version.update((value) => value + 1) + }) + destroyRef.onDestroy(() => subscription.unsubscribe()) + + const value = computed( + () => { + version() + return storeAtom.get() + }, + { + equal: options?.compare, + debugName: options?.debugName, + }, + ) + return signalToReadonlyAtom(value, injector) }, createWritableAtom: ( value: T, options?: TableAtomOptions, ): Atom => { - const writableSignal = signal(value, { - equal: options?.compare, - debugName: options?.debugName, + if (options?.debugName === optionsStoreDebugName) { + const writableSignal = signal(value, { + equal: options.compare, + debugName: options.debugName, + }) + return signalToWritableAtom(writableSignal, injector) + } + + return createAtom(value, { + compare: options?.compare, }) - return signalToWritableAtom(writableSignal, injector) }, untrack: untracked, - batch: (fn) => fn(), + batch, } } diff --git a/packages/angular-table/tests/angularReactivityFeature.test.ts b/packages/angular-table/tests/angularReactivityFeature.test.ts index 35f381d716..44657345da 100644 --- a/packages/angular-table/tests/angularReactivityFeature.test.ts +++ b/packages/angular-table/tests/angularReactivityFeature.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test, vi } from 'vitest' import { computed, effect, signal } from '@angular/core' import { TestBed } from '@angular/core/testing' +import { createAtom } from '@tanstack/angular-store' import { injectTable, stockFeatures } from '../src' -import type { ColumnDef } from '../src' +import type { ColumnDef, RowSelectionState } from '../src' import type { WritableSignal } from '@angular/core' describe('angularReactivityFeature', () => { @@ -102,5 +103,43 @@ describe('angularReactivityFeature', () => { [false], ]) }) + + test('methods within effect react to external atom changes', () => { + const rowSelectionAtom = createAtom({}) + const table = TestBed.runInInjectionContext(() => + injectTable(() => ({ + data: data(), + _features: { ...stockFeatures }, + columns: columns, + getRowId: (row) => row.id, + atoms: { + rowSelection: rowSelectionAtom, + }, + })), + ) + const isSelectedRow1Captor = vi.fn<(val: boolean) => void>() + const tableStateCaptor = vi.fn<(val: RowSelectionState) => void>() + + TestBed.runInInjectionContext(() => { + effect(() => { + isSelectedRow1Captor(table.getRow('1').getIsSelected()) + }) + effect(() => { + tableStateCaptor(table.state.rowSelection) + }) + }) + + TestBed.tick() + expect(isSelectedRow1Captor).toHaveBeenCalledTimes(1) + expect(tableStateCaptor).toHaveBeenCalledTimes(1) + + rowSelectionAtom.set({ 1: true }) + TestBed.tick() + + expect(isSelectedRow1Captor).toHaveBeenCalledTimes(2) + expect(tableStateCaptor).toHaveBeenCalledTimes(2) + expect(isSelectedRow1Captor.mock.calls).toEqual([[false], [true]]) + expect(tableStateCaptor.mock.calls).toEqual([[{}], [{ 1: true }]]) + }) }) }) diff --git a/packages/lit-table/skills/lit/lit-table-controller/SKILL.md b/packages/lit-table/skills/lit/lit-table-controller/SKILL.md index 70d2aea1ba..9062252303 100644 --- a/packages/lit-table/skills/lit/lit-table-controller/SKILL.md +++ b/packages/lit-table/skills/lit/lit-table-controller/SKILL.md @@ -210,12 +210,12 @@ class DashboardElement extends LitElement { ## Reading State Off the Controller -The controller's `.table(...)` return value carries everything you usually need: feature methods, `FlexRender`, `Subscribe`, and the `state` projection. Direct reads off `table.atoms..get()` and `table.store.state.` are current-value reads; reactivity comes from the host invalidation subscriptions the controller already wires up. +The controller's `.table(...)` return value carries everything you usually need: feature methods, `FlexRender`, `Subscribe`, and the `state` projection. Direct reads off `table.atoms..get()` and `table.state.` are current-value reads; reactivity comes from the host invalidation subscriptions the controller already wires up. ```ts // Inside render(): const pagination = table.atoms.pagination.get() // current value -const snapshot = table.store.state // current full state +const snapshot = table.state // current full state const selected = table.state // projected via the selector you passed to .table() ``` diff --git a/packages/lit-table/skills/lit/table-state/SKILL.md b/packages/lit-table/skills/lit/table-state/SKILL.md index 8c46af79d0..cb7f17489e 100644 --- a/packages/lit-table/skills/lit/table-state/SKILL.md +++ b/packages/lit-table/skills/lit/table-state/SKILL.md @@ -161,7 +161,7 @@ Direct atom / store reads return the current value without subscribing to change ```ts const pagination = table.atoms.pagination.get() const sorting = table.atoms.sorting.get() -const snapshot = table.store.state +const snapshot = table.state ``` ### 4. `table.Subscribe` in templates diff --git a/packages/lit-table/src/TableController.ts b/packages/lit-table/src/TableController.ts index 44c84e397a..447b30be9f 100644 --- a/packages/lit-table/src/TableController.ts +++ b/packages/lit-table/src/TableController.ts @@ -31,7 +31,14 @@ export type LitTable< TFeatures extends TableFeatures, TData extends RowData, TSelected = TableState, -> = Table & { +> = Omit, 'store'> & { + /** + * @deprecated Prefer `table.state` for render reads, + * `table.atoms..get()` for slice snapshots, or `table.Subscribe` for + * explicit subscriptions. `table.store.state` is a current-value snapshot and + * is easy to misuse in render code. + */ + readonly store: Table['store'] /** * Subscribe to a selected slice of table state, or to a single source (atom or store). * @@ -74,7 +81,7 @@ export type LitTable< } /** * The selected state of the table. This state may not match the structure of - * `table.store.state` because it is selected by the `selector` function that + * the full table state because it is selected by the selector function that * you pass as the 2nd argument to `controller.table()`. * * @example @@ -224,7 +231,7 @@ export class TableController< return (selector?.(tableInstance.store.state) ?? tableInstance.store.state) as TSelected }, - } + } as unknown as LitTable } private _setupSubscriptions() { diff --git a/packages/preact-table/skills/preact/getting-started/SKILL.md b/packages/preact-table/skills/preact/getting-started/SKILL.md index 5f6f1d28ec..38a73a1997 100644 --- a/packages/preact-table/skills/preact/getting-started/SKILL.md +++ b/packages/preact-table/skills/preact/getting-started/SKILL.md @@ -200,7 +200,7 @@ Source: `examples/preact/basic-use-table/src/main.tsx`. ## Step 5 — Drive features with feature APIs -Reach for `table.setSorting(...)`, `table.setPageIndex(...)`, `table.nextPage()`, `column.toggleVisibility()`, `row.toggleSelected()`, etc. — never edit `table.store.state` directly. +Reach for `table.setSorting(...)`, `table.setPageIndex(...)`, `table.nextPage()`, `column.toggleVisibility()`, `row.toggleSelected()`, etc. — never edit `table.state` directly. ```tsx table.setPageIndex(0)} disabled={!table.getCanPreviousPage()}>First diff --git a/packages/preact-table/skills/preact/table-state/SKILL.md b/packages/preact-table/skills/preact/table-state/SKILL.md index a9d09073b3..2abd73ba0f 100644 --- a/packages/preact-table/skills/preact/table-state/SKILL.md +++ b/packages/preact-table/skills/preact/table-state/SKILL.md @@ -252,7 +252,7 @@ function Pager({ table }) { } ``` -`.get()` and `table.store.state` are current-value reads, not subscriptions. The component never re-renders when the atom changes. +`.get()` and `table.state` are current-value reads, not subscriptions. The component never re-renders when the atom changes. Source: `docs/framework/preact/guide/table-state.md`. ### HIGH Passing both `atoms.X` and `state.X` for the same slice diff --git a/packages/preact-table/src/useTable.ts b/packages/preact-table/src/useTable.ts index 2e61faefa1..ddaf2919ab 100644 --- a/packages/preact-table/src/useTable.ts +++ b/packages/preact-table/src/useTable.ts @@ -20,7 +20,15 @@ export type PreactTable< TFeatures extends TableFeatures, TData extends RowData, TSelected = TableState, -> = Table & { +> = Omit, 'store'> & { + /** + * @deprecated Prefer `table.state` for render reads, + * `table.atoms..get()` for slice snapshots, or + * `table.Subscribe` / `useSelector(table.store, selector)` for explicit + * subscriptions. `table.store.state` is a current-value snapshot and is easy + * to misuse in render code. + */ + readonly store: Table['store'] /** * A Preact HOC (Higher Order Component) that allows you to subscribe to the table state. * @@ -71,7 +79,9 @@ export type PreactTable< props: FlexRenderProps, ) => ComponentChildren /** - * The selected state of the table. This state may not match the structure of `table.store.state` because it is selected by the `selector` function that you pass as the 2nd argument to `useTable`. + * The selected state of the table. This state may not match the structure of + * the full table state because it is selected by the selector function that + * you pass as the 2nd argument to `useTable`. */ readonly state: Readonly } @@ -115,7 +125,7 @@ export function useTable< coreReativityFeature: preactReactivity(), ...tableOptions._features, }, - }) as PreactTable + }) as unknown as PreactTable tableInstance.Subscribe = ((props: any) => { const source = props.source ?? tableInstance.store diff --git a/packages/react-table-devtools/src/useTanStackTableDevtools.ts b/packages/react-table-devtools/src/useTanStackTableDevtools.ts index 456f5d8f66..f880651ca5 100644 --- a/packages/react-table-devtools/src/useTanStackTableDevtools.ts +++ b/packages/react-table-devtools/src/useTanStackTableDevtools.ts @@ -5,6 +5,7 @@ import { removeTableDevtoolsTarget, upsertTableDevtoolsTarget, } from '@tanstack/table-devtools' +import { useEffect } from 'react' import type { RowData, Table, TableFeatures } from '@tanstack/table-core' export interface UseTanStackTableDevtoolsOptions { @@ -25,24 +26,32 @@ export function useTanStackTableDevtools< options?: UseTanStackTableDevtoolsOptions, ): void { const registrationId = React.useId() + const normalizedName = normalizeName(name) + + const instanceId = + // instanceId from react table adapter (if it exists) allows for stable devtools registration even if the table instance changes + (table as unknown as { instanceId?: string }).instanceId || + `${registrationId}${normalizedName ? `-${normalizedName}` : ``}` + const enabled = options?.enabled ?? true - React.useEffect(() => { + useEffect(() => { if (!enabled || !table) { - removeTableDevtoolsTarget(registrationId) + removeTableDevtoolsTarget(instanceId) return } upsertTableDevtoolsTarget({ - id: registrationId, + id: instanceId, table, - name: normalizeName(name), + name: normalizedName, }) return () => { - removeTableDevtoolsTarget(registrationId) + removeTableDevtoolsTarget(instanceId) } - }, [enabled, name, registrationId, table]) + // eslint-disable-next-line @eslint-react/exhaustive-deps,react-hooks/exhaustive-deps + }, [enabled, registrationId, instanceId]) } export function useTanStackTableDevtoolsNoOp< diff --git a/packages/react-table/skills/react/compose-with-tanstack-store/SKILL.md b/packages/react-table/skills/react/compose-with-tanstack-store/SKILL.md index ae658f6001..924c7e4284 100644 --- a/packages/react-table/skills/react/compose-with-tanstack-store/SKILL.md +++ b/packages/react-table/skills/react/compose-with-tanstack-store/SKILL.md @@ -179,7 +179,7 @@ function Table({ data, filter }) { | `` / `` | ✓ | Surgical re-render boundaries inside the tree | | `useSelector(table.atoms.X)` | ✓ | Narrowest possible subscription to one slice | | `table.atoms.X.get()` | ✗ current-value read | Inside event handlers / effects | -| `table.store.state` | ✗ current-value read | Debugging / one-shot reads | +| `table.state` | ✗ current-value read | Debugging / one-shot reads | | Write path | Owner | Effect | | ------------------------------- | ----------------- | ---------------------------------------------------------------------------------- | diff --git a/packages/react-table/skills/react/migrate-v8-to-v9/SKILL.md b/packages/react-table/skills/react/migrate-v8-to-v9/SKILL.md index 1eb711865a..c03616f1d0 100644 --- a/packages/react-table/skills/react/migrate-v8-to-v9/SKILL.md +++ b/packages/react-table/skills/react/migrate-v8-to-v9/SKILL.md @@ -6,7 +6,7 @@ description: > memory has a v9 equivalent enumerated below: `useReactTable` → `useTable`, root `get*RowModel` options → `_rowModels` with factory + *Fns parameter, `createColumnHelper` → `createColumnHelper`, - `table.getState()` → `table.store.state` / `table.state` / `table.atoms.X.get()`, + `table.getState()` → `table.state` / `table.state` / `table.atoms.X.get()`, `sortingFn` → `sortFn`, `enablePinning` → split, `_`-prefixed APIs unprefixed, `ColumnSizing` split into `columnSizingFeature` + `columnResizingFeature`. For incremental migration, `useLegacyTable` from `@tanstack/react-table/legacy` @@ -120,12 +120,12 @@ const state = table.getState() const cells = row._getAllCellsByColumnId() // v9 -const all = table.store.state // flat snapshot +const all = table.state // flat snapshot const sorting = table.atoms.sorting.get() // per-slice atom const cells = row.getAllCellsByColumnId() // no underscore — APIs unprefixed ``` -In components, prefer `` over `table.store.state` for reactivity (see `tanstack-table/react/table-state`). +In components, prefer `` over `table.state` for reactivity (see `tanstack-table/react/table-state`). ### Renames @@ -329,7 +329,7 @@ function Toolbar({ table }) { } ``` -`getState` was removed. Use `table.store.state` for a flat snapshot, `table.state` if you passed a `useTable` selector, or `` for reactive reads. +`getState` was removed. Use `table.state` for a flat snapshot, `table.state` if you passed a `useTable` selector, or `` for reactive reads. Source: `docs/framework/react/guide/migrating.md`; `examples/react/basic-subscribe/src/main.tsx`. ### HIGH `enablePinning: true` on v9 diff --git a/packages/react-table/skills/react/production-readiness/SKILL.md b/packages/react-table/skills/react/production-readiness/SKILL.md index 31151548b1..e20a948c93 100644 --- a/packages/react-table/skills/react/production-readiness/SKILL.md +++ b/packages/react-table/skills/react/production-readiness/SKILL.md @@ -269,7 +269,7 @@ function SelectedCount({ table }) { } ``` -`` still selects from `table.store.state` (the full state). For a single slice, `useSelector(table.atoms.X)` skips even constructing the snapshot. +`` still selects from `table.state` (the full state). For a single slice, `useSelector(table.atoms.X)` skips even constructing the snapshot. Source: `docs/framework/react/guide/table-state.md`. ### MEDIUM Hoisting heavy table state reads above virtualizers diff --git a/packages/react-table/skills/react/table-state/SKILL.md b/packages/react-table/skills/react/table-state/SKILL.md index d2de2f220a..3ffb6ac58b 100644 --- a/packages/react-table/skills/react/table-state/SKILL.md +++ b/packages/react-table/skills/react/table-state/SKILL.md @@ -258,7 +258,7 @@ function Pager({ table }) { } ``` -`.get()` and `table.store.state` are current-value reads, not subscriptions. The component never re-renders when the atom changes. +`.get()` and `table.state` are current-value reads, not subscriptions. The component never re-renders when the atom changes. Source: `docs/framework/react/guide/table-state.md`; `examples/react/basic-subscribe/src/main.tsx`. ### HIGH Passing both `atoms.X` and `state.X` for the same slice diff --git a/packages/react-table/src/useLegacyTable.ts b/packages/react-table/src/useLegacyTable.ts index daa0ad7474..989f916624 100644 --- a/packages/react-table/src/useLegacyTable.ts +++ b/packages/react-table/src/useLegacyTable.ts @@ -279,7 +279,7 @@ export type LegacyReactTable = ReactTable< > & { /** * Returns the current table state. - * @deprecated In v9, access state directly via `table.state` or use `table.store.state` for the full state. + * @deprecated In v9, access state directly via `table.state` or use `table.state` for the full state. */ getState: () => TableState /** diff --git a/packages/react-table/src/useTable.ts b/packages/react-table/src/useTable.ts index 8272983306..5ac76f1f55 100644 --- a/packages/react-table/src/useTable.ts +++ b/packages/react-table/src/useTable.ts @@ -1,6 +1,6 @@ 'use client' -import { useMemo, useState } from 'react' +import { useId, useMemo, useRef, useState } from 'react' import { constructTable } from '@tanstack/table-core' import { shallow, useSelector } from '@tanstack/react-store' import { reactReactivity } from './reactivity' @@ -22,7 +22,19 @@ export type ReactTable< TFeatures extends TableFeatures, TData extends RowData, TSelected = TableState, -> = Table & { +> = Omit, 'store'> & { + /** + * @deprecated Prefer `table.state` for render reads, + * `table.atoms..get()` for slice snapshots, or + * `table.Subscribe` / `useSelector(table.store, selector)` for explicit + * subscriptions. `table.store.state` is a current-value snapshot and is easy + * to misuse in render code. + */ + readonly store: Table['store'] + /** + * A stable id reference for table instance + */ + instanceId?: string /** * A React HOC (Higher Order Component) that allows you to subscribe to the table state. * @@ -95,7 +107,9 @@ export type ReactTable< props: FlexRenderProps, ) => ReactNode /** - * The selected state of the table. This state may not match the structure of `table.store.state` because it is selected by the `selector` function that you pass as the 2nd argument to `useTable`. + * The selected state of the table. This state may not match the structure of + * the full table state because it is selected by the selector function that + * you pass as the 2nd argument to `useTable`. * * @example * const table = useTable(options, (state) => ({ globalFilter: state.globalFilter })) // only globalFilter is part of the selected state @@ -105,6 +119,7 @@ export type ReactTable< readonly state: Readonly } +let tableId = 0 /** * Creates a React table instance backed by TanStack Store atoms. * @@ -137,6 +152,13 @@ export function useTable< tableOptions: TableOptions, selector?: (state: TableState) => TSelected, ): ReactTable { + const instanceIdRef = useRef(undefined) + if (!instanceIdRef.current) { + instanceIdRef.current = + 'randomUUID' in globalThis.crypto + ? globalThis.crypto.randomUUID() + : `table-${++tableId}` + } const [table] = useState(() => { const tableInstance = constructTable({ ...tableOptions, @@ -144,7 +166,7 @@ export function useTable< coreReativityFeature: reactReactivity(), ...tableOptions._features, }, - }) as ReactTable + }) as unknown as ReactTable tableInstance.Subscribe = ((props: any) => { const source = props.source ?? tableInstance.store @@ -156,6 +178,7 @@ export function useTable< }) as ReactTable['Subscribe'] tableInstance.FlexRender = FlexRender + tableInstance.instanceId = instanceIdRef.current return tableInstance }) diff --git a/packages/solid-table/skills/solid/production-readiness/SKILL.md b/packages/solid-table/skills/solid/production-readiness/SKILL.md index 4a56e7b84e..52383cf5bb 100644 --- a/packages/solid-table/skills/solid/production-readiness/SKILL.md +++ b/packages/solid-table/skills/solid/production-readiness/SKILL.md @@ -238,10 +238,11 @@ A clear "didn't think about the bundle" tell. Use only the features you render. Keep `createVirtualizer` in the component that owns the scroll container, not high up in the tree. Otherwise scroll-driven recompute fires across the page. -### MEDIUM — re-reading `table.store.state` in JSX when an atom would do +### MEDIUM — re-reading `table.state` in JSX -`table.store.state.pagination` works, but `table.atoms.pagination.get()` or -`useSelector(table.atoms.pagination)` is the per-slice path. Prefer the slice. +Use `table.state()` for component-level reactive reads, or +`table.atoms.pagination.get()` / `useSelector(table.atoms.pagination)` for +per-slice reads. Avoid direct `table.state` reads in JSX. ### MEDIUM — `autoResetPageIndex: true` on a server-driven table diff --git a/packages/solid-table/skills/solid/table-state/SKILL.md b/packages/solid-table/skills/solid/table-state/SKILL.md index adc5252fd7..bf1dd15f0b 100644 --- a/packages/solid-table/skills/solid/table-state/SKILL.md +++ b/packages/solid-table/skills/solid/table-state/SKILL.md @@ -37,14 +37,14 @@ state directly through table APIs inside reactive scopes and never need A `createTable(...)` call produces a `SolidTable` with several state surfaces: -- `table.baseAtoms.` — internal writable atoms (signals). -- `table.atoms.` — readonly derived atoms (memos). One per registered feature slice. -- `table.store` — flat readonly TanStack Store snapshot. `table.store.state.pagination` reads the current value. +- `table.baseAtoms.` — internal writable TanStack Store atoms. Treat these as write plumbing, not a render read surface. +- `table.atoms.` — readonly derived atoms (memos). One per registered feature slice. Use `table.atoms.pagination.get()` for slice-level reactive reads. +- `table.store` — flat readonly TanStack Store snapshot for explicit subscriptions. Prefer `table.state()` or `table.atoms..get()` in JSX. - `table.state()` — **a Solid accessor**, not a value. Returns the result of the selector passed as the second argument to `createTable`. Default selector is identity. State slices only exist for features registered through `_features`. If `rowSortingFeature` is not in `_features`, then `table.atoms.sorting`, -`table.store.state.sorting`, and `state.sorting` are all absent (TS error + missing at runtime). +`table.state().sorting`, and `state.sorting` are all absent (TS error + missing at runtime). ## Creating a table — native signals diff --git a/packages/solid-table/src/createTable.ts b/packages/solid-table/src/createTable.ts index 266cbb74c9..f29ce6c75d 100644 --- a/packages/solid-table/src/createTable.ts +++ b/packages/solid-table/src/createTable.ts @@ -28,7 +28,15 @@ export type SolidTable< TFeatures extends TableFeatures, TData extends RowData, TSelected = TableState, -> = Table & { +> = Omit, 'store'> & { + /** + * @deprecated Prefer `table.state()` for component-level reactive reads, + * `table.atoms..get()` for slice-level reactive reads, or + * `table.Subscribe` / `useSelector(table.store, selector)` for explicit + * subscriptions. Reading `table.state` directly does not follow Solid's + * accessor convention and may not update render code as expected. + */ + readonly store: Table['store'] /** * Subscribe to the store (selector required) or a single source (atom or store). * Source **without** `selector` is a separate overload so children receive @@ -52,7 +60,9 @@ export type SolidTable< }): JSX.Element } /** - * The selected state of the table. This state may not match the structure of `table.store.state` because it is selected by the `selector` function that you pass as the 2nd argument to `createTable`. + * The selected state of the table. This state may not match the structure of + * the full table state because it is selected by the selector function that + * you pass as the 2nd argument to `createTable`. * * @example * const table = createTable(options, (state) => ({ globalFilter: state.globalFilter })) // only globalFilter is part of the selected state @@ -125,7 +135,7 @@ export function createTable< mergedOptions, ) as TableOptions - const table = constructTable(resolvedOptions) as SolidTable< + const table = constructTable(resolvedOptions) as unknown as SolidTable< TFeatures, TData, TSelected diff --git a/packages/solid-table/src/reactivity.ts b/packages/solid-table/src/reactivity.ts index 66571918cd..1d21f0825e 100644 --- a/packages/solid-table/src/reactivity.ts +++ b/packages/solid-table/src/reactivity.ts @@ -1,11 +1,12 @@ import { - batch, createMemo, createSignal, observable, + onCleanup, runWithOwner, untrack, } from 'solid-js' +import { batch, createAtom } from '@tanstack/solid-store' import type { Accessor, Owner, Setter } from 'solid-js' import type { TableAtomOptions, @@ -13,6 +14,8 @@ import type { } from '@tanstack/table-core/reactivity' import type { Atom, Observer, ReadonlyAtom } from '@tanstack/solid-store' +const optionsStoreDebugName = 'table/optionsStore' + function signalToReadonlyAtom( signal: Accessor, owner: Owner, @@ -26,10 +29,10 @@ function signalToReadonlyAtom( } function signalToWritableAtom( - signalTuple: [Accessor, Setter], + signal: Accessor, + setSignal: Setter, owner: Owner, ): Atom { - const [signal, setSignal] = signalTuple return Object.assign(signal, { set: (updater: T | ((prevVal: T) => T)) => { typeof updater === 'function' @@ -46,30 +49,54 @@ function signalToWritableAtom( /** * Creates the table-core reactivity bindings used by the Solid adapter. * - * Readonly table atoms are backed by Solid memos and writable table atoms are - * backed by Solid signals. Subscriptions run with the captured owner so table - * APIs can safely participate in Solid computations. + * Table state atoms are backed by TanStack Store atoms. The options store stays + * framework-native because row-model APIs read `table.options` directly during + * render. Readonly table atoms bridge Store dependency tracking into Solid memos. */ export function solidReactivity(owner: Owner): TableReactivityBindings { return { createOptionsStore: true, schedule: (fn) => queueMicrotask(() => fn()), createReadonlyAtom: (fn: () => T, options?: TableAtomOptions) => { - const signal = createMemo(() => fn(), { - equals: options?.compare, - name: options?.debugName, + const storeAtom = createAtom(() => fn(), { + compare: options?.compare, + }) + const [version, setVersion] = createSignal(0, { equals: false }) + runWithOwner(owner, () => { + const subscription = storeAtom.subscribe(() => { + setVersion((value) => value + 1) + }) + onCleanup(() => subscription.unsubscribe()) }) + + const signal = createMemo( + () => { + version() + return storeAtom.get() + }, + undefined, + { + equals: options?.compare, + name: options?.debugName, + }, + ) return signalToReadonlyAtom(signal, owner) }, createWritableAtom: ( value: T, options?: TableAtomOptions, ): Atom => { - const writableSignal = createSignal(value, { - equals: options?.compare, - name: options?.debugName, + if (options?.debugName === optionsStoreDebugName) { + const [signal, setSignal] = createSignal(value, { + equals: options.compare, + name: options.debugName, + }) + return signalToWritableAtom(signal, setSignal, owner) + } + + return createAtom(value, { + compare: options?.compare, }) - return signalToWritableAtom(writableSignal, owner) }, untrack: untrack, batch: batch, diff --git a/packages/solid-table/tests/unit/reactivity.test.ts b/packages/solid-table/tests/unit/reactivity.test.ts new file mode 100644 index 0000000000..36bfa4dcfa --- /dev/null +++ b/packages/solid-table/tests/unit/reactivity.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'vitest' +import { createRoot, getOwner } from 'solid-js' +import { createAtom } from '@tanstack/solid-store' +import { solidReactivity } from '../../src/reactivity' + +describe('solidReactivity', () => { + test('readonly atoms update when they read external TanStack Store atoms', () => { + createRoot((dispose) => { + const owner = getOwner()! + const reactivity = solidReactivity(owner) + const external = createAtom(1) + const doubled = reactivity.createReadonlyAtom(() => external.get() * 2, { + debugName: 'doubled', + }) + + expect(doubled.get()).toBe(2) + + external.set(2) + + expect(doubled.get()).toBe(4) + dispose() + }) + }) +}) diff --git a/packages/svelte-table/skills/svelte/compose-with-tanstack-store/SKILL.md b/packages/svelte-table/skills/svelte/compose-with-tanstack-store/SKILL.md index 9a9a255c41..6a353ea38d 100644 --- a/packages/svelte-table/skills/svelte/compose-with-tanstack-store/SKILL.md +++ b/packages/svelte-table/skills/svelte/compose-with-tanstack-store/SKILL.md @@ -71,7 +71,7 @@ function logSort() { } ``` -`table.store.state` is the full snapshot equivalent. +`table.state` is the full snapshot equivalent. ## Pattern 2 — Reactive selector via `createTable` diff --git a/packages/svelte-table/skills/svelte/production-readiness/SKILL.md b/packages/svelte-table/skills/svelte/production-readiness/SKILL.md index 8832e88107..466fa22173 100644 --- a/packages/svelte-table/skills/svelte/production-readiness/SKILL.md +++ b/packages/svelte-table/skills/svelte/production-readiness/SKILL.md @@ -160,7 +160,7 @@ function exportSelected() { } ``` -`table.store.state` is the same idea for a full snapshot. +`table.state` is the same idea for a full snapshot. ## 6. Key every `{#each}` block on a stable id diff --git a/packages/svelte-table/skills/svelte/table-state/SKILL.md b/packages/svelte-table/skills/svelte/table-state/SKILL.md index 50f0cff4f7..63d6de8f56 100644 --- a/packages/svelte-table/skills/svelte/table-state/SKILL.md +++ b/packages/svelte-table/skills/svelte/table-state/SKILL.md @@ -109,7 +109,7 @@ Read the atom directly. Cheapest path; only reactive when called inside a rune-t ```ts const sorting = table.atoms.sorting.get() const pagination = table.atoms.pagination.get() -const flat = table.store.state +const flat = table.state ``` ### Reactive read inside markup — `table.state` selector diff --git a/packages/svelte-table/src/createTable.svelte.ts b/packages/svelte-table/src/createTable.svelte.ts index 283b161337..5a8451ac8a 100644 --- a/packages/svelte-table/src/createTable.svelte.ts +++ b/packages/svelte-table/src/createTable.svelte.ts @@ -15,9 +15,19 @@ export type SvelteTable< TFeatures extends TableFeatures, TData extends RowData, TSelected = TableState, -> = Table & { +> = Omit, 'store'> & { /** - * The selected state of the table. This state may not match the structure of `table.store.state` because it is selected by the `selector` function that you pass as the 2nd argument to `createTable`. + * @deprecated Prefer `table.state` for render reads, + * `table.atoms..get()` for slice snapshots, or + * `useSelector(table.store, selector)` for explicit subscriptions. + * `table.store.state` is a current-value snapshot and is easy to misuse in + * render code. + */ + readonly store: Table['store'] + /** + * The selected state of the table. This state may not match the structure of + * the full table state because it is selected by the selector function that + * you pass as the 2nd argument to `createTable`. * * @example * const table = createTable(options, (state) => ({ globalFilter: state.globalFilter })) // only globalFilter is part of the selected state @@ -82,7 +92,7 @@ export function createTable< ) as TableOptions // 3. Construct table - const table = constructTable(resolvedOptions) as SvelteTable< + const table = constructTable(resolvedOptions) as unknown as SvelteTable< TFeatures, TData, TSelected diff --git a/packages/svelte-table/src/reactivity.svelte.ts b/packages/svelte-table/src/reactivity.svelte.ts index 37887d6e2c..ae229e60f5 100644 --- a/packages/svelte-table/src/reactivity.svelte.ts +++ b/packages/svelte-table/src/reactivity.svelte.ts @@ -1,10 +1,13 @@ -import { flushSync, untrack } from 'svelte' +import { untrack } from 'svelte' +import { batch, createAtom } from '@tanstack/svelte-store' import type { TableAtomOptions, TableReactivityBindings, } from '@tanstack/table-core/reactivity' import type { Atom, Observer, ReadonlyAtom } from '@tanstack/svelte-store' +const optionsStoreDebugName = 'table/optionsStore' + function observerToCallback( observerOrNext: Observer | ((value: T) => void), ): (value: T) => void { @@ -28,19 +31,52 @@ function subscribeToRune( return { unsubscribe } } +function createRuneWritableAtom(initialValue: T): Atom { + let value = $state(initialValue) + + return { + set: (updater: T | ((prevVal: T) => T)) => { + value = + typeof updater === 'function' + ? (updater as (prevVal: T) => T)(value) + : updater + }, + get: () => value, + subscribe: ((observerOrNext: Observer | ((value: T) => void)) => { + return subscribeToRune(() => value, observerOrNext) + }) as Atom['subscribe'], + } +} + /** * Creates the table-core reactivity bindings used by the Svelte adapter. * - * Readonly table atoms are backed by `$derived.by`, writable atoms by `$state`, - * and subscriptions bridge through rune effects so table APIs participate in - * Svelte dependency tracking. + * Table state atoms are backed by TanStack Store atoms. The options store stays + * framework-native because row-model APIs read `table.options` directly during + * render. Readonly table atoms bridge Store dependency tracking into `$derived.by`. */ export function svelteReactivity(): TableReactivityBindings { return { createOptionsStore: true, schedule: (fn) => queueMicrotask(() => fn()), createReadonlyAtom: (fn: () => T, _options?: TableAtomOptions) => { - const value = $derived.by(fn) + const storeAtom = createAtom(() => fn(), { + compare: _options?.compare, + }) + let version = $state(0) + + $effect(() => { + const subscription = storeAtom.subscribe(() => { + version += 1 + }) + + return () => subscription.unsubscribe() + }) + + const value = $derived.by(() => { + version + return storeAtom.get() + }) return { get: () => value, @@ -53,22 +89,15 @@ export function svelteReactivity(): TableReactivityBindings { initialValue: T, _options?: TableAtomOptions, ): Atom => { - let value = $state(initialValue) - - return { - set: (updater: T | ((prevVal: T) => T)) => { - value = - typeof updater === 'function' - ? (updater as (prevVal: T) => T)(value) - : updater - }, - get: () => value, - subscribe: ((observerOrNext: Observer | ((value: T) => void)) => { - return subscribeToRune(() => value, observerOrNext) - }) as Atom['subscribe'], + if (_options?.debugName === optionsStoreDebugName) { + return createRuneWritableAtom(initialValue) } + + return createAtom(initialValue, { + compare: _options?.compare, + }) }, untrack: untrack, - batch: (fn) => flushSync(fn), + batch, } } diff --git a/packages/table-devtools/eslint.config.js b/packages/table-devtools/eslint.config.js index 5880eb7bfa..9fb656d60d 100644 --- a/packages/table-devtools/eslint.config.js +++ b/packages/table-devtools/eslint.config.js @@ -1,13 +1,9 @@ // @ts-check +import solid from 'eslint-plugin-solid/configs/recommended' import rootConfig from '../../eslint.config.js' /** @type {any} */ -const config = [ - ...rootConfig, - { - rules: {}, - }, -] +const config = [...rootConfig, solid] export default config diff --git a/packages/table-devtools/package.json b/packages/table-devtools/package.json index b1528fcaef..d81b2890b8 100644 --- a/packages/table-devtools/package.json +++ b/packages/table-devtools/package.json @@ -59,6 +59,7 @@ }, "devDependencies": { "@tanstack/table-core": "workspace:*", + "eslint-plugin-solid": "^0.14.5", "vite-plugin-solid": "^2.11.12" } } diff --git a/packages/table-devtools/src/TableContextProvider.tsx b/packages/table-devtools/src/TableContextProvider.tsx index 5b333f30cf..26aa5119fb 100644 --- a/packages/table-devtools/src/TableContextProvider.tsx +++ b/packages/table-devtools/src/TableContextProvider.tsx @@ -15,7 +15,7 @@ import type { RowData, Table, TableFeatures } from '@tanstack/table-core' import type { TableDevtoolsRegistration } from './tableTarget' type TableDevtoolsTabId = 'features' | 'state' | 'options' | 'rows' | 'columns' -type AnyTable = Table +type AnyTable = Table<{}, RowData> interface TableDevtoolsContextValue { targets: Accessor> @@ -31,12 +31,12 @@ const TableDevtoolsContext = createContext< >(undefined) export const TableContextProvider: ParentComponent = (props) => { - const [targets, setTargets] = createSignal>( - getTableDevtoolsTargets(), - ) + const initialTargets = getTableDevtoolsTargets() + const [targets, setTargets] = + createSignal>(initialTargets) const [selectedTargetId, setSelectedTargetId] = createSignal< string | undefined - >(targets()[0]?.id) + >(initialTargets[0]?.id) const [activeTab, setActiveTab] = createSignal('features') const selectedTarget = createMemo(() => diff --git a/packages/table-devtools/src/components/ColumnsPanel.tsx b/packages/table-devtools/src/components/ColumnsPanel.tsx index bd168f12af..8395b6d400 100644 --- a/packages/table-devtools/src/components/ColumnsPanel.tsx +++ b/packages/table-devtools/src/components/ColumnsPanel.tsx @@ -1,4 +1,4 @@ -import { For } from 'solid-js' +import { For, Show, createMemo } from 'solid-js' import { useTableDevtoolsContext } from '../TableContextProvider' import { useTableStore } from '../useTableStore' import { useStyles } from '../styles/use-styles' @@ -32,65 +32,61 @@ export function ColumnsPanel() { const styles = useStyles() const { table } = useTableDevtoolsContext() - const tableInstance = table() const tableState = useTableStore( - tableInstance ? tableInstance.store : undefined, + () => table()?.store, (state) => state, ) - if (!tableInstance) { - return - } - - const getColumns = (): Array => { - tableState?.() - const tableWithColumnFns = tableInstance as unknown as { - getAllFlatColumns?: () => Array - getAllLeafColumns?: () => Array + const columns = createMemo>(() => { + const tableInstance = table() + if (!tableInstance) { + return [] } + tableState() + return ( - tableWithColumnFns.getAllFlatColumns?.() ?? - tableWithColumnFns.getAllLeafColumns?.() ?? + tableInstance.getAllFlatColumns?.() ?? + tableInstance.getAllLeafColumns?.() ?? [] ) - } - - const columns = getColumns() + }) return ( - - Columns ({columns.length}) - - - - - # - id - depth - accessor - columnDef - - - - - {(column, index) => ( - - {index() + 1} - {column.id} - {column.depth} - - {column.accessorFn ? '✓' : '○'} - - - {getColumnDefSummary(column)} - - - )} - - - + } when={table()}> + + Columns ({columns().length}) + + + + + # + id + depth + accessor + columnDef + + + + + {(column, index) => ( + + {index() + 1} + {column.id} + {column.depth} + + {column.accessorFn ? '✓' : '○'} + + + {getColumnDefSummary(column)} + + + )} + + + + - + ) } diff --git a/packages/table-devtools/src/components/FeaturesPanel.tsx b/packages/table-devtools/src/components/FeaturesPanel.tsx index 2deecafbe7..b8eb23ebe3 100644 --- a/packages/table-devtools/src/components/FeaturesPanel.tsx +++ b/packages/table-devtools/src/components/FeaturesPanel.tsx @@ -1,4 +1,4 @@ -import { For } from 'solid-js' +import { For, Show, createMemo } from 'solid-js' import { coreFeatures, stockFeatures } from '@tanstack/table-core' import { useTableDevtoolsContext } from '../TableContextProvider' import { useTableStore } from '../useTableStore' @@ -6,6 +6,8 @@ import { useStyles } from '../styles/use-styles' import { NoTableConnected } from './NoTableConnected' import { ResizableSplit } from './ResizableSplit' +import type { RowData, Table } from '@tanstack/table-core' + type FnBuckets = Partial< Record<'filterFns' | 'sortFns' | 'aggregationFns', Record> > @@ -101,12 +103,16 @@ const ROW_MODEL_TO_GETTER: Record< } function getRowCountForModel( - tableInstance: { [key: string]: unknown } | undefined, + tableInstance: Table<{}, RowData> | undefined, rowModelName: string, ): number { const getter = ROW_MODEL_TO_GETTER[rowModelName] - if (!getter || typeof tableInstance?.[getter] !== 'function') return 0 - const result = (tableInstance[getter] as () => { rows?: Array })() + if (!getter || !tableInstance) return 0 + + const tableRecord = tableInstance as unknown as Record + if (typeof tableRecord[getter] !== 'function') return 0 + + const result = (tableRecord[getter] as () => { rows?: Array })() return result.rows?.length ?? 0 } @@ -126,43 +132,58 @@ export function FeaturesPanel() { const styles = useStyles() const { table } = useTableDevtoolsContext() - const tableInstance = table() const tableState = useTableStore( - tableInstance ? tableInstance.store : undefined, + () => table()?.store, (state) => state, ) + const tableOptions = useTableStore( + () => { + const tableInstance = table() + return tableInstance?.optionsStore ?? tableInstance?.store + }, + () => table()?.options as unknown, + ) - if (!tableInstance) { - return - } + const tableFeatures = createMemo((): Set => { + const tableInstance = table() + if (!tableInstance) return new Set() - const getTableFeatures = (): Set => { - tableState?.() - return new Set(Object.keys(tableInstance?._features ?? {})) - } + tableState() + return new Set(Object.keys(tableInstance._features ?? {})) + }) - const getRowModelNames = (): Array => { - tableState?.() - return Object.keys(tableInstance?.options._rowModels ?? {}) - } + const rowModelNames = createMemo((): Array => { + const tableInstance = table() + if (!tableInstance) return [] + + tableState() + tableOptions() + + return Object.keys(tableInstance.options._rowModels ?? {}) + }) const getFnNames = ( kind: 'filterFns' | 'sortFns' | 'aggregationFns', ): Array => { - tableState?.() - const rowModelFns = toFnBuckets(tableInstance?._rowModelFns) - const optionFns = toFnBuckets(tableInstance?.options) + const tableInstance = table() + if (!tableInstance) return [] + + tableState() + tableOptions() + + const rowModelFns = toFnBuckets(tableInstance._rowModelFns) + const optionFns = toFnBuckets(tableInstance.options) return Object.keys(rowModelFns[kind] ?? optionFns[kind] ?? {}) } - const getAdditionalPlugins = (): Array => { - const tableFeatures = getTableFeatures() + const additionalPlugins = createMemo((): Array => { + const currentFeatures = tableFeatures() const knownFeatures = new Set([ ...CORE_FEATURE_NAMES, ...STOCK_FEATURE_NAMES, ]) - return [...tableFeatures].filter((f) => !knownFeatures.has(f)).sort() - } + return [...currentFeatures].filter((f) => !knownFeatures.has(f)).sort() + }) const getRowModelFunctions = (rowModelName: string): Array => { const fnKind = ROW_MODEL_TO_FN_KIND[rowModelName] @@ -170,22 +191,46 @@ export function FeaturesPanel() { return getFnNames(fnKind) } - const tableFeatures = getTableFeatures() - const rowModelNames = getRowModelNames() - const enabledFeatureEstimate = [...tableFeatures].reduce( - (total, featureName) => { + const enabledFeatureEstimate = createMemo(() => + [...tableFeatures()].reduce((total, featureName) => { return total + (FEATURE_SIZE_ESTIMATES_BYTES[featureName] ?? 0) - }, - 0, + }, 0), + ) + const enabledRowModelEstimate = createMemo(() => + [...new Set(rowModelNames())] + .map((rowModelName) => normalizeRowModelEstimateKey(rowModelName)) + .filter((rowModelName, index, all) => all.indexOf(rowModelName) === index) + .reduce((total, rowModelName) => { + return total + (ROW_MODEL_SIZE_ESTIMATES_BYTES[rowModelName] ?? 0) + }, 0), + ) + const totalEstimatedBundleSize = createMemo( + () => enabledFeatureEstimate() + enabledRowModelEstimate(), ) - const enabledRowModelEstimate = [...new Set(rowModelNames)] - .map((rowModelName) => normalizeRowModelEstimateKey(rowModelName)) - .filter((rowModelName, index, all) => all.indexOf(rowModelName) === index) - .reduce((total, rowModelName) => { - return total + (ROW_MODEL_SIZE_ESTIMATES_BYTES[rowModelName] ?? 0) - }, 0) - const totalEstimatedBundleSize = - enabledFeatureEstimate + enabledRowModelEstimate + + const rowModels = createMemo(() => { + const tableInstance = table() + if (!tableInstance) return [] + + tableState() + + return rowModelNames().map((rowModelName) => { + const sharedLabel = ROW_MODEL_SHARED_SIZE_LABELS[rowModelName] + + return { + rowModelName, + fns: getRowModelFunctions(rowModelName), + rowCount: getRowCountForModel(tableInstance, rowModelName), + estimateLabel: + sharedLabel ?? + formatEstimatedSize( + ROW_MODEL_SIZE_ESTIMATES_BYTES[ + normalizeRowModelEstimateKey(rowModelName) + ], + ), + } + }) + }) const renderFeatureItem = ( name: string, @@ -202,142 +247,139 @@ export function FeaturesPanel() { ) return ( - - - Features - - - Estimated table-core package - - - Registered features - {formatEstimatedSize(enabledFeatureEstimate)} - - - Client row models - {formatEstimatedSize(enabledRowModelEstimate)} - - - Total - {formatEstimatedSize(totalEstimatedBundleSize)} + } when={table()}> + + + Features + + + Estimated table-core package + + + Registered features + {formatEstimatedSize(enabledFeatureEstimate())} + + + Client row models + {formatEstimatedSize(enabledRowModelEstimate())} + + + Total + {formatEstimatedSize(totalEstimatedBundleSize())} + + + Allocated from the current `size-limit` metric: minified and + brotlied. + - - Allocated from the current `size-limit` metric: minified and - brotlied. + + + Core Features + + {(name) => + renderFeatureItem( + name, + tableFeatures().has(name), + formatEstimatedSize(FEATURE_SIZE_ESTIMATES_BYTES[name]), + ) + } + - - - - Core Features - - {(name) => - renderFeatureItem( - name, - tableFeatures.has(name), - formatEstimatedSize(FEATURE_SIZE_ESTIMATES_BYTES[name]), - ) - } - - - - - Stock Features - - {(name) => - renderFeatureItem( - name, - tableFeatures.has(name), - formatEstimatedSize(FEATURE_SIZE_ESTIMATES_BYTES[name]), - ) - } - - - {getAdditionalPlugins().length > 0 && ( - Additional Plugins + Stock Features - - {(name) => renderFeatureItem(name, true, 'custom')} + + {(name) => + renderFeatureItem( + name, + tableFeatures().has(name), + formatEstimatedSize(FEATURE_SIZE_ESTIMATES_BYTES[name]), + ) + } - )} - > - } - right={ - <> - - Client Side Row Models and Fns - - - {(rowModelName) => { - const fns = getRowModelFunctions(rowModelName) - const rowCount = getRowCountForModel( - tableInstance, - rowModelName, - ) - const sharedLabel = ROW_MODEL_SHARED_SIZE_LABELS[rowModelName] - const estimateLabel = - sharedLabel ?? - formatEstimatedSize( - ROW_MODEL_SIZE_ESTIMATES_BYTES[ - normalizeRowModelEstimateKey(rowModelName) - ], - ) - return ( + + {additionalPlugins().length > 0 && ( + + + Additional Plugins + + + {(name) => renderFeatureItem(name, true, 'custom')} + + + )} + > + } + right={ + <> + + Client Side Row Models and Fns + + + {(rowModel) => ( - {rowModelName} + + {rowModel.rowModelName} + - {rowCount} rows, {estimateLabel} + {rowModel.rowCount} rows, {rowModel.estimateLabel} - + {(fnName) => ( {fnName} )} - ) - }} - - {rowModelNames.length === 0 && ( - No row models configured - )} - - Full package reference:{' '} - {formatEstimatedSize(PACKAGE_SIZE_LIMIT_BYTES)} - - - Execution Order - - {(getter, index) => { - const rowModelKey = getterToRowModelKey(getter) - const isPresent = - rowModelKey !== null && rowModelNames.includes(rowModelKey) - return ( - <> - {index() > 0 && ' → '} - - {getter} - - > - ) - }} + )} - - > - } - /> - + {rowModelNames().length === 0 && ( + + No row models configured + + )} + + Full package reference:{' '} + {formatEstimatedSize(PACKAGE_SIZE_LIMIT_BYTES)} + + + + Execution Order + + + {(getter, index) => { + const rowModelKey = getterToRowModelKey(getter) + const isPresent = + rowModelKey !== null && + rowModelNames().includes(rowModelKey) + + return ( + <> + {index() > 0 && ' → '} + + {getter} + + > + ) + }} + + + > + } + /> + + ) } diff --git a/packages/table-devtools/src/components/OptionsPanel.tsx b/packages/table-devtools/src/components/OptionsPanel.tsx index ab1c6165d6..243b37f8ba 100644 --- a/packages/table-devtools/src/components/OptionsPanel.tsx +++ b/packages/table-devtools/src/components/OptionsPanel.tsx @@ -1,5 +1,5 @@ import { JsonTree } from '@tanstack/devtools-ui' -import { useSelector } from '@tanstack/solid-store' +import { Show, createMemo } from 'solid-js' import { useTableDevtoolsContext } from '../TableContextProvider' import { useTableStore } from '../useTableStore' import { useStyles } from '../styles/use-styles' @@ -21,37 +21,42 @@ export function OptionsPanel() { const styles = useStyles() const { table } = useTableDevtoolsContext() - const tableInstance = table() - const tableState = tableInstance - ? tableInstance.optionsStore - ? useSelector(tableInstance.optionsStore, (state: unknown) => - projectOptionsForTree(state), - ) - : useTableStore(tableInstance.store, () => - projectOptionsForTree(tableInstance.options as unknown), - ) - : undefined + const tableOptions = useTableStore( + () => { + const tableInstance = table() + return tableInstance?.optionsStore ?? tableInstance?.store + }, + () => { + const tableInstance = table() + return tableInstance + ? projectOptionsForTree(tableInstance.options as unknown) + : undefined + }, + ) - if (!tableInstance) { - return - } + const options = createMemo(() => { + const tableInstance = table() + if (!tableInstance) { + return undefined + } - const getState = (): unknown => { - tableState?.() - return tableState?.() - } + tableOptions() + return projectOptionsForTree(tableInstance.options as unknown) + }) return ( - - - Options - - > - } - right={<>>} - /> - + } when={table()}> + + + Options + + > + } + right={<>>} + /> + + ) } diff --git a/packages/table-devtools/src/components/RowsPanel.tsx b/packages/table-devtools/src/components/RowsPanel.tsx index 29d3f48717..af45649ae9 100644 --- a/packages/table-devtools/src/components/RowsPanel.tsx +++ b/packages/table-devtools/src/components/RowsPanel.tsx @@ -1,4 +1,4 @@ -import { For, createSignal } from 'solid-js' +import { For, Show, createEffect, createMemo, createSignal } from 'solid-js' import { JsonTree } from '@tanstack/devtools-ui' import { useTableDevtoolsContext } from '../TableContextProvider' import { useTableStore } from '../useTableStore' @@ -47,35 +47,52 @@ function stringifyValue(value: unknown): string { export function RowsPanel() { const styles = useStyles() const { table } = useTableDevtoolsContext() - const tableInstance = table() const tableState = useTableStore( - tableInstance ? tableInstance.store : undefined, + () => table()?.store, (state) => state, ) + const tableOptions = useTableStore( + () => { + const tableInstance = table() + return tableInstance?.optionsStore ?? tableInstance?.store + }, + () => table()?.options as unknown, + ) const [selectedRowModel, setSelectedRowModel] = createSignal<(typeof ROW_MODEL_GETTERS)[number]>('getRowModel') - if (!tableInstance) { - return - } + const rawData = createMemo((): unknown => { + const tableInstance = table() + if (!tableInstance) return undefined + + tableState() + tableOptions() - const getRawData = (): unknown => { - tableState?.() const data = tableInstance.options.data as ReadonlyArray if (!Array.isArray(data)) return data if (data.length <= ROW_LIMIT) return data as unknown return data.slice(0, ROW_LIMIT) as unknown - } + }) + + const rawDataTotalCount = createMemo((): number => { + const tableInstance = table() + if (!tableInstance) return 0 + + tableState() + tableOptions() - const getRawDataTotalCount = (): number => { - tableState?.() const data = tableInstance.options.data as ReadonlyArray return Array.isArray(data) ? data.length : 0 - } + }) + + const columns = createMemo((): Array => { + const tableInstance = table() + if (!tableInstance) return [] + + tableState() + tableOptions() - const getColumns = (): Array => { - tableState?.() const tableWithColumnFns = tableInstance as unknown as { getVisibleLeafColumns?: () => Array getAllLeafColumns?: () => Array @@ -86,119 +103,144 @@ export function RowsPanel() { tableWithColumnFns.getAllLeafColumns?.() ?? [] ) - } + }) + + const availableGetters = createMemo( + (): Array<(typeof ROW_MODEL_GETTERS)[number]> => { + const tableInstance = table() + if (!tableInstance) return [] + + const tableRecord = tableInstance as unknown as Record + + return ROW_MODEL_GETTERS.filter( + (name) => typeof tableRecord[name] === 'function', + ) + }, + ) + + createEffect(() => { + const getters = availableGetters() + if (getters.length === 0) return - const getAllRows = (): Array => { - tableState?.() - selectedRowModel() - const getter = tableInstance?.[selectedRowModel()] as + const currentGetter = selectedRowModel() + if (!getters.includes(currentGetter)) { + setSelectedRowModel(getters[0]!) + } + }) + + const allRows = createMemo((): Array => { + const tableInstance = table() + if (!tableInstance) return [] + + tableState() + + const tableRecord = tableInstance as unknown as Record + const getter = tableRecord[selectedRowModel()] as | (() => { rows: Array }) | undefined + return getter?.().rows ?? [] - } + }) - const getRows = (): Array => { - const rows = getAllRows() - return rows.length <= ROW_LIMIT ? rows : rows.slice(0, ROW_LIMIT) - } + const rows = createMemo((): Array => { + const nextRows = allRows() + if (nextRows.length <= ROW_LIMIT) return nextRows + return nextRows.slice(0, ROW_LIMIT) + }) - const getRowsTotalCount = (): number => getAllRows().length + const rowsTotalCount = createMemo(() => allRows().length) const getCells = (row: AnyRow): Array => { - tableState?.() const rowWithMaybeVisibleCells = row as unknown as { getVisibleCells?: () => Array } return rowWithMaybeVisibleCells.getVisibleCells?.() ?? row.getAllCells() } - const getAvailableGetters = (): Array<(typeof ROW_MODEL_GETTERS)[number]> => { - return ROW_MODEL_GETTERS.filter( - (name) => typeof tableInstance[name] === 'function', - ) - } - return ( - - - - Raw Data - {getRawDataTotalCount() > ROW_LIMIT && ( - - {' '} - (First {ROW_LIMIT} rows) - - )} - - - > - } - right={ - <> - - Rows ({getRows().length} - {getRowsTotalCount() > ROW_LIMIT && ` of ${getRowsTotalCount()}`}) - {getRowsTotalCount() > ROW_LIMIT && ( - - {' '} - — First {ROW_LIMIT} rows - - )} - - - View: - - setSelectedRowModel( - e.currentTarget.value as (typeof ROW_MODEL_GETTERS)[number], - ) - } - > - - {(getterName) => ( - {getterName} - )} - - - - - - - - # - - {(column) => ( - {column.id} - )} - - - - - - {(row) => ( - - {row.id} - - {(cell) => ( - - {stringifyValue(cell.getValue())} - - )} - - + } when={table()}> + + + + Raw Data + {rawDataTotalCount() > ROW_LIMIT && ( + + {' '} + (First {ROW_LIMIT} rows) + + )} + + + > + } + right={ + <> + + Rows ({rows().length} + {rowsTotalCount() > ROW_LIMIT && ` of ${rowsTotalCount()}`}) + {rowsTotalCount() > ROW_LIMIT && ( + + {' '} + — First {ROW_LIMIT} rows + + )} + + + View: + + setSelectedRowModel( + e.currentTarget + .value as (typeof ROW_MODEL_GETTERS)[number], + ) + } + > + + {(getterName) => ( + {getterName} )} - - - - > - } - /> - + + + + + + + # + + {(column) => ( + {column.id} + )} + + + + + + {(row) => ( + + {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 13f4e19fa6..c321e30d4f 100644 --- a/packages/table-devtools/src/components/Shell.tsx +++ b/packages/table-devtools/src/components/Shell.tsx @@ -1,4 +1,4 @@ -import { Match, Show, Switch } from 'solid-js' +import { For, Match, Show, Switch } from 'solid-js' import { Header, HeaderLogo, MainPanel, Select } from '@tanstack/devtools-ui' import { useTableDevtoolsContext } from '../TableContextProvider' import { useStyles } from '../styles/use-styles' @@ -49,31 +49,35 @@ export function Shell() { - 0}> - - {(_selectedTargetId) => ( - setSelectedTargetId(value)} - /> - )} - + 0 && tableOptions()}> + {(tableOptions) => ( + + {(selectedTargetId) => ( + setSelectedTargetId(value)} + /> + )} + + )} - {tabs.map((tab) => ( - setActiveTab(tab.id)} - > - {tab.label} - - ))} + + {(tab) => ( + setActiveTab(tab.id)} + > + {tab.label} + + )} + diff --git a/packages/table-devtools/src/components/StatePanel.tsx b/packages/table-devtools/src/components/StatePanel.tsx index 2b03b5320c..0f47ccde97 100644 --- a/packages/table-devtools/src/components/StatePanel.tsx +++ b/packages/table-devtools/src/components/StatePanel.tsx @@ -1,6 +1,6 @@ -import { For, createSignal } from 'solid-js' +import { For, Show, createEffect, createMemo, createSignal } from 'solid-js' import { JsonTree } from '@tanstack/devtools-ui' -import { batch, useSelector } from '@tanstack/solid-store' +import { batch } from '@tanstack/solid-store' import { useTableDevtoolsContext } from '../TableContextProvider' import { useTableStore } from '../useTableStore' import { useStyles } from '../styles/use-styles' @@ -22,41 +22,49 @@ export function StatePanel() { const [storeCopied, setStoreCopied] = createSignal(false) const [pasteError, setPasteError] = createSignal(null) - const tableInstance = table() // 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( - tableInstance ? tableInstance.store : undefined, + () => table()?.store, (state) => state, ) - const tableOptions = tableInstance - ? tableInstance.optionsStore - ? useSelector(tableInstance.optionsStore, (opts) => opts) - : useTableStore(tableInstance.store, () => tableInstance.options) - : undefined - - if (!tableInstance) { - return - } + const tableOptions = useTableStore( + () => { + const tableInstance = table() + return tableInstance?.optionsStore ?? tableInstance?.store + }, + () => table()?.options as unknown, + ) - const getInitialState = (): unknown => { - tableState?.() - tableOptions?.() - return tableInstance.initialState as unknown - } + const initialState = createMemo((): unknown => { + const tableInstance = table() + if (!tableInstance) return undefined - const getStoreState = (): unknown => { - tableState?.() - tableOptions?.() - return tableInstance.store.state as unknown - } + tableState() + tableOptions() + + return tableInstance.initialState + }) + + const storeState = createMemo((): unknown => { + const tableInstance = table() + if (!tableInstance) return undefined + + tableState() + tableOptions() + + return tableInstance.store.state + }) + + const atomSlices = createMemo((): Array => { + const tableInstance = table() + if (!tableInstance) return [] - const getAtomSlices = (): Array => { // Touch subscriptions so this recomputes on state or option change. - tableState?.() - tableOptions?.() + tableState() + tableOptions() - const options = tableInstance.options as Record + const options = tableInstance.options as unknown as Record const externalAtoms = (options.atoms as Record | undefined) ?? {} const externalState = @@ -82,7 +90,7 @@ export function StatePanel() { source, } }) - } + }) const copyToClipboard = async ( value: unknown, @@ -98,8 +106,11 @@ export function StatePanel() { } const handlePaste = async () => { + const tableInstance = table() if (!tableInstance) return + setPasteError(null) + try { const text = await navigator.clipboard.readText() const parsed = JSON.parse(text) @@ -130,77 +141,75 @@ export function StatePanel() { } const handleReset = () => { - tableInstance.reset() + table()?.reset() } return ( - - - initialState - - - copyToClipboard(getInitialState(), setInitialStateCopied) - } - disabled={!tableInstance} - > - {initialStateCopied() ? 'Copied!' : 'Copy'} - - - - > - } - middle={ - <> - Atoms - - - Reset to initialState - - - - {(slice) => } - - > - } - right={ - <> - Store - - copyToClipboard(getStoreState(), setStoreCopied)} - disabled={!tableInstance} - > - {storeCopied() ? 'Copied!' : 'Copy'} - - - Paste - - - {pasteError() && ( - {pasteError()} - )} - - > - } - /> - + } when={table()}> + + + initialState + + + copyToClipboard(initialState(), setInitialStateCopied) + } + > + {initialStateCopied() ? 'Copied!' : 'Copy'} + + + + > + } + middle={ + <> + Atoms + + + Reset to initialState + + + + {(slice) => } + + > + } + right={ + <> + Store + + copyToClipboard(storeState(), setStoreCopied)} + > + {storeCopied() ? 'Copied!' : 'Copy'} + + + Paste + + + {pasteError() && ( + {pasteError()} + )} + + > + } + /> + + ) } diff --git a/packages/table-devtools/src/components/ThreeWayResizableSplit.tsx b/packages/table-devtools/src/components/ThreeWayResizableSplit.tsx index fb629a230e..3a5803172e 100644 --- a/packages/table-devtools/src/components/ThreeWayResizableSplit.tsx +++ b/packages/table-devtools/src/components/ThreeWayResizableSplit.tsx @@ -20,6 +20,7 @@ export function ThreeWayResizableSplit(props: ThreeWayResizableSplitProps) { const makeDragHandler = (which: 'left' | 'right'): ((e: MouseEvent) => void) => + // eslint-disable-next-line solid/reactivity (e) => { e.preventDefault() const handleEl = e.currentTarget as HTMLElement diff --git a/packages/table-devtools/src/tableTarget.ts b/packages/table-devtools/src/tableTarget.ts index 0811d957a9..12e86b9646 100644 --- a/packages/table-devtools/src/tableTarget.ts +++ b/packages/table-devtools/src/tableTarget.ts @@ -1,3 +1,4 @@ +import { createEffect, createRoot, createSignal } from 'solid-js' import type { RowData, Table, TableFeatures } from '@tanstack/table-core' type AnyTable = Table @@ -18,18 +19,11 @@ export interface UpsertTableDevtoolsTargetOptions { name?: string } -const registrations = new Map() -const listeners = new Set() +const [registrationsMap, setRegistrationsMap] = createSignal< + Map +>(new Map()) let fallbackNameCounter = 1 -function emitTargets() { - const targets = getTableDevtoolsTargets() - - for (const listener of listeners) { - listener(targets) - } -} - function normalizeName(name?: string) { const trimmedName = name?.trim() return trimmedName ? trimmedName : undefined @@ -38,15 +32,13 @@ function normalizeName(name?: string) { export function upsertTableDevtoolsTarget( options: UpsertTableDevtoolsTargetOptions, ) { + const registrations = registrationsMap() const existingRegistration = registrations.get(options.id) const name = normalizeName(options.name) if (existingRegistration) { - registrations.set(options.id, { - ...existingRegistration, - table: options.table, - name, - }) + existingRegistration.table = options.table + existingRegistration.name = name } else { registrations.set(options.id, { id: options.id, @@ -56,27 +48,31 @@ export function upsertTableDevtoolsTarget( }) } - emitTargets() + setRegistrationsMap(new Map(registrations.entries())) } export function removeTableDevtoolsTarget(id: string) { + const registrations = registrationsMap() if (!registrations.delete(id)) { return } - emitTargets() + setRegistrationsMap(new Map(registrations.entries())) } export function getTableDevtoolsTargets(): Array { - return Array.from(registrations.values()) + return Array.from(registrationsMap().values()) } export function subscribeTableDevtoolsTargets(listener: Listener) { - listeners.add(listener) - - return () => { - listeners.delete(listener) - } + let disposeRoot = () => {} + createRoot((dispose) => { + disposeRoot = dispose + createEffect(() => { + listener(getTableDevtoolsTargets()) + }) + }) + return disposeRoot } export function setTableDevtoolsTarget(table: Table | undefined) { diff --git a/packages/table-devtools/src/useTableStore.ts b/packages/table-devtools/src/useTableStore.ts index 43c2fe748e..70fbfa2ec4 100644 --- a/packages/table-devtools/src/useTableStore.ts +++ b/packages/table-devtools/src/useTableStore.ts @@ -1,4 +1,6 @@ -import { createSignal, onCleanup } from 'solid-js' +import { createEffect, createSignal, onCleanup } from 'solid-js' +import type { Accessor } from 'solid-js' +import type { Readable } from '@tanstack/solid-store' /** * Subscribes to a table store and returns a reactive signal. @@ -6,28 +8,25 @@ import { createSignal, onCleanup } from 'solid-js' * { unsubscribe } object return (store 0.9.x). */ export function useTableStore( - store: - | { state: T; subscribe: (listener: () => void) => unknown } - | null - | undefined, + storeAccessor: Accessor | null | undefined>, selector: (state: T) => U = (s) => s as unknown as U, -): (() => U) | undefined { - if (!store) return undefined +): Accessor { + const initialValue = storeAccessor()?.get() + const [signal, setSignal] = createSignal( + initialValue ? selector(initialValue) : undefined, + ) - const [signal, setSignal] = createSignal(selector(store.state)) - const result = store.subscribe(() => { - setSignal(() => selector(store.state)) - }) + createEffect(() => { + const store = storeAccessor() + if (!store) return + + const subscription = store.subscribe(() => { + setSignal(() => selector(store.get())) + }) - onCleanup(() => { - if (typeof result === 'function') { - ;(result as () => void)() - } else if ( - result && - typeof (result as { unsubscribe?: () => void }).unsubscribe === 'function' - ) { - ;(result as { unsubscribe: () => void }).unsubscribe() - } + onCleanup(() => { + subscription.unsubscribe() + }) }) return signal diff --git a/packages/vue-table/skills/vue/compose-with-tanstack-store/SKILL.md b/packages/vue-table/skills/vue/compose-with-tanstack-store/SKILL.md index 44a82817cc..ddfa0696ba 100644 --- a/packages/vue-table/skills/vue/compose-with-tanstack-store/SKILL.md +++ b/packages/vue-table/skills/vue/compose-with-tanstack-store/SKILL.md @@ -117,8 +117,8 @@ useSelector(table.atoms.sorting) // reactive ref-like computed(() => table.atoms.sorting.get()) // alternative // (b) Flat store — full snapshot. -table.store.state // readonly -table.store.state.sorting // current value +table.state // readonly +table.state.sorting // current value // (c) useTable selector — typed reactive projection. const table = useTable(opts, (s) => ({ sorting: s.sorting })) @@ -285,7 +285,7 @@ const sorting = computed(() => sortingAtom.get()) ### Hallucinating pre-v9 API names (CRITICAL) -`useVueTable`, `table.getState()` — both v8. v9 uses `useTable` and `table.store.state` / +`useVueTable`, `table.getState()` — both v8. v9 uses `useTable` and `table.state` / `table.state` / `table.atoms..get()`. See `tanstack-table/vue/migrate-v8-to-v9`. ### "API missing" because feature not in `_features` (CRITICAL — v9-specific) diff --git a/packages/vue-table/skills/vue/migrate-v8-to-v9/SKILL.md b/packages/vue-table/skills/vue/migrate-v8-to-v9/SKILL.md index 1b96c40688..47b2c6d186 100644 --- a/packages/vue-table/skills/vue/migrate-v8-to-v9/SKILL.md +++ b/packages/vue-table/skills/vue/migrate-v8-to-v9/SKILL.md @@ -5,7 +5,7 @@ description: > → `useTable`, move `getCoreRowModel`/`getSortedRowModel`/etc. options into `_rowModels` factories, add the mandatory `_features` via `tableFeatures({...})`, update `createColumnHelper()` → `createColumnHelper()`, rename - `sortingFn`/`sortingFns` → `sortFn`/`sortFns`, swap `table.getState()` for `table.store.state` + `sortingFn`/`sortingFns` → `sortFn`/`sortFns`, swap `table.getState()` for `table.state` / `table.state` / `table.atoms..get()`, and prefer `` over the legacy `:render`/`:props` shape. Vue has NO `/legacy` entrypoint — migration is a direct rewrite. The Vue adapter installs `vueReactivity()` automatically. @@ -133,7 +133,7 @@ const table = useTable({ | `state.columnSizingInfo` | `state.columnResizing` | | `onColumnSizingInfoChange` | `onColumnResizingChange` | | `ColumnSizing` feature | `columnSizingFeature` + `columnResizingFeature` (split) | -| `table.getState()` | `table.store.state` (full) / `table.state` (selector) / `table.atoms..get()` | +| `table.getState()` | `table.state` (full) / `table.state` (selector) / `table.atoms..get()` | | `row._getAllCellsByColumnId()` | `row.getAllCellsByColumnId()` (underscore removed) | | `table._getFacetedRowModel()` / `_getFacetedMinMaxValues()` / `_getFacetedUniqueValues()` | Same names without leading underscore | | `` | `` / `:header` / `:footer` (preferred; legacy still works) | @@ -193,7 +193,7 @@ const sorting = table.getState().sorting // v9 — pick the narrowest read. const sorting = table.atoms.sorting.get() // narrowest, no full state object built -const snapshot = table.store.state // full readonly view +const snapshot = table.state // full readonly view const table = useTable(opts, (s) => ({ sorting: s.sorting })) // selected reactive state table.state.sorting // typed selector output ``` diff --git a/packages/vue-table/skills/vue/table-state/SKILL.md b/packages/vue-table/skills/vue/table-state/SKILL.md index 510c57d38c..bcfa69f9ef 100644 --- a/packages/vue-table/skills/vue/table-state/SKILL.md +++ b/packages/vue-table/skills/vue/table-state/SKILL.md @@ -127,7 +127,7 @@ The Vue adapter calls `vueReactivity()` and installs it as `coreReativityFeature const sorting = table.atoms.sorting.get() // (b) Flat readonly store — every registered slice as one object -const snapshot = table.store.state +const snapshot = table.state // (c) Vue selected state — the value returned from useTable's 2nd arg const table = useTable( diff --git a/packages/vue-table/src/reactivity.ts b/packages/vue-table/src/reactivity.ts index e04475636d..2f27ddaecf 100644 --- a/packages/vue-table/src/reactivity.ts +++ b/packages/vue-table/src/reactivity.ts @@ -1,4 +1,11 @@ -import { computed, shallowRef, watch } from 'vue' +import { + computed, + getCurrentScope, + onScopeDispose, + shallowRef, + watch, +} from 'vue' +import { batch, createAtom } from '@tanstack/vue-store' import type { TableAtomOptions, TableReactivityBindings, @@ -6,6 +13,8 @@ import type { import type { Atom, Observer, ReadonlyAtom } from '@tanstack/vue-store' import type { ComputedRef, ShallowRef } from 'vue' +const optionsStoreDebugName = 'table/optionsStore' + function observerToCallback( observerOrNext: Observer | ((value: T) => void), ): (value: T) => void { @@ -49,24 +58,47 @@ function refToWritableAtom(source: ShallowRef): Atom { /** * Creates the table-core reactivity bindings used by the Vue adapter. * - * Readonly table atoms are backed by Vue `computed` refs and writable atoms by - * `shallowRef`. Subscriptions use synchronous `watch` callbacks so table store - * updates are visible to Vue render and computed work immediately. + * Table state atoms are backed by TanStack Store atoms. The options store stays + * framework-native because row-model APIs read `table.options` directly during + * render. Readonly table atoms bridge Store dependency tracking into Vue computed + * refs. */ export function vueReactivity(): TableReactivityBindings { return { createOptionsStore: true, schedule: (fn) => queueMicrotask(() => fn()), createReadonlyAtom: (fn: () => T, _options?: TableAtomOptions) => { - return refToReadonlyAtom(computed(fn)) + const storeAtom = createAtom(() => fn(), { + compare: _options?.compare, + }) + const version = shallowRef(0) + const subscription = storeAtom.subscribe(() => { + version.value += 1 + }) + if (getCurrentScope()) { + onScopeDispose(() => subscription.unsubscribe()) + } + + return refToReadonlyAtom( + computed(() => { + version.value + return storeAtom.get() + }), + ) }, createWritableAtom: ( value: T, _options?: TableAtomOptions, ): Atom => { - return refToWritableAtom(shallowRef(value) as ShallowRef) + if (_options?.debugName === optionsStoreDebugName) { + return refToWritableAtom(shallowRef(value) as ShallowRef) + } + + return createAtom(value, { + compare: _options?.compare, + }) }, untrack: (fn) => fn(), - batch: (fn) => fn(), + batch, } } diff --git a/packages/vue-table/src/useTable.ts b/packages/vue-table/src/useTable.ts index dc25973de1..3c12f30c62 100644 --- a/packages/vue-table/src/useTable.ts +++ b/packages/vue-table/src/useTable.ts @@ -62,7 +62,15 @@ export type VueTable< TFeatures extends TableFeatures, TData extends RowData, TSelected = TableState, -> = Table & { +> = Omit, 'store'> & { + /** + * @deprecated Prefer `table.state` for render reads, + * `table.atoms..get()` for slice snapshots, or + * `table.Subscribe` / `useSelector(table.store, selector)` for explicit + * subscriptions. `table.store.state` is a current-value snapshot and is easy + * to misuse in render code. + */ + readonly store: Table['store'] /** * Store mode: `selector` required. Source mode: pass `source` (atom or store); omit * `selector` for the whole value (identity), or pass `selector` to project. Split @@ -94,7 +102,9 @@ export type VueTable< }): VNode | Array } /** - * The selected state of the table. This state may not match the structure of `table.store.state` because it is selected by the `selector` function that you pass as the 2nd argument to `useTable`. + * The selected state of the table. This state may not match the structure of + * the full table state because it is selected by the selector function that + * you pass as the 2nd argument to `useTable`. * * @example * const table = useTable(options, (state) => ({ globalFilter: state.globalFilter })) // only globalFilter is part of the selected state @@ -147,18 +157,15 @@ export function useTable< ) } - const mergedOptions = { - ...tableOptions, + const mergedOptions = mergeProxy(tableOptions, { _features: { coreReativityFeature: vueReactivity(), ...tableOptions._features, }, - } + }) as TableOptionsWithReactiveData const resolvedOptions = mergeProxy( - getOptionsWithReactiveValues( - mergedOptions as TableOptionsWithReactiveData
{JSON.stringify(table.state, null, 2) }
{JSON.stringify(table.store.state.columnOrder, null, 2) + {JSON.stringify(table.state.columnOrder, null, 2) } diff --git a/examples/svelte/column-pinning/src/App.svelte b/examples/svelte/column-pinning/src/App.svelte index 11dcf6c044..aae9ecf419 100644 --- a/examples/svelte/column-pinning/src/App.svelte +++ b/examples/svelte/column-pinning/src/App.svelte @@ -303,5 +303,5 @@ } - {JSON.stringify(table.store.state.columnPinning, null, 2)} + {JSON.stringify(table.state.columnPinning, null, 2)} diff --git a/examples/svelte/column-resizing-performant/src/App.svelte b/examples/svelte/column-resizing-performant/src/App.svelte index 7fe5d770fc..905373dae2 100644 --- a/examples/svelte/column-resizing-performant/src/App.svelte +++ b/examples/svelte/column-resizing-performant/src/App.svelte @@ -111,7 +111,7 @@ - {JSON.stringify(table.store.state, null, 2)} + {JSON.stringify(table.state, null, 2)} ({data.length.toLocaleString()} rows) diff --git a/examples/svelte/column-resizing/src/App.svelte b/examples/svelte/column-resizing/src/App.svelte index acd70ce285..1f32cda8a3 100644 --- a/examples/svelte/column-resizing/src/App.svelte +++ b/examples/svelte/column-resizing/src/App.svelte @@ -99,7 +99,7 @@ header: ReturnType[number]['headers'][number], ) { if (columnResizeMode === 'onEnd' && header.column.getIsResizing()) { - const delta = table.store.state.columnResizing.deltaOffset ?? 0 + const delta = table.state.columnResizing.deltaOffset ?? 0 const dir = table.options.columnResizeDirection === 'rtl' ? -1 : 1 return `translateX(${dir * delta }px)` @@ -271,5 +271,5 @@ - {JSON.stringify(table.store.state, null, 2)} + {JSON.stringify(table.state, null, 2)} diff --git a/examples/svelte/column-sizing/src/App.svelte b/examples/svelte/column-sizing/src/App.svelte index a547912812..beefd91afe 100644 --- a/examples/svelte/column-sizing/src/App.svelte +++ b/examples/svelte/column-sizing/src/App.svelte @@ -85,7 +85,7 @@ value={column.getSize()} oninput={(e) => { table.setColumnSizing({ - ...table.store.state.columnSizing, + ...table.state.columnSizing, [column.id]: Number((e.target as HTMLInputElement).value), }) }} @@ -201,5 +201,5 @@ - {JSON.stringify(table.store.state, null, 2)} + {JSON.stringify(table.state, null, 2)} diff --git a/examples/svelte/column-visibility/src/App.svelte b/examples/svelte/column-visibility/src/App.svelte index e9b3dcd0e1..3fdc5fa7d8 100644 --- a/examples/svelte/column-visibility/src/App.svelte +++ b/examples/svelte/column-visibility/src/App.svelte @@ -170,6 +170,6 @@ - {JSON.stringify(table.store.state.columnVisibility, null, 2) + {JSON.stringify(table.state.columnVisibility, null, 2) } diff --git a/examples/svelte/composable-tables/src/components/PaginationControls.svelte b/examples/svelte/composable-tables/src/components/PaginationControls.svelte index ee6f9ad636..e56b58121f 100644 --- a/examples/svelte/composable-tables/src/components/PaginationControls.svelte +++ b/examples/svelte/composable-tables/src/components/PaginationControls.svelte @@ -38,7 +38,7 @@ Page - {(table.store.state.pagination.pageIndex + 1).toLocaleString()} of {table.getPageCount().toLocaleString()} + {(table.state.pagination.pageIndex + 1).toLocaleString()} of {table.getPageCount().toLocaleString()} @@ -47,7 +47,7 @@ type="number" min="1" max={table.getPageCount()} - value={table.store.state.pagination.pageIndex + 1} + value={table.state.pagination.pageIndex + 1} onchange={(e) => { const page = e.currentTarget.value ? Number(e.currentTarget.value) - 1 : 0 table.setPageIndex(page) @@ -55,7 +55,7 @@ /> { table.setPageSize(Number(e.currentTarget.value)) }} diff --git a/examples/svelte/composable-tables/src/components/ProductsTable.svelte b/examples/svelte/composable-tables/src/components/ProductsTable.svelte index eba8efbdf6..596db98a56 100644 --- a/examples/svelte/composable-tables/src/components/ProductsTable.svelte +++ b/examples/svelte/composable-tables/src/components/ProductsTable.svelte @@ -62,14 +62,14 @@ }) // Reactive derived values from table state - let sorting = $derived(table.store.state.sorting) - let columnFilters = $derived(table.store.state.columnFilters) + let sorting = $derived(table.state.sorting) + let columnFilters = $derived(table.state.columnFilters) // IMPORTANT: Derive rows from table state so Svelte tracks the dependency. // We must read a $state value that changes on every table update. // JSON.stringify forces a deep read, ensuring Svelte sees the dependency. const rows = $derived.by(() => { - JSON.stringify(table.store.state) + JSON.stringify(table.state) return table.getRowModel().rows }) diff --git a/examples/svelte/composable-tables/src/components/UsersTable.svelte b/examples/svelte/composable-tables/src/components/UsersTable.svelte index ed85bff648..a83910e657 100644 --- a/examples/svelte/composable-tables/src/components/UsersTable.svelte +++ b/examples/svelte/composable-tables/src/components/UsersTable.svelte @@ -72,16 +72,16 @@ }) // Reactive derived values from table state. - // Reading table.store.state creates a $state dependency (via the notifier) + // Reading table.state creates a $state dependency (via the notifier) // that triggers re-renders when any table state changes. - let sorting = $derived(table.store.state.sorting) - let columnFilters = $derived(table.store.state.columnFilters) + let sorting = $derived(table.state.sorting) + let columnFilters = $derived(table.state.columnFilters) // IMPORTANT: Derive rows from table state so Svelte tracks the dependency. // We must read a $state value that changes on every table update. // JSON.stringify forces a deep read, ensuring Svelte sees the dependency. const rows = $derived.by(() => { - JSON.stringify(table.store.state) + JSON.stringify(table.state) return table.getRowModel().rows }) diff --git a/examples/svelte/filters-fuzzy/src/App.svelte b/examples/svelte/filters-fuzzy/src/App.svelte index 6034180890..e562313191 100644 --- a/examples/svelte/filters-fuzzy/src/App.svelte +++ b/examples/svelte/filters-fuzzy/src/App.svelte @@ -107,8 +107,8 @@ ) $effect(() => { - if (table.store.state.columnFilters[0]?.id === 'fullName') { - if (table.store.state.sorting[0]?.id !== 'fullName') { + if (table.state.columnFilters[0]?.id === 'fullName') { + if (table.state.sorting[0]?.id !== 'fullName') { table.setSorting([{ id: 'fullName', desc: false }]) } diff --git a/examples/vue/column-visibility/src/App.tsx b/examples/vue/column-visibility/src/App.tsx index 0d400685be..faca800839 100644 --- a/examples/vue/column-visibility/src/App.tsx +++ b/examples/vue/column-visibility/src/App.tsx @@ -182,7 +182,7 @@ export default defineComponent({ - {JSON.stringify(table.store.state, null, 2)} + {JSON.stringify(table.state, null, 2)} ) }, diff --git a/examples/vue/expanding/src/App.tsx b/examples/vue/expanding/src/App.tsx index 4bad74a9ba..a72af6bb64 100644 --- a/examples/vue/expanding/src/App.tsx +++ b/examples/vue/expanding/src/App.tsx @@ -294,7 +294,7 @@ export default defineComponent({ Page - {(table.store.state.pagination.pageIndex + 1).toLocaleString()} of{' '} + {(table.state.pagination.pageIndex + 1).toLocaleString()} of{' '} {table.getPageCount().toLocaleString()} @@ -304,7 +304,7 @@ export default defineComponent({ type="number" min="1" max={table.getPageCount()} - value={table.store.state.pagination.pageIndex + 1} + value={table.state.pagination.pageIndex + 1} onInput={(event: Event) => { const target = event.currentTarget as HTMLInputElement const page = target.value ? Number(target.value) - 1 : 0 @@ -314,7 +314,7 @@ export default defineComponent({ /> { const target = event.currentTarget as HTMLSelectElement table.setPageSize(Number(target.value)) @@ -328,7 +328,7 @@ export default defineComponent({ {table.getRowModel().rows.length.toLocaleString()} Rows - {JSON.stringify(table.store.state, null, 2)} + {JSON.stringify(table.state, null, 2)} ) }, diff --git a/packages/angular-table-devtools/eslint.config.js b/packages/angular-table-devtools/eslint.config.js new file mode 100644 index 0000000000..892f5314df --- /dev/null +++ b/packages/angular-table-devtools/eslint.config.js @@ -0,0 +1,8 @@ +// @ts-check + +import rootConfig from '../../eslint.config.js' + +/** @type {any} */ +const config = [...rootConfig] + +export default config diff --git a/packages/angular-table-devtools/package.json b/packages/angular-table-devtools/package.json new file mode 100644 index 0000000000..6d5d25e4c4 --- /dev/null +++ b/packages/angular-table-devtools/package.json @@ -0,0 +1,54 @@ +{ + "name": "@tanstack/angular-table-devtools", + "version": "9.0.0-alpha.43", + "description": "Angular devtools for TanStack Table.", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/table.git", + "directory": "packages/angular-table-devtools" + }, + "homepage": "https://tanstack.com/table", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "keywords": [ + "angular", + "tanstack", + "table", + "devtools" + ], + "scripts": { + "clean": "rimraf ./build && rimraf ./dist", + "test:eslint": "eslint ./src", + "test:lib": "vitest --passWithNoTests", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc", + "test:build": "publint --strict", + "build": "tsdown" + }, + "type": "module", + "types": "dist/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./production": "./dist/production.js", + "./package.json": "./package.json" + }, + "sideEffects": false, + "engines": { + "node": ">=20" + }, + "files": [ + "dist", + "src" + ], + "dependencies": { + "@tanstack/devtools-utils": "^0.5.0", + "@tanstack/table-core": "workspace:*", + "@tanstack/table-devtools": "workspace:*" + }, + "peerDependencies": { + "@angular/core": ">=21.0.0" + } +} diff --git a/packages/angular-table-devtools/src/TableDevtools.ts b/packages/angular-table-devtools/src/TableDevtools.ts new file mode 100644 index 0000000000..f8c9ff90d7 --- /dev/null +++ b/packages/angular-table-devtools/src/TableDevtools.ts @@ -0,0 +1,34 @@ +import { TableDevtoolsCore } from '@tanstack/table-devtools' +import { createAngularPanel } from '@tanstack/devtools-utils/angular' +import type { DevtoolsPanelProps } from '@tanstack/devtools-utils/angular' + +export interface TableDevtoolsAngularInit extends Partial {} + +const [TableDevtoolsPanelBase, TableDevtoolsPanelNoOpBase] = + createAngularPanel(TableDevtoolsCore) + +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 = TableDevtoolsPanelBase() + return panel(() => resolvePanelProps(props()), host) + } + +export const TableDevtoolsPanelNoOp: TableDevtoolsPanelComponent = + () => (props, host) => { + const panel = TableDevtoolsPanelNoOpBase() + return () => panel + } diff --git a/packages/angular-table-devtools/src/index.ts b/packages/angular-table-devtools/src/index.ts new file mode 100644 index 0000000000..224ae9fee8 --- /dev/null +++ b/packages/angular-table-devtools/src/index.ts @@ -0,0 +1,20 @@ +import { isDevMode } from '@angular/core' +import * as plugin from './plugin' +import * as Devtools from './TableDevtools' +import * as inject from './injectTanStackTableDevtools' + +export const TableDevtoolsPanel = isDevMode() + ? Devtools.TableDevtoolsPanel + : Devtools.TableDevtoolsPanelNoOp + +export const tableDevtoolsPlugin = isDevMode() + ? plugin.tableDevtoolsPlugin + : plugin.tableDevtoolsNoOpPlugin + +export type { TableDevtoolsAngularInit } from './TableDevtools' + +export type { InjectTanStackTableDevtoolsOptions } from './injectTanStackTableDevtools' + +export const injectTanStackTableDevtools = isDevMode() + ? inject.injectTanStackTableDevtools + : inject.injectTanStackTableDevtoolsNoOp diff --git a/packages/angular-table-devtools/src/injectTanStackTableDevtools.ts b/packages/angular-table-devtools/src/injectTanStackTableDevtools.ts new file mode 100644 index 0000000000..d21be1abf6 --- /dev/null +++ b/packages/angular-table-devtools/src/injectTanStackTableDevtools.ts @@ -0,0 +1,68 @@ +import { + removeTableDevtoolsTarget, + upsertTableDevtoolsTarget, +} from '@tanstack/table-devtools' +import { + APP_ID, + DestroyRef, + Injector, + assertInInjectionContext, + effect, + inject, +} from '@angular/core' +import type { Table } from '@tanstack/table-core' + +function normalizeName(name?: string) { + const trimmedName = name?.trim() + return trimmedName ? trimmedName : undefined +} + +let autoId = 0 +function generateId(): string { + const appId = inject(APP_ID) + return `tanstacktable-${appId}_${autoId++}${Date.now().toString(36)}` +} + +export interface InjectTanStackTableDevtoolsOptions { + table: Table | undefined + name: string + enabled?: () => boolean + injector?: Injector +} + +export function injectTanStackTableDevtools( + options: () => InjectTanStackTableDevtoolsOptions, +): void { + const registrationId = generateId() + const enabled = () => options().enabled?.() ?? true + assertInInjectionContext(injectTanStackTableDevtools) + const injector = options().injector ?? inject(Injector) + const destroyRef = inject(DestroyRef) + + effect( + (onCleanup) => { + const { table, name } = options() + const enabledValue = enabled() + if (!enabledValue || !table) { + removeTableDevtoolsTarget(registrationId) + } + upsertTableDevtoolsTarget({ + id: registrationId, + table: table, + name: normalizeName(name), + }) + onCleanup(() => { + removeTableDevtoolsTarget(registrationId) + }) + }, + { injector }, + ) + + destroyRef.onDestroy(() => { + removeTableDevtoolsTarget(registrationId) + }) +} + +export function injectTanStackTableDevtoolsNoOp( + options: () => InjectTanStackTableDevtoolsOptions, +): void {} diff --git a/packages/angular-table-devtools/src/plugin.ts b/packages/angular-table-devtools/src/plugin.ts new file mode 100644 index 0000000000..4db67c28b9 --- /dev/null +++ b/packages/angular-table-devtools/src/plugin.ts @@ -0,0 +1,13 @@ +import { createAngularPlugin } from '@tanstack/devtools-utils/angular' +import { TableDevtoolsPanel } from './TableDevtools' + +type TableDevtoolsPluginFactory = ReturnType[0] + +const plugins = createAngularPlugin({ + name: 'TanStack Table', + render: TableDevtoolsPanel, +}) + +export const tableDevtoolsPlugin: TableDevtoolsPluginFactory = plugins[0] +export const tableDevtoolsNoOpPlugin: TableDevtoolsPluginFactory = + plugins[1] as any diff --git a/packages/angular-table-devtools/src/production.ts b/packages/angular-table-devtools/src/production.ts new file mode 100644 index 0000000000..f3e96534ef --- /dev/null +++ b/packages/angular-table-devtools/src/production.ts @@ -0,0 +1,5 @@ +export { TableDevtoolsPanel } from './TableDevtools' +export type { TableDevtoolsAngularInit } from './TableDevtools' +export { tableDevtoolsPlugin } from './plugin' +export { injectTanStackTableDevtools } from './injectTanStackTableDevtools' +export type { InjectTanStackTableDevtoolsOptions } from './injectTanStackTableDevtools' diff --git a/packages/angular-table-devtools/tsconfig.json b/packages/angular-table-devtools/tsconfig.json new file mode 100644 index 0000000000..7cd68e0598 --- /dev/null +++ b/packages/angular-table-devtools/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": [ + "src", + "tests", + "eslint.config.js", + "vite.config.ts", + "tsdown.config.ts" + ] +} diff --git a/packages/angular-table-devtools/tsdown.config.ts b/packages/angular-table-devtools/tsdown.config.ts new file mode 100644 index 0000000000..63b0b0bd20 --- /dev/null +++ b/packages/angular-table-devtools/tsdown.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + plugins: [], + entry: ['./src/index.ts', './src/production.ts'], + format: ['esm'], + unbundle: true, + dts: true, + sourcemap: true, + clean: true, + minify: false, + fixedExtension: false, + exports: true, + publint: { + strict: true, + }, +}) diff --git a/packages/angular-table-devtools/vite.config.ts b/packages/angular-table-devtools/vite.config.ts new file mode 100644 index 0000000000..8feed8cff3 --- /dev/null +++ b/packages/angular-table-devtools/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import packageJson from './package.json' + +const config = defineConfig({ + plugins: [], + test: { + name: packageJson.name, + dir: './', + watch: false, + environment: 'jsdom', + globals: true, + }, +}) + +export default config diff --git a/packages/angular-table/skills/angular/compose-with-tanstack-store/SKILL.md b/packages/angular-table/skills/angular/compose-with-tanstack-store/SKILL.md index ef5391d9fd..4ce220dcb8 100644 --- a/packages/angular-table/skills/angular/compose-with-tanstack-store/SKILL.md +++ b/packages/angular-table/skills/angular/compose-with-tanstack-store/SKILL.md @@ -60,7 +60,7 @@ tracks the dependency. ```ts this.table.atoms.pagination.get() // current value (reactive) this.table.atoms.pagination.subscribe(obs) // RxJS observer form -this.table.store.state.pagination // flat snapshot read +this.table.state.pagination // flat snapshot read this.table.baseAtoms.pagination.set(...) // direct internal write (avoid) ``` diff --git a/packages/angular-table/skills/angular/migrate-v8-to-v9/SKILL.md b/packages/angular-table/skills/angular/migrate-v8-to-v9/SKILL.md index 3d0603c35c..35e9269a50 100644 --- a/packages/angular-table/skills/angular/migrate-v8-to-v9/SKILL.md +++ b/packages/angular-table/skills/angular/migrate-v8-to-v9/SKILL.md @@ -3,7 +3,7 @@ name: angular/migrate-v8-to-v9 description: > Mechanical v8 → v9 migration for `@tanstack/angular-table`: `createAngularTable` → `injectTable`, `get*RowModel()` options → `_rowModels` factories with explicit `*Fns`, - required `_features` via `tableFeatures()`, `state` access via `table.store.state` instead + required `_features` via `tableFeatures()`, `state` access via `table.state` instead of `table.getState()`, `createColumnHelper()` generic-order flip, every type now requires `TFeatures`, `enablePinning` split into `enableColumnPinning` / `enableRowPinning`, `sortingFn` → `sortFn` rename pile, `ColumnSizingInfo` → `ColumnResizing` @@ -124,21 +124,21 @@ Row-model and feature lookup tables → [`references/v8-to-v9-mapping.md`](refer --- -## 3. State access: `getState()` → `table.store.state` (and atoms) +## 3. State access: `getState()` → `table.state` (and atoms) ```ts // v8 const { sorting, pagination } = table.getState() // v9 — flat snapshot -const { sorting, pagination } = table.store.state +const { sorting, pagination } = table.state // v9 — per slice (signal-backed in Angular) const sorting = table.atoms.sorting.get() const pagination = table.atoms.pagination.get() ``` -In Angular, all three (`table.atoms.`, `table.store.state`, +In Angular, all three (`table.atoms.`, `table.state`, `table.baseAtoms.`) are signal-backed — reading them inside a template, `computed(...)`, or `effect(...)` registers an Angular dependency automatically. No `toSignal(...)` wrappers needed. @@ -275,7 +275,7 @@ v8 backed reactivity with manual memoized getters. v9's adapter `computed` and every writable atom with an Angular `signal`. Consequences: - **No `toSignal(...)` adapters around table state.** Read `table.atoms.x.get()` - / `table.store.state.x` directly inside templates, `computed`, `effect`. + / `table.state.x` directly inside templates, `computed`, `effect`. - **`computed(...)` is for derivation / equality, not for "make it reactive".** Use `{ equal: shallow }` from `@tanstack/angular-table` on object/array slices to skip downstream work on no-op updates. @@ -306,7 +306,7 @@ v8 backed reactivity with manual memoized getters. v9's adapter - [ ] Update `createColumnHelper()` → `createColumnHelper()`. - [ ] Update every `ColumnDef` / `Cell` etc. to include `TFeatures`. -- [ ] Replace `table.getState()` reads with `table.store.state` (or +- [ ] Replace `table.getState()` reads with `table.state` (or `table.atoms..get()` for per-slice reactivity). - [ ] Remove any usage of the v8 single `onStateChange` — split into per-slice `on[State]Change`. @@ -366,9 +366,9 @@ _rowModels: { Same for sorting, pagination, expanding, grouping, faceting. Selection, visibility, ordering, pinning, sizing, resizing do **not** need a row model. -### 4. (HIGH) `getState()` → `table.store.state` text replacement loses reactivity +### 4. (HIGH) `getState()` → `table.state` text replacement loses reactivity -Bulk-replacing `table.getState().x` with `table.store.state.x` works for _current +Bulk-replacing `table.getState().x` with `table.state.x` works for _current value_ reads, but if you used a `computed`/`memo` around `getState()` for reactivity, switch to `table.atoms.x.get()` — it's already signal-backed and needs no wrapper. diff --git a/packages/angular-table/skills/angular/production-readiness/SKILL.md b/packages/angular-table/skills/angular/production-readiness/SKILL.md index 8f0b5764c8..a5caaa42a2 100644 --- a/packages/angular-table/skills/angular/production-readiness/SKILL.md +++ b/packages/angular-table/skills/angular/production-readiness/SKILL.md @@ -6,7 +6,7 @@ description: > stable references OUTSIDE the `injectTable` initializer; pass only the `*Fns` your data needs to `createSortedRowModel` / `createFilteredRowModel` / `createGroupedRowModel`; use `ChangeDetectionStrategy.OnPush`; lean on signal-backed atoms (`table.atoms..get()`) - instead of broad `table.store.state` reads where granularity matters; use `{ equal: shallow }` + instead of broad `table.state` reads where granularity matters; use `{ equal: shallow }` on object/array `computed` selectors; set `getRowId` for stable identity; track by `id` in every `@for`; defer cell components with `flexRenderComponent` only when you need its options; scope DI tokens via `[tanStackTable*]` directives to kill prop drilling. @@ -148,13 +148,13 @@ All `examples/angular/*` use `OnPush`. Match that. --- -## 4. Read narrowly — `table.atoms..get()` over `table.store.state` +## 4. Read narrowly — `table.atoms..get()` over `table.state` Both surfaces are signal-backed. The difference is _which signal_ gets read. ```ts // Wider — depends on the flat snapshot signal (recomputes when ANY registered slice changes) -const pageIndex = computed(() => this.table.store.state.pagination.pageIndex) +const pageIndex = computed(() => this.table.state.pagination.pageIndex) // Narrower — depends only on the pagination atom const pageIndex = computed(() => this.table.atoms.pagination.get().pageIndex) diff --git a/packages/angular-table/skills/angular/table-state/SKILL.md b/packages/angular-table/skills/angular/table-state/SKILL.md index 441a19ddfa..4c09c5a000 100644 --- a/packages/angular-table/skills/angular/table-state/SKILL.md +++ b/packages/angular-table/skills/angular/table-state/SKILL.md @@ -145,7 +145,7 @@ A table instance has three ways to look at its state: | ------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- | | `table.baseAtoms.` | writable TanStack Store atom (always exists for registered slices) | low-level direct write; rare | | `table.atoms.` | **readonly** derived atom per registered feature; backed by Angular `computed` | reading current value or driving reactivity | -| `table.store.state` | flat snapshot object of every registered slice; backed by Angular `computed` | reading multiple slices at once, devtools | +| `table.state` | flat snapshot object of every registered slice; backed by Angular `computed` | reading multiple slices at once, devtools | All three are signal-backed in Angular. Reading any of them inside a template, `computed(...)`, or `effect(...)` registers an Angular dependency. @@ -155,7 +155,7 @@ All three are signal-backed in Angular. Reading any of them inside a template, const pagination = this.table.atoms.pagination.get() // Same value, flat shape -const pagination2 = this.table.store.state.pagination +const pagination2 = this.table.state.pagination // Reactive derivation with custom equality import { computed } from '@angular/core' diff --git a/packages/angular-table/src/injectTable.ts b/packages/angular-table/src/injectTable.ts index a60e18910d..85dd1fb569 100644 --- a/packages/angular-table/src/injectTable.ts +++ b/packages/angular-table/src/injectTable.ts @@ -6,6 +6,7 @@ import { inject, untracked, } from '@angular/core' +import { injectSelector } from '@tanstack/angular-store' import { constructTable } from '@tanstack/table-core' import { lazyInit } from './lazySignalInitializer' import { angularReactivity } from './reactivity' @@ -20,6 +21,7 @@ import type { Table, TableFeatures, TableOptions, + TableState, } from '@tanstack/table-core' export type SubscribeSource = @@ -31,7 +33,19 @@ export type SubscribeSource = export type AngularTable< TFeatures extends TableFeatures, TData extends RowData, -> = Table +> = Table & { + /** + * @deprecated Prefer `table.state` for template/render reads, + * `table.atoms..get()` for slice snapshots, or Angular computed values + * around explicit selectors. `table.state` is a current-value snapshot + * and is easy to misuse in render code. + */ + readonly store: Table['store'] + /** + * The current table state exposed for template/render reads. + */ + readonly state: Readonly> +} /** * Creates and returns an Angular-reactive table instance. @@ -107,6 +121,15 @@ export function injectTable< coreReativityFeature: angularReactivity(injector), ...options()._features, }, + }) as AngularTable + const stateSignal = injectSelector(table.store, undefined, { injector }) + + Object.defineProperty(table, 'state', { + get() { + return stateSignal() + }, + configurable: true, + enumerable: true, }) let isMount = true diff --git a/packages/angular-table/src/reactivity.ts b/packages/angular-table/src/reactivity.ts index ad6907c124..09834cdab6 100644 --- a/packages/angular-table/src/reactivity.ts +++ b/packages/angular-table/src/reactivity.ts @@ -1,5 +1,6 @@ -import { NgZone, computed, signal, untracked } from '@angular/core' +import { DestroyRef, NgZone, computed, signal, untracked } from '@angular/core' import { toObservable } from '@angular/core/rxjs-interop' +import { batch, createAtom } from '@tanstack/angular-store' import type { Atom, Observer, ReadonlyAtom } from '@tanstack/angular-store' import type { TableAtomOptions, @@ -7,6 +8,8 @@ import type { } from '@tanstack/table-core/reactivity' import type { Injector, Signal, WritableSignal } from '@angular/core' +const optionsStoreDebugName = 'table/optionsStore' + function signalToReadonlyAtom( signal: Signal, injector: Injector, @@ -14,9 +17,13 @@ function signalToReadonlyAtom( return Object.assign(signal, { get: () => signal(), subscribe: (observer: Observer) => { - return toObservable(computed(signal), { injector: injector }).subscribe( - observer, - ) + const subscription = toObservable(computed(signal), { + injector: injector, + }).subscribe(observer) + + return { + unsubscribe: () => subscription.unsubscribe(), + } }, }) } @@ -33,9 +40,13 @@ function signalToWritableAtom( }, get: () => signal(), subscribe: (observer: Observer) => { - return toObservable(computed(signal), { injector: injector }).subscribe( - observer, - ) + const subscription = toObservable(computed(signal), { + injector: injector, + }).subscribe(observer) + + return { + unsubscribe: () => subscription.unsubscribe(), + } }, }) } @@ -43,34 +54,59 @@ function signalToWritableAtom( /** * Creates the table-core reactivity bindings used by the Angular adapter. * - * Readonly table atoms are backed by Angular `computed` signals and writable - * atoms by Angular `signal`. Subscriptions bridge through `toObservable` with - * the caller's injector so table APIs can be consumed from Angular `computed` - * and `effect` calls. + * Table state atoms are backed by TanStack Store atoms. The options store stays + * framework-native because row-model APIs read `table.options` directly during + * render. Readonly table atoms bridge Store dependency tracking into Angular + * computed signals. */ export function angularReactivity(injector: Injector): TableReactivityBindings { const ngZone = injector.get(NgZone) + const destroyRef = injector.get(DestroyRef) + return { createOptionsStore: true, schedule: (fn) => ngZone.runOutsideAngular(() => queueMicrotask(fn)), createReadonlyAtom: (fn: () => T, options?: TableAtomOptions) => { - const signal = computed(() => fn(), { - equal: options?.compare, - debugName: options?.debugName, + const storeAtom = createAtom(() => fn(), { + compare: options?.compare, }) - return signalToReadonlyAtom(signal, injector) + const version = signal(0, { + equal: () => false, + }) + const subscription = storeAtom.subscribe(() => { + version.update((value) => value + 1) + }) + destroyRef.onDestroy(() => subscription.unsubscribe()) + + const value = computed( + () => { + version() + return storeAtom.get() + }, + { + equal: options?.compare, + debugName: options?.debugName, + }, + ) + return signalToReadonlyAtom(value, injector) }, createWritableAtom: ( value: T, options?: TableAtomOptions, ): Atom => { - const writableSignal = signal(value, { - equal: options?.compare, - debugName: options?.debugName, + if (options?.debugName === optionsStoreDebugName) { + const writableSignal = signal(value, { + equal: options.compare, + debugName: options.debugName, + }) + return signalToWritableAtom(writableSignal, injector) + } + + return createAtom(value, { + compare: options?.compare, }) - return signalToWritableAtom(writableSignal, injector) }, untrack: untracked, - batch: (fn) => fn(), + batch, } } diff --git a/packages/angular-table/tests/angularReactivityFeature.test.ts b/packages/angular-table/tests/angularReactivityFeature.test.ts index 35f381d716..44657345da 100644 --- a/packages/angular-table/tests/angularReactivityFeature.test.ts +++ b/packages/angular-table/tests/angularReactivityFeature.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test, vi } from 'vitest' import { computed, effect, signal } from '@angular/core' import { TestBed } from '@angular/core/testing' +import { createAtom } from '@tanstack/angular-store' import { injectTable, stockFeatures } from '../src' -import type { ColumnDef } from '../src' +import type { ColumnDef, RowSelectionState } from '../src' import type { WritableSignal } from '@angular/core' describe('angularReactivityFeature', () => { @@ -102,5 +103,43 @@ describe('angularReactivityFeature', () => { [false], ]) }) + + test('methods within effect react to external atom changes', () => { + const rowSelectionAtom = createAtom({}) + const table = TestBed.runInInjectionContext(() => + injectTable(() => ({ + data: data(), + _features: { ...stockFeatures }, + columns: columns, + getRowId: (row) => row.id, + atoms: { + rowSelection: rowSelectionAtom, + }, + })), + ) + const isSelectedRow1Captor = vi.fn<(val: boolean) => void>() + const tableStateCaptor = vi.fn<(val: RowSelectionState) => void>() + + TestBed.runInInjectionContext(() => { + effect(() => { + isSelectedRow1Captor(table.getRow('1').getIsSelected()) + }) + effect(() => { + tableStateCaptor(table.state.rowSelection) + }) + }) + + TestBed.tick() + expect(isSelectedRow1Captor).toHaveBeenCalledTimes(1) + expect(tableStateCaptor).toHaveBeenCalledTimes(1) + + rowSelectionAtom.set({ 1: true }) + TestBed.tick() + + expect(isSelectedRow1Captor).toHaveBeenCalledTimes(2) + expect(tableStateCaptor).toHaveBeenCalledTimes(2) + expect(isSelectedRow1Captor.mock.calls).toEqual([[false], [true]]) + expect(tableStateCaptor.mock.calls).toEqual([[{}], [{ 1: true }]]) + }) }) }) diff --git a/packages/lit-table/skills/lit/lit-table-controller/SKILL.md b/packages/lit-table/skills/lit/lit-table-controller/SKILL.md index 70d2aea1ba..9062252303 100644 --- a/packages/lit-table/skills/lit/lit-table-controller/SKILL.md +++ b/packages/lit-table/skills/lit/lit-table-controller/SKILL.md @@ -210,12 +210,12 @@ class DashboardElement extends LitElement { ## Reading State Off the Controller -The controller's `.table(...)` return value carries everything you usually need: feature methods, `FlexRender`, `Subscribe`, and the `state` projection. Direct reads off `table.atoms..get()` and `table.store.state.` are current-value reads; reactivity comes from the host invalidation subscriptions the controller already wires up. +The controller's `.table(...)` return value carries everything you usually need: feature methods, `FlexRender`, `Subscribe`, and the `state` projection. Direct reads off `table.atoms..get()` and `table.state.` are current-value reads; reactivity comes from the host invalidation subscriptions the controller already wires up. ```ts // Inside render(): const pagination = table.atoms.pagination.get() // current value -const snapshot = table.store.state // current full state +const snapshot = table.state // current full state const selected = table.state // projected via the selector you passed to .table() ``` diff --git a/packages/lit-table/skills/lit/table-state/SKILL.md b/packages/lit-table/skills/lit/table-state/SKILL.md index 8c46af79d0..cb7f17489e 100644 --- a/packages/lit-table/skills/lit/table-state/SKILL.md +++ b/packages/lit-table/skills/lit/table-state/SKILL.md @@ -161,7 +161,7 @@ Direct atom / store reads return the current value without subscribing to change ```ts const pagination = table.atoms.pagination.get() const sorting = table.atoms.sorting.get() -const snapshot = table.store.state +const snapshot = table.state ``` ### 4. `table.Subscribe` in templates diff --git a/packages/lit-table/src/TableController.ts b/packages/lit-table/src/TableController.ts index 44c84e397a..447b30be9f 100644 --- a/packages/lit-table/src/TableController.ts +++ b/packages/lit-table/src/TableController.ts @@ -31,7 +31,14 @@ export type LitTable< TFeatures extends TableFeatures, TData extends RowData, TSelected = TableState, -> = Table & { +> = Omit, 'store'> & { + /** + * @deprecated Prefer `table.state` for render reads, + * `table.atoms..get()` for slice snapshots, or `table.Subscribe` for + * explicit subscriptions. `table.store.state` is a current-value snapshot and + * is easy to misuse in render code. + */ + readonly store: Table['store'] /** * Subscribe to a selected slice of table state, or to a single source (atom or store). * @@ -74,7 +81,7 @@ export type LitTable< } /** * The selected state of the table. This state may not match the structure of - * `table.store.state` because it is selected by the `selector` function that + * the full table state because it is selected by the selector function that * you pass as the 2nd argument to `controller.table()`. * * @example @@ -224,7 +231,7 @@ export class TableController< return (selector?.(tableInstance.store.state) ?? tableInstance.store.state) as TSelected }, - } + } as unknown as LitTable } private _setupSubscriptions() { diff --git a/packages/preact-table/skills/preact/getting-started/SKILL.md b/packages/preact-table/skills/preact/getting-started/SKILL.md index 5f6f1d28ec..38a73a1997 100644 --- a/packages/preact-table/skills/preact/getting-started/SKILL.md +++ b/packages/preact-table/skills/preact/getting-started/SKILL.md @@ -200,7 +200,7 @@ Source: `examples/preact/basic-use-table/src/main.tsx`. ## Step 5 — Drive features with feature APIs -Reach for `table.setSorting(...)`, `table.setPageIndex(...)`, `table.nextPage()`, `column.toggleVisibility()`, `row.toggleSelected()`, etc. — never edit `table.store.state` directly. +Reach for `table.setSorting(...)`, `table.setPageIndex(...)`, `table.nextPage()`, `column.toggleVisibility()`, `row.toggleSelected()`, etc. — never edit `table.state` directly. ```tsx table.setPageIndex(0)} disabled={!table.getCanPreviousPage()}>First diff --git a/packages/preact-table/skills/preact/table-state/SKILL.md b/packages/preact-table/skills/preact/table-state/SKILL.md index a9d09073b3..2abd73ba0f 100644 --- a/packages/preact-table/skills/preact/table-state/SKILL.md +++ b/packages/preact-table/skills/preact/table-state/SKILL.md @@ -252,7 +252,7 @@ function Pager({ table }) { } ``` -`.get()` and `table.store.state` are current-value reads, not subscriptions. The component never re-renders when the atom changes. +`.get()` and `table.state` are current-value reads, not subscriptions. The component never re-renders when the atom changes. Source: `docs/framework/preact/guide/table-state.md`. ### HIGH Passing both `atoms.X` and `state.X` for the same slice diff --git a/packages/preact-table/src/useTable.ts b/packages/preact-table/src/useTable.ts index 2e61faefa1..ddaf2919ab 100644 --- a/packages/preact-table/src/useTable.ts +++ b/packages/preact-table/src/useTable.ts @@ -20,7 +20,15 @@ export type PreactTable< TFeatures extends TableFeatures, TData extends RowData, TSelected = TableState, -> = Table & { +> = Omit, 'store'> & { + /** + * @deprecated Prefer `table.state` for render reads, + * `table.atoms..get()` for slice snapshots, or + * `table.Subscribe` / `useSelector(table.store, selector)` for explicit + * subscriptions. `table.store.state` is a current-value snapshot and is easy + * to misuse in render code. + */ + readonly store: Table['store'] /** * A Preact HOC (Higher Order Component) that allows you to subscribe to the table state. * @@ -71,7 +79,9 @@ export type PreactTable< props: FlexRenderProps, ) => ComponentChildren /** - * The selected state of the table. This state may not match the structure of `table.store.state` because it is selected by the `selector` function that you pass as the 2nd argument to `useTable`. + * The selected state of the table. This state may not match the structure of + * the full table state because it is selected by the selector function that + * you pass as the 2nd argument to `useTable`. */ readonly state: Readonly } @@ -115,7 +125,7 @@ export function useTable< coreReativityFeature: preactReactivity(), ...tableOptions._features, }, - }) as PreactTable + }) as unknown as PreactTable tableInstance.Subscribe = ((props: any) => { const source = props.source ?? tableInstance.store diff --git a/packages/react-table-devtools/src/useTanStackTableDevtools.ts b/packages/react-table-devtools/src/useTanStackTableDevtools.ts index 456f5d8f66..f880651ca5 100644 --- a/packages/react-table-devtools/src/useTanStackTableDevtools.ts +++ b/packages/react-table-devtools/src/useTanStackTableDevtools.ts @@ -5,6 +5,7 @@ import { removeTableDevtoolsTarget, upsertTableDevtoolsTarget, } from '@tanstack/table-devtools' +import { useEffect } from 'react' import type { RowData, Table, TableFeatures } from '@tanstack/table-core' export interface UseTanStackTableDevtoolsOptions { @@ -25,24 +26,32 @@ export function useTanStackTableDevtools< options?: UseTanStackTableDevtoolsOptions, ): void { const registrationId = React.useId() + const normalizedName = normalizeName(name) + + const instanceId = + // instanceId from react table adapter (if it exists) allows for stable devtools registration even if the table instance changes + (table as unknown as { instanceId?: string }).instanceId || + `${registrationId}${normalizedName ? `-${normalizedName}` : ``}` + const enabled = options?.enabled ?? true - React.useEffect(() => { + useEffect(() => { if (!enabled || !table) { - removeTableDevtoolsTarget(registrationId) + removeTableDevtoolsTarget(instanceId) return } upsertTableDevtoolsTarget({ - id: registrationId, + id: instanceId, table, - name: normalizeName(name), + name: normalizedName, }) return () => { - removeTableDevtoolsTarget(registrationId) + removeTableDevtoolsTarget(instanceId) } - }, [enabled, name, registrationId, table]) + // eslint-disable-next-line @eslint-react/exhaustive-deps,react-hooks/exhaustive-deps + }, [enabled, registrationId, instanceId]) } export function useTanStackTableDevtoolsNoOp< diff --git a/packages/react-table/skills/react/compose-with-tanstack-store/SKILL.md b/packages/react-table/skills/react/compose-with-tanstack-store/SKILL.md index ae658f6001..924c7e4284 100644 --- a/packages/react-table/skills/react/compose-with-tanstack-store/SKILL.md +++ b/packages/react-table/skills/react/compose-with-tanstack-store/SKILL.md @@ -179,7 +179,7 @@ function Table({ data, filter }) { | `` / `` | ✓ | Surgical re-render boundaries inside the tree | | `useSelector(table.atoms.X)` | ✓ | Narrowest possible subscription to one slice | | `table.atoms.X.get()` | ✗ current-value read | Inside event handlers / effects | -| `table.store.state` | ✗ current-value read | Debugging / one-shot reads | +| `table.state` | ✗ current-value read | Debugging / one-shot reads | | Write path | Owner | Effect | | ------------------------------- | ----------------- | ---------------------------------------------------------------------------------- | diff --git a/packages/react-table/skills/react/migrate-v8-to-v9/SKILL.md b/packages/react-table/skills/react/migrate-v8-to-v9/SKILL.md index 1eb711865a..c03616f1d0 100644 --- a/packages/react-table/skills/react/migrate-v8-to-v9/SKILL.md +++ b/packages/react-table/skills/react/migrate-v8-to-v9/SKILL.md @@ -6,7 +6,7 @@ description: > memory has a v9 equivalent enumerated below: `useReactTable` → `useTable`, root `get*RowModel` options → `_rowModels` with factory + *Fns parameter, `createColumnHelper` → `createColumnHelper`, - `table.getState()` → `table.store.state` / `table.state` / `table.atoms.X.get()`, + `table.getState()` → `table.state` / `table.state` / `table.atoms.X.get()`, `sortingFn` → `sortFn`, `enablePinning` → split, `_`-prefixed APIs unprefixed, `ColumnSizing` split into `columnSizingFeature` + `columnResizingFeature`. For incremental migration, `useLegacyTable` from `@tanstack/react-table/legacy` @@ -120,12 +120,12 @@ const state = table.getState() const cells = row._getAllCellsByColumnId() // v9 -const all = table.store.state // flat snapshot +const all = table.state // flat snapshot const sorting = table.atoms.sorting.get() // per-slice atom const cells = row.getAllCellsByColumnId() // no underscore — APIs unprefixed ``` -In components, prefer `` over `table.store.state` for reactivity (see `tanstack-table/react/table-state`). +In components, prefer `` over `table.state` for reactivity (see `tanstack-table/react/table-state`). ### Renames @@ -329,7 +329,7 @@ function Toolbar({ table }) { } ``` -`getState` was removed. Use `table.store.state` for a flat snapshot, `table.state` if you passed a `useTable` selector, or `` for reactive reads. +`getState` was removed. Use `table.state` for a flat snapshot, `table.state` if you passed a `useTable` selector, or `` for reactive reads. Source: `docs/framework/react/guide/migrating.md`; `examples/react/basic-subscribe/src/main.tsx`. ### HIGH `enablePinning: true` on v9 diff --git a/packages/react-table/skills/react/production-readiness/SKILL.md b/packages/react-table/skills/react/production-readiness/SKILL.md index 31151548b1..e20a948c93 100644 --- a/packages/react-table/skills/react/production-readiness/SKILL.md +++ b/packages/react-table/skills/react/production-readiness/SKILL.md @@ -269,7 +269,7 @@ function SelectedCount({ table }) { } ``` -`` still selects from `table.store.state` (the full state). For a single slice, `useSelector(table.atoms.X)` skips even constructing the snapshot. +`` still selects from `table.state` (the full state). For a single slice, `useSelector(table.atoms.X)` skips even constructing the snapshot. Source: `docs/framework/react/guide/table-state.md`. ### MEDIUM Hoisting heavy table state reads above virtualizers diff --git a/packages/react-table/skills/react/table-state/SKILL.md b/packages/react-table/skills/react/table-state/SKILL.md index d2de2f220a..3ffb6ac58b 100644 --- a/packages/react-table/skills/react/table-state/SKILL.md +++ b/packages/react-table/skills/react/table-state/SKILL.md @@ -258,7 +258,7 @@ function Pager({ table }) { } ``` -`.get()` and `table.store.state` are current-value reads, not subscriptions. The component never re-renders when the atom changes. +`.get()` and `table.state` are current-value reads, not subscriptions. The component never re-renders when the atom changes. Source: `docs/framework/react/guide/table-state.md`; `examples/react/basic-subscribe/src/main.tsx`. ### HIGH Passing both `atoms.X` and `state.X` for the same slice diff --git a/packages/react-table/src/useLegacyTable.ts b/packages/react-table/src/useLegacyTable.ts index daa0ad7474..989f916624 100644 --- a/packages/react-table/src/useLegacyTable.ts +++ b/packages/react-table/src/useLegacyTable.ts @@ -279,7 +279,7 @@ export type LegacyReactTable = ReactTable< > & { /** * Returns the current table state. - * @deprecated In v9, access state directly via `table.state` or use `table.store.state` for the full state. + * @deprecated In v9, access state directly via `table.state` or use `table.state` for the full state. */ getState: () => TableState /** diff --git a/packages/react-table/src/useTable.ts b/packages/react-table/src/useTable.ts index 8272983306..5ac76f1f55 100644 --- a/packages/react-table/src/useTable.ts +++ b/packages/react-table/src/useTable.ts @@ -1,6 +1,6 @@ 'use client' -import { useMemo, useState } from 'react' +import { useId, useMemo, useRef, useState } from 'react' import { constructTable } from '@tanstack/table-core' import { shallow, useSelector } from '@tanstack/react-store' import { reactReactivity } from './reactivity' @@ -22,7 +22,19 @@ export type ReactTable< TFeatures extends TableFeatures, TData extends RowData, TSelected = TableState, -> = Table & { +> = Omit, 'store'> & { + /** + * @deprecated Prefer `table.state` for render reads, + * `table.atoms..get()` for slice snapshots, or + * `table.Subscribe` / `useSelector(table.store, selector)` for explicit + * subscriptions. `table.store.state` is a current-value snapshot and is easy + * to misuse in render code. + */ + readonly store: Table['store'] + /** + * A stable id reference for table instance + */ + instanceId?: string /** * A React HOC (Higher Order Component) that allows you to subscribe to the table state. * @@ -95,7 +107,9 @@ export type ReactTable< props: FlexRenderProps, ) => ReactNode /** - * The selected state of the table. This state may not match the structure of `table.store.state` because it is selected by the `selector` function that you pass as the 2nd argument to `useTable`. + * The selected state of the table. This state may not match the structure of + * the full table state because it is selected by the selector function that + * you pass as the 2nd argument to `useTable`. * * @example * const table = useTable(options, (state) => ({ globalFilter: state.globalFilter })) // only globalFilter is part of the selected state @@ -105,6 +119,7 @@ export type ReactTable< readonly state: Readonly } +let tableId = 0 /** * Creates a React table instance backed by TanStack Store atoms. * @@ -137,6 +152,13 @@ export function useTable< tableOptions: TableOptions, selector?: (state: TableState) => TSelected, ): ReactTable { + const instanceIdRef = useRef(undefined) + if (!instanceIdRef.current) { + instanceIdRef.current = + 'randomUUID' in globalThis.crypto + ? globalThis.crypto.randomUUID() + : `table-${++tableId}` + } const [table] = useState(() => { const tableInstance = constructTable({ ...tableOptions, @@ -144,7 +166,7 @@ export function useTable< coreReativityFeature: reactReactivity(), ...tableOptions._features, }, - }) as ReactTable + }) as unknown as ReactTable tableInstance.Subscribe = ((props: any) => { const source = props.source ?? tableInstance.store @@ -156,6 +178,7 @@ export function useTable< }) as ReactTable['Subscribe'] tableInstance.FlexRender = FlexRender + tableInstance.instanceId = instanceIdRef.current return tableInstance }) diff --git a/packages/solid-table/skills/solid/production-readiness/SKILL.md b/packages/solid-table/skills/solid/production-readiness/SKILL.md index 4a56e7b84e..52383cf5bb 100644 --- a/packages/solid-table/skills/solid/production-readiness/SKILL.md +++ b/packages/solid-table/skills/solid/production-readiness/SKILL.md @@ -238,10 +238,11 @@ A clear "didn't think about the bundle" tell. Use only the features you render. Keep `createVirtualizer` in the component that owns the scroll container, not high up in the tree. Otherwise scroll-driven recompute fires across the page. -### MEDIUM — re-reading `table.store.state` in JSX when an atom would do +### MEDIUM — re-reading `table.state` in JSX -`table.store.state.pagination` works, but `table.atoms.pagination.get()` or -`useSelector(table.atoms.pagination)` is the per-slice path. Prefer the slice. +Use `table.state()` for component-level reactive reads, or +`table.atoms.pagination.get()` / `useSelector(table.atoms.pagination)` for +per-slice reads. Avoid direct `table.state` reads in JSX. ### MEDIUM — `autoResetPageIndex: true` on a server-driven table diff --git a/packages/solid-table/skills/solid/table-state/SKILL.md b/packages/solid-table/skills/solid/table-state/SKILL.md index adc5252fd7..bf1dd15f0b 100644 --- a/packages/solid-table/skills/solid/table-state/SKILL.md +++ b/packages/solid-table/skills/solid/table-state/SKILL.md @@ -37,14 +37,14 @@ state directly through table APIs inside reactive scopes and never need A `createTable(...)` call produces a `SolidTable` with several state surfaces: -- `table.baseAtoms.` — internal writable atoms (signals). -- `table.atoms.` — readonly derived atoms (memos). One per registered feature slice. -- `table.store` — flat readonly TanStack Store snapshot. `table.store.state.pagination` reads the current value. +- `table.baseAtoms.` — internal writable TanStack Store atoms. Treat these as write plumbing, not a render read surface. +- `table.atoms.` — readonly derived atoms (memos). One per registered feature slice. Use `table.atoms.pagination.get()` for slice-level reactive reads. +- `table.store` — flat readonly TanStack Store snapshot for explicit subscriptions. Prefer `table.state()` or `table.atoms..get()` in JSX. - `table.state()` — **a Solid accessor**, not a value. Returns the result of the selector passed as the second argument to `createTable`. Default selector is identity. State slices only exist for features registered through `_features`. If `rowSortingFeature` is not in `_features`, then `table.atoms.sorting`, -`table.store.state.sorting`, and `state.sorting` are all absent (TS error + missing at runtime). +`table.state().sorting`, and `state.sorting` are all absent (TS error + missing at runtime). ## Creating a table — native signals diff --git a/packages/solid-table/src/createTable.ts b/packages/solid-table/src/createTable.ts index 266cbb74c9..f29ce6c75d 100644 --- a/packages/solid-table/src/createTable.ts +++ b/packages/solid-table/src/createTable.ts @@ -28,7 +28,15 @@ export type SolidTable< TFeatures extends TableFeatures, TData extends RowData, TSelected = TableState, -> = Table & { +> = Omit, 'store'> & { + /** + * @deprecated Prefer `table.state()` for component-level reactive reads, + * `table.atoms..get()` for slice-level reactive reads, or + * `table.Subscribe` / `useSelector(table.store, selector)` for explicit + * subscriptions. Reading `table.state` directly does not follow Solid's + * accessor convention and may not update render code as expected. + */ + readonly store: Table['store'] /** * Subscribe to the store (selector required) or a single source (atom or store). * Source **without** `selector` is a separate overload so children receive @@ -52,7 +60,9 @@ export type SolidTable< }): JSX.Element } /** - * The selected state of the table. This state may not match the structure of `table.store.state` because it is selected by the `selector` function that you pass as the 2nd argument to `createTable`. + * The selected state of the table. This state may not match the structure of + * the full table state because it is selected by the selector function that + * you pass as the 2nd argument to `createTable`. * * @example * const table = createTable(options, (state) => ({ globalFilter: state.globalFilter })) // only globalFilter is part of the selected state @@ -125,7 +135,7 @@ export function createTable< mergedOptions, ) as TableOptions - const table = constructTable(resolvedOptions) as SolidTable< + const table = constructTable(resolvedOptions) as unknown as SolidTable< TFeatures, TData, TSelected diff --git a/packages/solid-table/src/reactivity.ts b/packages/solid-table/src/reactivity.ts index 66571918cd..1d21f0825e 100644 --- a/packages/solid-table/src/reactivity.ts +++ b/packages/solid-table/src/reactivity.ts @@ -1,11 +1,12 @@ import { - batch, createMemo, createSignal, observable, + onCleanup, runWithOwner, untrack, } from 'solid-js' +import { batch, createAtom } from '@tanstack/solid-store' import type { Accessor, Owner, Setter } from 'solid-js' import type { TableAtomOptions, @@ -13,6 +14,8 @@ import type { } from '@tanstack/table-core/reactivity' import type { Atom, Observer, ReadonlyAtom } from '@tanstack/solid-store' +const optionsStoreDebugName = 'table/optionsStore' + function signalToReadonlyAtom( signal: Accessor, owner: Owner, @@ -26,10 +29,10 @@ function signalToReadonlyAtom( } function signalToWritableAtom( - signalTuple: [Accessor, Setter], + signal: Accessor, + setSignal: Setter, owner: Owner, ): Atom { - const [signal, setSignal] = signalTuple return Object.assign(signal, { set: (updater: T | ((prevVal: T) => T)) => { typeof updater === 'function' @@ -46,30 +49,54 @@ function signalToWritableAtom( /** * Creates the table-core reactivity bindings used by the Solid adapter. * - * Readonly table atoms are backed by Solid memos and writable table atoms are - * backed by Solid signals. Subscriptions run with the captured owner so table - * APIs can safely participate in Solid computations. + * Table state atoms are backed by TanStack Store atoms. The options store stays + * framework-native because row-model APIs read `table.options` directly during + * render. Readonly table atoms bridge Store dependency tracking into Solid memos. */ export function solidReactivity(owner: Owner): TableReactivityBindings { return { createOptionsStore: true, schedule: (fn) => queueMicrotask(() => fn()), createReadonlyAtom: (fn: () => T, options?: TableAtomOptions) => { - const signal = createMemo(() => fn(), { - equals: options?.compare, - name: options?.debugName, + const storeAtom = createAtom(() => fn(), { + compare: options?.compare, + }) + const [version, setVersion] = createSignal(0, { equals: false }) + runWithOwner(owner, () => { + const subscription = storeAtom.subscribe(() => { + setVersion((value) => value + 1) + }) + onCleanup(() => subscription.unsubscribe()) }) + + const signal = createMemo( + () => { + version() + return storeAtom.get() + }, + undefined, + { + equals: options?.compare, + name: options?.debugName, + }, + ) return signalToReadonlyAtom(signal, owner) }, createWritableAtom: ( value: T, options?: TableAtomOptions, ): Atom => { - const writableSignal = createSignal(value, { - equals: options?.compare, - name: options?.debugName, + if (options?.debugName === optionsStoreDebugName) { + const [signal, setSignal] = createSignal(value, { + equals: options.compare, + name: options.debugName, + }) + return signalToWritableAtom(signal, setSignal, owner) + } + + return createAtom(value, { + compare: options?.compare, }) - return signalToWritableAtom(writableSignal, owner) }, untrack: untrack, batch: batch, diff --git a/packages/solid-table/tests/unit/reactivity.test.ts b/packages/solid-table/tests/unit/reactivity.test.ts new file mode 100644 index 0000000000..36bfa4dcfa --- /dev/null +++ b/packages/solid-table/tests/unit/reactivity.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'vitest' +import { createRoot, getOwner } from 'solid-js' +import { createAtom } from '@tanstack/solid-store' +import { solidReactivity } from '../../src/reactivity' + +describe('solidReactivity', () => { + test('readonly atoms update when they read external TanStack Store atoms', () => { + createRoot((dispose) => { + const owner = getOwner()! + const reactivity = solidReactivity(owner) + const external = createAtom(1) + const doubled = reactivity.createReadonlyAtom(() => external.get() * 2, { + debugName: 'doubled', + }) + + expect(doubled.get()).toBe(2) + + external.set(2) + + expect(doubled.get()).toBe(4) + dispose() + }) + }) +}) diff --git a/packages/svelte-table/skills/svelte/compose-with-tanstack-store/SKILL.md b/packages/svelte-table/skills/svelte/compose-with-tanstack-store/SKILL.md index 9a9a255c41..6a353ea38d 100644 --- a/packages/svelte-table/skills/svelte/compose-with-tanstack-store/SKILL.md +++ b/packages/svelte-table/skills/svelte/compose-with-tanstack-store/SKILL.md @@ -71,7 +71,7 @@ function logSort() { } ``` -`table.store.state` is the full snapshot equivalent. +`table.state` is the full snapshot equivalent. ## Pattern 2 — Reactive selector via `createTable` diff --git a/packages/svelte-table/skills/svelte/production-readiness/SKILL.md b/packages/svelte-table/skills/svelte/production-readiness/SKILL.md index 8832e88107..466fa22173 100644 --- a/packages/svelte-table/skills/svelte/production-readiness/SKILL.md +++ b/packages/svelte-table/skills/svelte/production-readiness/SKILL.md @@ -160,7 +160,7 @@ function exportSelected() { } ``` -`table.store.state` is the same idea for a full snapshot. +`table.state` is the same idea for a full snapshot. ## 6. Key every `{#each}` block on a stable id diff --git a/packages/svelte-table/skills/svelte/table-state/SKILL.md b/packages/svelte-table/skills/svelte/table-state/SKILL.md index 50f0cff4f7..63d6de8f56 100644 --- a/packages/svelte-table/skills/svelte/table-state/SKILL.md +++ b/packages/svelte-table/skills/svelte/table-state/SKILL.md @@ -109,7 +109,7 @@ Read the atom directly. Cheapest path; only reactive when called inside a rune-t ```ts const sorting = table.atoms.sorting.get() const pagination = table.atoms.pagination.get() -const flat = table.store.state +const flat = table.state ``` ### Reactive read inside markup — `table.state` selector diff --git a/packages/svelte-table/src/createTable.svelte.ts b/packages/svelte-table/src/createTable.svelte.ts index 283b161337..5a8451ac8a 100644 --- a/packages/svelte-table/src/createTable.svelte.ts +++ b/packages/svelte-table/src/createTable.svelte.ts @@ -15,9 +15,19 @@ export type SvelteTable< TFeatures extends TableFeatures, TData extends RowData, TSelected = TableState, -> = Table & { +> = Omit, 'store'> & { /** - * The selected state of the table. This state may not match the structure of `table.store.state` because it is selected by the `selector` function that you pass as the 2nd argument to `createTable`. + * @deprecated Prefer `table.state` for render reads, + * `table.atoms..get()` for slice snapshots, or + * `useSelector(table.store, selector)` for explicit subscriptions. + * `table.store.state` is a current-value snapshot and is easy to misuse in + * render code. + */ + readonly store: Table['store'] + /** + * The selected state of the table. This state may not match the structure of + * the full table state because it is selected by the selector function that + * you pass as the 2nd argument to `createTable`. * * @example * const table = createTable(options, (state) => ({ globalFilter: state.globalFilter })) // only globalFilter is part of the selected state @@ -82,7 +92,7 @@ export function createTable< ) as TableOptions // 3. Construct table - const table = constructTable(resolvedOptions) as SvelteTable< + const table = constructTable(resolvedOptions) as unknown as SvelteTable< TFeatures, TData, TSelected diff --git a/packages/svelte-table/src/reactivity.svelte.ts b/packages/svelte-table/src/reactivity.svelte.ts index 37887d6e2c..ae229e60f5 100644 --- a/packages/svelte-table/src/reactivity.svelte.ts +++ b/packages/svelte-table/src/reactivity.svelte.ts @@ -1,10 +1,13 @@ -import { flushSync, untrack } from 'svelte' +import { untrack } from 'svelte' +import { batch, createAtom } from '@tanstack/svelte-store' import type { TableAtomOptions, TableReactivityBindings, } from '@tanstack/table-core/reactivity' import type { Atom, Observer, ReadonlyAtom } from '@tanstack/svelte-store' +const optionsStoreDebugName = 'table/optionsStore' + function observerToCallback( observerOrNext: Observer | ((value: T) => void), ): (value: T) => void { @@ -28,19 +31,52 @@ function subscribeToRune( return { unsubscribe } } +function createRuneWritableAtom(initialValue: T): Atom { + let value = $state(initialValue) + + return { + set: (updater: T | ((prevVal: T) => T)) => { + value = + typeof updater === 'function' + ? (updater as (prevVal: T) => T)(value) + : updater + }, + get: () => value, + subscribe: ((observerOrNext: Observer | ((value: T) => void)) => { + return subscribeToRune(() => value, observerOrNext) + }) as Atom['subscribe'], + } +} + /** * Creates the table-core reactivity bindings used by the Svelte adapter. * - * Readonly table atoms are backed by `$derived.by`, writable atoms by `$state`, - * and subscriptions bridge through rune effects so table APIs participate in - * Svelte dependency tracking. + * Table state atoms are backed by TanStack Store atoms. The options store stays + * framework-native because row-model APIs read `table.options` directly during + * render. Readonly table atoms bridge Store dependency tracking into `$derived.by`. */ export function svelteReactivity(): TableReactivityBindings { return { createOptionsStore: true, schedule: (fn) => queueMicrotask(() => fn()), createReadonlyAtom: (fn: () => T, _options?: TableAtomOptions) => { - const value = $derived.by(fn) + const storeAtom = createAtom(() => fn(), { + compare: _options?.compare, + }) + let version = $state(0) + + $effect(() => { + const subscription = storeAtom.subscribe(() => { + version += 1 + }) + + return () => subscription.unsubscribe() + }) + + const value = $derived.by(() => { + version + return storeAtom.get() + }) return { get: () => value, @@ -53,22 +89,15 @@ export function svelteReactivity(): TableReactivityBindings { initialValue: T, _options?: TableAtomOptions, ): Atom => { - let value = $state(initialValue) - - return { - set: (updater: T | ((prevVal: T) => T)) => { - value = - typeof updater === 'function' - ? (updater as (prevVal: T) => T)(value) - : updater - }, - get: () => value, - subscribe: ((observerOrNext: Observer | ((value: T) => void)) => { - return subscribeToRune(() => value, observerOrNext) - }) as Atom['subscribe'], + if (_options?.debugName === optionsStoreDebugName) { + return createRuneWritableAtom(initialValue) } + + return createAtom(initialValue, { + compare: _options?.compare, + }) }, untrack: untrack, - batch: (fn) => flushSync(fn), + batch, } } diff --git a/packages/table-devtools/eslint.config.js b/packages/table-devtools/eslint.config.js index 5880eb7bfa..9fb656d60d 100644 --- a/packages/table-devtools/eslint.config.js +++ b/packages/table-devtools/eslint.config.js @@ -1,13 +1,9 @@ // @ts-check +import solid from 'eslint-plugin-solid/configs/recommended' import rootConfig from '../../eslint.config.js' /** @type {any} */ -const config = [ - ...rootConfig, - { - rules: {}, - }, -] +const config = [...rootConfig, solid] export default config diff --git a/packages/table-devtools/package.json b/packages/table-devtools/package.json index b1528fcaef..d81b2890b8 100644 --- a/packages/table-devtools/package.json +++ b/packages/table-devtools/package.json @@ -59,6 +59,7 @@ }, "devDependencies": { "@tanstack/table-core": "workspace:*", + "eslint-plugin-solid": "^0.14.5", "vite-plugin-solid": "^2.11.12" } } diff --git a/packages/table-devtools/src/TableContextProvider.tsx b/packages/table-devtools/src/TableContextProvider.tsx index 5b333f30cf..26aa5119fb 100644 --- a/packages/table-devtools/src/TableContextProvider.tsx +++ b/packages/table-devtools/src/TableContextProvider.tsx @@ -15,7 +15,7 @@ import type { RowData, Table, TableFeatures } from '@tanstack/table-core' import type { TableDevtoolsRegistration } from './tableTarget' type TableDevtoolsTabId = 'features' | 'state' | 'options' | 'rows' | 'columns' -type AnyTable = Table +type AnyTable = Table<{}, RowData> interface TableDevtoolsContextValue { targets: Accessor> @@ -31,12 +31,12 @@ const TableDevtoolsContext = createContext< >(undefined) export const TableContextProvider: ParentComponent = (props) => { - const [targets, setTargets] = createSignal>( - getTableDevtoolsTargets(), - ) + const initialTargets = getTableDevtoolsTargets() + const [targets, setTargets] = + createSignal>(initialTargets) const [selectedTargetId, setSelectedTargetId] = createSignal< string | undefined - >(targets()[0]?.id) + >(initialTargets[0]?.id) const [activeTab, setActiveTab] = createSignal('features') const selectedTarget = createMemo(() => diff --git a/packages/table-devtools/src/components/ColumnsPanel.tsx b/packages/table-devtools/src/components/ColumnsPanel.tsx index bd168f12af..8395b6d400 100644 --- a/packages/table-devtools/src/components/ColumnsPanel.tsx +++ b/packages/table-devtools/src/components/ColumnsPanel.tsx @@ -1,4 +1,4 @@ -import { For } from 'solid-js' +import { For, Show, createMemo } from 'solid-js' import { useTableDevtoolsContext } from '../TableContextProvider' import { useTableStore } from '../useTableStore' import { useStyles } from '../styles/use-styles' @@ -32,65 +32,61 @@ export function ColumnsPanel() { const styles = useStyles() const { table } = useTableDevtoolsContext() - const tableInstance = table() const tableState = useTableStore( - tableInstance ? tableInstance.store : undefined, + () => table()?.store, (state) => state, ) - if (!tableInstance) { - return - } - - const getColumns = (): Array => { - tableState?.() - const tableWithColumnFns = tableInstance as unknown as { - getAllFlatColumns?: () => Array - getAllLeafColumns?: () => Array + const columns = createMemo>(() => { + const tableInstance = table() + if (!tableInstance) { + return [] } + tableState() + return ( - tableWithColumnFns.getAllFlatColumns?.() ?? - tableWithColumnFns.getAllLeafColumns?.() ?? + tableInstance.getAllFlatColumns?.() ?? + tableInstance.getAllLeafColumns?.() ?? [] ) - } - - const columns = getColumns() + }) return ( - - Columns ({columns.length}) - - - - - # - id - depth - accessor - columnDef - - - - - {(column, index) => ( - - {index() + 1} - {column.id} - {column.depth} - - {column.accessorFn ? '✓' : '○'} - - - {getColumnDefSummary(column)} - - - )} - - - + } when={table()}> + + Columns ({columns().length}) + + + + + # + id + depth + accessor + columnDef + + + + + {(column, index) => ( + + {index() + 1} + {column.id} + {column.depth} + + {column.accessorFn ? '✓' : '○'} + + + {getColumnDefSummary(column)} + + + )} + + + + - + ) } diff --git a/packages/table-devtools/src/components/FeaturesPanel.tsx b/packages/table-devtools/src/components/FeaturesPanel.tsx index 2deecafbe7..b8eb23ebe3 100644 --- a/packages/table-devtools/src/components/FeaturesPanel.tsx +++ b/packages/table-devtools/src/components/FeaturesPanel.tsx @@ -1,4 +1,4 @@ -import { For } from 'solid-js' +import { For, Show, createMemo } from 'solid-js' import { coreFeatures, stockFeatures } from '@tanstack/table-core' import { useTableDevtoolsContext } from '../TableContextProvider' import { useTableStore } from '../useTableStore' @@ -6,6 +6,8 @@ import { useStyles } from '../styles/use-styles' import { NoTableConnected } from './NoTableConnected' import { ResizableSplit } from './ResizableSplit' +import type { RowData, Table } from '@tanstack/table-core' + type FnBuckets = Partial< Record<'filterFns' | 'sortFns' | 'aggregationFns', Record> > @@ -101,12 +103,16 @@ const ROW_MODEL_TO_GETTER: Record< } function getRowCountForModel( - tableInstance: { [key: string]: unknown } | undefined, + tableInstance: Table<{}, RowData> | undefined, rowModelName: string, ): number { const getter = ROW_MODEL_TO_GETTER[rowModelName] - if (!getter || typeof tableInstance?.[getter] !== 'function') return 0 - const result = (tableInstance[getter] as () => { rows?: Array })() + if (!getter || !tableInstance) return 0 + + const tableRecord = tableInstance as unknown as Record + if (typeof tableRecord[getter] !== 'function') return 0 + + const result = (tableRecord[getter] as () => { rows?: Array })() return result.rows?.length ?? 0 } @@ -126,43 +132,58 @@ export function FeaturesPanel() { const styles = useStyles() const { table } = useTableDevtoolsContext() - const tableInstance = table() const tableState = useTableStore( - tableInstance ? tableInstance.store : undefined, + () => table()?.store, (state) => state, ) + const tableOptions = useTableStore( + () => { + const tableInstance = table() + return tableInstance?.optionsStore ?? tableInstance?.store + }, + () => table()?.options as unknown, + ) - if (!tableInstance) { - return - } + const tableFeatures = createMemo((): Set => { + const tableInstance = table() + if (!tableInstance) return new Set() - const getTableFeatures = (): Set => { - tableState?.() - return new Set(Object.keys(tableInstance?._features ?? {})) - } + tableState() + return new Set(Object.keys(tableInstance._features ?? {})) + }) - const getRowModelNames = (): Array => { - tableState?.() - return Object.keys(tableInstance?.options._rowModels ?? {}) - } + const rowModelNames = createMemo((): Array => { + const tableInstance = table() + if (!tableInstance) return [] + + tableState() + tableOptions() + + return Object.keys(tableInstance.options._rowModels ?? {}) + }) const getFnNames = ( kind: 'filterFns' | 'sortFns' | 'aggregationFns', ): Array => { - tableState?.() - const rowModelFns = toFnBuckets(tableInstance?._rowModelFns) - const optionFns = toFnBuckets(tableInstance?.options) + const tableInstance = table() + if (!tableInstance) return [] + + tableState() + tableOptions() + + const rowModelFns = toFnBuckets(tableInstance._rowModelFns) + const optionFns = toFnBuckets(tableInstance.options) return Object.keys(rowModelFns[kind] ?? optionFns[kind] ?? {}) } - const getAdditionalPlugins = (): Array => { - const tableFeatures = getTableFeatures() + const additionalPlugins = createMemo((): Array => { + const currentFeatures = tableFeatures() const knownFeatures = new Set([ ...CORE_FEATURE_NAMES, ...STOCK_FEATURE_NAMES, ]) - return [...tableFeatures].filter((f) => !knownFeatures.has(f)).sort() - } + return [...currentFeatures].filter((f) => !knownFeatures.has(f)).sort() + }) const getRowModelFunctions = (rowModelName: string): Array => { const fnKind = ROW_MODEL_TO_FN_KIND[rowModelName] @@ -170,22 +191,46 @@ export function FeaturesPanel() { return getFnNames(fnKind) } - const tableFeatures = getTableFeatures() - const rowModelNames = getRowModelNames() - const enabledFeatureEstimate = [...tableFeatures].reduce( - (total, featureName) => { + const enabledFeatureEstimate = createMemo(() => + [...tableFeatures()].reduce((total, featureName) => { return total + (FEATURE_SIZE_ESTIMATES_BYTES[featureName] ?? 0) - }, - 0, + }, 0), + ) + const enabledRowModelEstimate = createMemo(() => + [...new Set(rowModelNames())] + .map((rowModelName) => normalizeRowModelEstimateKey(rowModelName)) + .filter((rowModelName, index, all) => all.indexOf(rowModelName) === index) + .reduce((total, rowModelName) => { + return total + (ROW_MODEL_SIZE_ESTIMATES_BYTES[rowModelName] ?? 0) + }, 0), + ) + const totalEstimatedBundleSize = createMemo( + () => enabledFeatureEstimate() + enabledRowModelEstimate(), ) - const enabledRowModelEstimate = [...new Set(rowModelNames)] - .map((rowModelName) => normalizeRowModelEstimateKey(rowModelName)) - .filter((rowModelName, index, all) => all.indexOf(rowModelName) === index) - .reduce((total, rowModelName) => { - return total + (ROW_MODEL_SIZE_ESTIMATES_BYTES[rowModelName] ?? 0) - }, 0) - const totalEstimatedBundleSize = - enabledFeatureEstimate + enabledRowModelEstimate + + const rowModels = createMemo(() => { + const tableInstance = table() + if (!tableInstance) return [] + + tableState() + + return rowModelNames().map((rowModelName) => { + const sharedLabel = ROW_MODEL_SHARED_SIZE_LABELS[rowModelName] + + return { + rowModelName, + fns: getRowModelFunctions(rowModelName), + rowCount: getRowCountForModel(tableInstance, rowModelName), + estimateLabel: + sharedLabel ?? + formatEstimatedSize( + ROW_MODEL_SIZE_ESTIMATES_BYTES[ + normalizeRowModelEstimateKey(rowModelName) + ], + ), + } + }) + }) const renderFeatureItem = ( name: string, @@ -202,142 +247,139 @@ export function FeaturesPanel() { ) return ( - - - Features - - - Estimated table-core package - - - Registered features - {formatEstimatedSize(enabledFeatureEstimate)} - - - Client row models - {formatEstimatedSize(enabledRowModelEstimate)} - - - Total - {formatEstimatedSize(totalEstimatedBundleSize)} + } when={table()}> + + + Features + + + Estimated table-core package + + + Registered features + {formatEstimatedSize(enabledFeatureEstimate())} + + + Client row models + {formatEstimatedSize(enabledRowModelEstimate())} + + + Total + {formatEstimatedSize(totalEstimatedBundleSize())} + + + Allocated from the current `size-limit` metric: minified and + brotlied. + - - Allocated from the current `size-limit` metric: minified and - brotlied. + + + Core Features + + {(name) => + renderFeatureItem( + name, + tableFeatures().has(name), + formatEstimatedSize(FEATURE_SIZE_ESTIMATES_BYTES[name]), + ) + } + - - - - Core Features - - {(name) => - renderFeatureItem( - name, - tableFeatures.has(name), - formatEstimatedSize(FEATURE_SIZE_ESTIMATES_BYTES[name]), - ) - } - - - - - Stock Features - - {(name) => - renderFeatureItem( - name, - tableFeatures.has(name), - formatEstimatedSize(FEATURE_SIZE_ESTIMATES_BYTES[name]), - ) - } - - - {getAdditionalPlugins().length > 0 && ( - Additional Plugins + Stock Features - - {(name) => renderFeatureItem(name, true, 'custom')} + + {(name) => + renderFeatureItem( + name, + tableFeatures().has(name), + formatEstimatedSize(FEATURE_SIZE_ESTIMATES_BYTES[name]), + ) + } - )} - > - } - right={ - <> - - Client Side Row Models and Fns - - - {(rowModelName) => { - const fns = getRowModelFunctions(rowModelName) - const rowCount = getRowCountForModel( - tableInstance, - rowModelName, - ) - const sharedLabel = ROW_MODEL_SHARED_SIZE_LABELS[rowModelName] - const estimateLabel = - sharedLabel ?? - formatEstimatedSize( - ROW_MODEL_SIZE_ESTIMATES_BYTES[ - normalizeRowModelEstimateKey(rowModelName) - ], - ) - return ( + + {additionalPlugins().length > 0 && ( + + + Additional Plugins + + + {(name) => renderFeatureItem(name, true, 'custom')} + + + )} + > + } + right={ + <> + + Client Side Row Models and Fns + + + {(rowModel) => ( - {rowModelName} + + {rowModel.rowModelName} + - {rowCount} rows, {estimateLabel} + {rowModel.rowCount} rows, {rowModel.estimateLabel} - + {(fnName) => ( {fnName} )} - ) - }} - - {rowModelNames.length === 0 && ( - No row models configured - )} - - Full package reference:{' '} - {formatEstimatedSize(PACKAGE_SIZE_LIMIT_BYTES)} - - - Execution Order - - {(getter, index) => { - const rowModelKey = getterToRowModelKey(getter) - const isPresent = - rowModelKey !== null && rowModelNames.includes(rowModelKey) - return ( - <> - {index() > 0 && ' → '} - - {getter} - - > - ) - }} + )} - - > - } - /> - + {rowModelNames().length === 0 && ( + + No row models configured + + )} + + Full package reference:{' '} + {formatEstimatedSize(PACKAGE_SIZE_LIMIT_BYTES)} + + + + Execution Order + + + {(getter, index) => { + const rowModelKey = getterToRowModelKey(getter) + const isPresent = + rowModelKey !== null && + rowModelNames().includes(rowModelKey) + + return ( + <> + {index() > 0 && ' → '} + + {getter} + + > + ) + }} + + + > + } + /> + + ) } diff --git a/packages/table-devtools/src/components/OptionsPanel.tsx b/packages/table-devtools/src/components/OptionsPanel.tsx index ab1c6165d6..243b37f8ba 100644 --- a/packages/table-devtools/src/components/OptionsPanel.tsx +++ b/packages/table-devtools/src/components/OptionsPanel.tsx @@ -1,5 +1,5 @@ import { JsonTree } from '@tanstack/devtools-ui' -import { useSelector } from '@tanstack/solid-store' +import { Show, createMemo } from 'solid-js' import { useTableDevtoolsContext } from '../TableContextProvider' import { useTableStore } from '../useTableStore' import { useStyles } from '../styles/use-styles' @@ -21,37 +21,42 @@ export function OptionsPanel() { const styles = useStyles() const { table } = useTableDevtoolsContext() - const tableInstance = table() - const tableState = tableInstance - ? tableInstance.optionsStore - ? useSelector(tableInstance.optionsStore, (state: unknown) => - projectOptionsForTree(state), - ) - : useTableStore(tableInstance.store, () => - projectOptionsForTree(tableInstance.options as unknown), - ) - : undefined + const tableOptions = useTableStore( + () => { + const tableInstance = table() + return tableInstance?.optionsStore ?? tableInstance?.store + }, + () => { + const tableInstance = table() + return tableInstance + ? projectOptionsForTree(tableInstance.options as unknown) + : undefined + }, + ) - if (!tableInstance) { - return - } + const options = createMemo(() => { + const tableInstance = table() + if (!tableInstance) { + return undefined + } - const getState = (): unknown => { - tableState?.() - return tableState?.() - } + tableOptions() + return projectOptionsForTree(tableInstance.options as unknown) + }) return ( - - - Options - - > - } - right={<>>} - /> - + } when={table()}> + + + Options + + > + } + right={<>>} + /> + + ) } diff --git a/packages/table-devtools/src/components/RowsPanel.tsx b/packages/table-devtools/src/components/RowsPanel.tsx index 29d3f48717..af45649ae9 100644 --- a/packages/table-devtools/src/components/RowsPanel.tsx +++ b/packages/table-devtools/src/components/RowsPanel.tsx @@ -1,4 +1,4 @@ -import { For, createSignal } from 'solid-js' +import { For, Show, createEffect, createMemo, createSignal } from 'solid-js' import { JsonTree } from '@tanstack/devtools-ui' import { useTableDevtoolsContext } from '../TableContextProvider' import { useTableStore } from '../useTableStore' @@ -47,35 +47,52 @@ function stringifyValue(value: unknown): string { export function RowsPanel() { const styles = useStyles() const { table } = useTableDevtoolsContext() - const tableInstance = table() const tableState = useTableStore( - tableInstance ? tableInstance.store : undefined, + () => table()?.store, (state) => state, ) + const tableOptions = useTableStore( + () => { + const tableInstance = table() + return tableInstance?.optionsStore ?? tableInstance?.store + }, + () => table()?.options as unknown, + ) const [selectedRowModel, setSelectedRowModel] = createSignal<(typeof ROW_MODEL_GETTERS)[number]>('getRowModel') - if (!tableInstance) { - return - } + const rawData = createMemo((): unknown => { + const tableInstance = table() + if (!tableInstance) return undefined + + tableState() + tableOptions() - const getRawData = (): unknown => { - tableState?.() const data = tableInstance.options.data as ReadonlyArray if (!Array.isArray(data)) return data if (data.length <= ROW_LIMIT) return data as unknown return data.slice(0, ROW_LIMIT) as unknown - } + }) + + const rawDataTotalCount = createMemo((): number => { + const tableInstance = table() + if (!tableInstance) return 0 + + tableState() + tableOptions() - const getRawDataTotalCount = (): number => { - tableState?.() const data = tableInstance.options.data as ReadonlyArray return Array.isArray(data) ? data.length : 0 - } + }) + + const columns = createMemo((): Array => { + const tableInstance = table() + if (!tableInstance) return [] + + tableState() + tableOptions() - const getColumns = (): Array => { - tableState?.() const tableWithColumnFns = tableInstance as unknown as { getVisibleLeafColumns?: () => Array getAllLeafColumns?: () => Array @@ -86,119 +103,144 @@ export function RowsPanel() { tableWithColumnFns.getAllLeafColumns?.() ?? [] ) - } + }) + + const availableGetters = createMemo( + (): Array<(typeof ROW_MODEL_GETTERS)[number]> => { + const tableInstance = table() + if (!tableInstance) return [] + + const tableRecord = tableInstance as unknown as Record + + return ROW_MODEL_GETTERS.filter( + (name) => typeof tableRecord[name] === 'function', + ) + }, + ) + + createEffect(() => { + const getters = availableGetters() + if (getters.length === 0) return - const getAllRows = (): Array => { - tableState?.() - selectedRowModel() - const getter = tableInstance?.[selectedRowModel()] as + const currentGetter = selectedRowModel() + if (!getters.includes(currentGetter)) { + setSelectedRowModel(getters[0]!) + } + }) + + const allRows = createMemo((): Array => { + const tableInstance = table() + if (!tableInstance) return [] + + tableState() + + const tableRecord = tableInstance as unknown as Record + const getter = tableRecord[selectedRowModel()] as | (() => { rows: Array }) | undefined + return getter?.().rows ?? [] - } + }) - const getRows = (): Array => { - const rows = getAllRows() - return rows.length <= ROW_LIMIT ? rows : rows.slice(0, ROW_LIMIT) - } + const rows = createMemo((): Array => { + const nextRows = allRows() + if (nextRows.length <= ROW_LIMIT) return nextRows + return nextRows.slice(0, ROW_LIMIT) + }) - const getRowsTotalCount = (): number => getAllRows().length + const rowsTotalCount = createMemo(() => allRows().length) const getCells = (row: AnyRow): Array => { - tableState?.() const rowWithMaybeVisibleCells = row as unknown as { getVisibleCells?: () => Array } return rowWithMaybeVisibleCells.getVisibleCells?.() ?? row.getAllCells() } - const getAvailableGetters = (): Array<(typeof ROW_MODEL_GETTERS)[number]> => { - return ROW_MODEL_GETTERS.filter( - (name) => typeof tableInstance[name] === 'function', - ) - } - return ( - - - - Raw Data - {getRawDataTotalCount() > ROW_LIMIT && ( - - {' '} - (First {ROW_LIMIT} rows) - - )} - - - > - } - right={ - <> - - Rows ({getRows().length} - {getRowsTotalCount() > ROW_LIMIT && ` of ${getRowsTotalCount()}`}) - {getRowsTotalCount() > ROW_LIMIT && ( - - {' '} - — First {ROW_LIMIT} rows - - )} - - - View: - - setSelectedRowModel( - e.currentTarget.value as (typeof ROW_MODEL_GETTERS)[number], - ) - } - > - - {(getterName) => ( - {getterName} - )} - - - - - - - - # - - {(column) => ( - {column.id} - )} - - - - - - {(row) => ( - - {row.id} - - {(cell) => ( - - {stringifyValue(cell.getValue())} - - )} - - + } when={table()}> + + + + Raw Data + {rawDataTotalCount() > ROW_LIMIT && ( + + {' '} + (First {ROW_LIMIT} rows) + + )} + + + > + } + right={ + <> + + Rows ({rows().length} + {rowsTotalCount() > ROW_LIMIT && ` of ${rowsTotalCount()}`}) + {rowsTotalCount() > ROW_LIMIT && ( + + {' '} + — First {ROW_LIMIT} rows + + )} + + + View: + + setSelectedRowModel( + e.currentTarget + .value as (typeof ROW_MODEL_GETTERS)[number], + ) + } + > + + {(getterName) => ( + {getterName} )} - - - - > - } - /> - + + + + + + + # + + {(column) => ( + {column.id} + )} + + + + + + {(row) => ( + + {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 13f4e19fa6..c321e30d4f 100644 --- a/packages/table-devtools/src/components/Shell.tsx +++ b/packages/table-devtools/src/components/Shell.tsx @@ -1,4 +1,4 @@ -import { Match, Show, Switch } from 'solid-js' +import { For, Match, Show, Switch } from 'solid-js' import { Header, HeaderLogo, MainPanel, Select } from '@tanstack/devtools-ui' import { useTableDevtoolsContext } from '../TableContextProvider' import { useStyles } from '../styles/use-styles' @@ -49,31 +49,35 @@ export function Shell() { - 0}> - - {(_selectedTargetId) => ( - setSelectedTargetId(value)} - /> - )} - + 0 && tableOptions()}> + {(tableOptions) => ( + + {(selectedTargetId) => ( + setSelectedTargetId(value)} + /> + )} + + )} - {tabs.map((tab) => ( - setActiveTab(tab.id)} - > - {tab.label} - - ))} + + {(tab) => ( + setActiveTab(tab.id)} + > + {tab.label} + + )} + diff --git a/packages/table-devtools/src/components/StatePanel.tsx b/packages/table-devtools/src/components/StatePanel.tsx index 2b03b5320c..0f47ccde97 100644 --- a/packages/table-devtools/src/components/StatePanel.tsx +++ b/packages/table-devtools/src/components/StatePanel.tsx @@ -1,6 +1,6 @@ -import { For, createSignal } from 'solid-js' +import { For, Show, createEffect, createMemo, createSignal } from 'solid-js' import { JsonTree } from '@tanstack/devtools-ui' -import { batch, useSelector } from '@tanstack/solid-store' +import { batch } from '@tanstack/solid-store' import { useTableDevtoolsContext } from '../TableContextProvider' import { useTableStore } from '../useTableStore' import { useStyles } from '../styles/use-styles' @@ -22,41 +22,49 @@ export function StatePanel() { const [storeCopied, setStoreCopied] = createSignal(false) const [pasteError, setPasteError] = createSignal(null) - const tableInstance = table() // 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( - tableInstance ? tableInstance.store : undefined, + () => table()?.store, (state) => state, ) - const tableOptions = tableInstance - ? tableInstance.optionsStore - ? useSelector(tableInstance.optionsStore, (opts) => opts) - : useTableStore(tableInstance.store, () => tableInstance.options) - : undefined - - if (!tableInstance) { - return - } + const tableOptions = useTableStore( + () => { + const tableInstance = table() + return tableInstance?.optionsStore ?? tableInstance?.store + }, + () => table()?.options as unknown, + ) - const getInitialState = (): unknown => { - tableState?.() - tableOptions?.() - return tableInstance.initialState as unknown - } + const initialState = createMemo((): unknown => { + const tableInstance = table() + if (!tableInstance) return undefined - const getStoreState = (): unknown => { - tableState?.() - tableOptions?.() - return tableInstance.store.state as unknown - } + tableState() + tableOptions() + + return tableInstance.initialState + }) + + const storeState = createMemo((): unknown => { + const tableInstance = table() + if (!tableInstance) return undefined + + tableState() + tableOptions() + + return tableInstance.store.state + }) + + const atomSlices = createMemo((): Array => { + const tableInstance = table() + if (!tableInstance) return [] - const getAtomSlices = (): Array => { // Touch subscriptions so this recomputes on state or option change. - tableState?.() - tableOptions?.() + tableState() + tableOptions() - const options = tableInstance.options as Record + const options = tableInstance.options as unknown as Record const externalAtoms = (options.atoms as Record | undefined) ?? {} const externalState = @@ -82,7 +90,7 @@ export function StatePanel() { source, } }) - } + }) const copyToClipboard = async ( value: unknown, @@ -98,8 +106,11 @@ export function StatePanel() { } const handlePaste = async () => { + const tableInstance = table() if (!tableInstance) return + setPasteError(null) + try { const text = await navigator.clipboard.readText() const parsed = JSON.parse(text) @@ -130,77 +141,75 @@ export function StatePanel() { } const handleReset = () => { - tableInstance.reset() + table()?.reset() } return ( - - - initialState - - - copyToClipboard(getInitialState(), setInitialStateCopied) - } - disabled={!tableInstance} - > - {initialStateCopied() ? 'Copied!' : 'Copy'} - - - - > - } - middle={ - <> - Atoms - - - Reset to initialState - - - - {(slice) => } - - > - } - right={ - <> - Store - - copyToClipboard(getStoreState(), setStoreCopied)} - disabled={!tableInstance} - > - {storeCopied() ? 'Copied!' : 'Copy'} - - - Paste - - - {pasteError() && ( - {pasteError()} - )} - - > - } - /> - + } when={table()}> + + + initialState + + + copyToClipboard(initialState(), setInitialStateCopied) + } + > + {initialStateCopied() ? 'Copied!' : 'Copy'} + + + + > + } + middle={ + <> + Atoms + + + Reset to initialState + + + + {(slice) => } + + > + } + right={ + <> + Store + + copyToClipboard(storeState(), setStoreCopied)} + > + {storeCopied() ? 'Copied!' : 'Copy'} + + + Paste + + + {pasteError() && ( + {pasteError()} + )} + + > + } + /> + + ) } diff --git a/packages/table-devtools/src/components/ThreeWayResizableSplit.tsx b/packages/table-devtools/src/components/ThreeWayResizableSplit.tsx index fb629a230e..3a5803172e 100644 --- a/packages/table-devtools/src/components/ThreeWayResizableSplit.tsx +++ b/packages/table-devtools/src/components/ThreeWayResizableSplit.tsx @@ -20,6 +20,7 @@ export function ThreeWayResizableSplit(props: ThreeWayResizableSplitProps) { const makeDragHandler = (which: 'left' | 'right'): ((e: MouseEvent) => void) => + // eslint-disable-next-line solid/reactivity (e) => { e.preventDefault() const handleEl = e.currentTarget as HTMLElement diff --git a/packages/table-devtools/src/tableTarget.ts b/packages/table-devtools/src/tableTarget.ts index 0811d957a9..12e86b9646 100644 --- a/packages/table-devtools/src/tableTarget.ts +++ b/packages/table-devtools/src/tableTarget.ts @@ -1,3 +1,4 @@ +import { createEffect, createRoot, createSignal } from 'solid-js' import type { RowData, Table, TableFeatures } from '@tanstack/table-core' type AnyTable = Table @@ -18,18 +19,11 @@ export interface UpsertTableDevtoolsTargetOptions { name?: string } -const registrations = new Map() -const listeners = new Set() +const [registrationsMap, setRegistrationsMap] = createSignal< + Map +>(new Map()) let fallbackNameCounter = 1 -function emitTargets() { - const targets = getTableDevtoolsTargets() - - for (const listener of listeners) { - listener(targets) - } -} - function normalizeName(name?: string) { const trimmedName = name?.trim() return trimmedName ? trimmedName : undefined @@ -38,15 +32,13 @@ function normalizeName(name?: string) { export function upsertTableDevtoolsTarget( options: UpsertTableDevtoolsTargetOptions, ) { + const registrations = registrationsMap() const existingRegistration = registrations.get(options.id) const name = normalizeName(options.name) if (existingRegistration) { - registrations.set(options.id, { - ...existingRegistration, - table: options.table, - name, - }) + existingRegistration.table = options.table + existingRegistration.name = name } else { registrations.set(options.id, { id: options.id, @@ -56,27 +48,31 @@ export function upsertTableDevtoolsTarget( }) } - emitTargets() + setRegistrationsMap(new Map(registrations.entries())) } export function removeTableDevtoolsTarget(id: string) { + const registrations = registrationsMap() if (!registrations.delete(id)) { return } - emitTargets() + setRegistrationsMap(new Map(registrations.entries())) } export function getTableDevtoolsTargets(): Array { - return Array.from(registrations.values()) + return Array.from(registrationsMap().values()) } export function subscribeTableDevtoolsTargets(listener: Listener) { - listeners.add(listener) - - return () => { - listeners.delete(listener) - } + let disposeRoot = () => {} + createRoot((dispose) => { + disposeRoot = dispose + createEffect(() => { + listener(getTableDevtoolsTargets()) + }) + }) + return disposeRoot } export function setTableDevtoolsTarget(table: Table | undefined) { diff --git a/packages/table-devtools/src/useTableStore.ts b/packages/table-devtools/src/useTableStore.ts index 43c2fe748e..70fbfa2ec4 100644 --- a/packages/table-devtools/src/useTableStore.ts +++ b/packages/table-devtools/src/useTableStore.ts @@ -1,4 +1,6 @@ -import { createSignal, onCleanup } from 'solid-js' +import { createEffect, createSignal, onCleanup } from 'solid-js' +import type { Accessor } from 'solid-js' +import type { Readable } from '@tanstack/solid-store' /** * Subscribes to a table store and returns a reactive signal. @@ -6,28 +8,25 @@ import { createSignal, onCleanup } from 'solid-js' * { unsubscribe } object return (store 0.9.x). */ export function useTableStore( - store: - | { state: T; subscribe: (listener: () => void) => unknown } - | null - | undefined, + storeAccessor: Accessor | null | undefined>, selector: (state: T) => U = (s) => s as unknown as U, -): (() => U) | undefined { - if (!store) return undefined +): Accessor { + const initialValue = storeAccessor()?.get() + const [signal, setSignal] = createSignal( + initialValue ? selector(initialValue) : undefined, + ) - const [signal, setSignal] = createSignal(selector(store.state)) - const result = store.subscribe(() => { - setSignal(() => selector(store.state)) - }) + createEffect(() => { + const store = storeAccessor() + if (!store) return + + const subscription = store.subscribe(() => { + setSignal(() => selector(store.get())) + }) - onCleanup(() => { - if (typeof result === 'function') { - ;(result as () => void)() - } else if ( - result && - typeof (result as { unsubscribe?: () => void }).unsubscribe === 'function' - ) { - ;(result as { unsubscribe: () => void }).unsubscribe() - } + onCleanup(() => { + subscription.unsubscribe() + }) }) return signal diff --git a/packages/vue-table/skills/vue/compose-with-tanstack-store/SKILL.md b/packages/vue-table/skills/vue/compose-with-tanstack-store/SKILL.md index 44a82817cc..ddfa0696ba 100644 --- a/packages/vue-table/skills/vue/compose-with-tanstack-store/SKILL.md +++ b/packages/vue-table/skills/vue/compose-with-tanstack-store/SKILL.md @@ -117,8 +117,8 @@ useSelector(table.atoms.sorting) // reactive ref-like computed(() => table.atoms.sorting.get()) // alternative // (b) Flat store — full snapshot. -table.store.state // readonly -table.store.state.sorting // current value +table.state // readonly +table.state.sorting // current value // (c) useTable selector — typed reactive projection. const table = useTable(opts, (s) => ({ sorting: s.sorting })) @@ -285,7 +285,7 @@ const sorting = computed(() => sortingAtom.get()) ### Hallucinating pre-v9 API names (CRITICAL) -`useVueTable`, `table.getState()` — both v8. v9 uses `useTable` and `table.store.state` / +`useVueTable`, `table.getState()` — both v8. v9 uses `useTable` and `table.state` / `table.state` / `table.atoms..get()`. See `tanstack-table/vue/migrate-v8-to-v9`. ### "API missing" because feature not in `_features` (CRITICAL — v9-specific) diff --git a/packages/vue-table/skills/vue/migrate-v8-to-v9/SKILL.md b/packages/vue-table/skills/vue/migrate-v8-to-v9/SKILL.md index 1b96c40688..47b2c6d186 100644 --- a/packages/vue-table/skills/vue/migrate-v8-to-v9/SKILL.md +++ b/packages/vue-table/skills/vue/migrate-v8-to-v9/SKILL.md @@ -5,7 +5,7 @@ description: > → `useTable`, move `getCoreRowModel`/`getSortedRowModel`/etc. options into `_rowModels` factories, add the mandatory `_features` via `tableFeatures({...})`, update `createColumnHelper()` → `createColumnHelper()`, rename - `sortingFn`/`sortingFns` → `sortFn`/`sortFns`, swap `table.getState()` for `table.store.state` + `sortingFn`/`sortingFns` → `sortFn`/`sortFns`, swap `table.getState()` for `table.state` / `table.state` / `table.atoms..get()`, and prefer `` over the legacy `:render`/`:props` shape. Vue has NO `/legacy` entrypoint — migration is a direct rewrite. The Vue adapter installs `vueReactivity()` automatically. @@ -133,7 +133,7 @@ const table = useTable({ | `state.columnSizingInfo` | `state.columnResizing` | | `onColumnSizingInfoChange` | `onColumnResizingChange` | | `ColumnSizing` feature | `columnSizingFeature` + `columnResizingFeature` (split) | -| `table.getState()` | `table.store.state` (full) / `table.state` (selector) / `table.atoms..get()` | +| `table.getState()` | `table.state` (full) / `table.state` (selector) / `table.atoms..get()` | | `row._getAllCellsByColumnId()` | `row.getAllCellsByColumnId()` (underscore removed) | | `table._getFacetedRowModel()` / `_getFacetedMinMaxValues()` / `_getFacetedUniqueValues()` | Same names without leading underscore | | `` | `` / `:header` / `:footer` (preferred; legacy still works) | @@ -193,7 +193,7 @@ const sorting = table.getState().sorting // v9 — pick the narrowest read. const sorting = table.atoms.sorting.get() // narrowest, no full state object built -const snapshot = table.store.state // full readonly view +const snapshot = table.state // full readonly view const table = useTable(opts, (s) => ({ sorting: s.sorting })) // selected reactive state table.state.sorting // typed selector output ``` diff --git a/packages/vue-table/skills/vue/table-state/SKILL.md b/packages/vue-table/skills/vue/table-state/SKILL.md index 510c57d38c..bcfa69f9ef 100644 --- a/packages/vue-table/skills/vue/table-state/SKILL.md +++ b/packages/vue-table/skills/vue/table-state/SKILL.md @@ -127,7 +127,7 @@ The Vue adapter calls `vueReactivity()` and installs it as `coreReativityFeature const sorting = table.atoms.sorting.get() // (b) Flat readonly store — every registered slice as one object -const snapshot = table.store.state +const snapshot = table.state // (c) Vue selected state — the value returned from useTable's 2nd arg const table = useTable( diff --git a/packages/vue-table/src/reactivity.ts b/packages/vue-table/src/reactivity.ts index e04475636d..2f27ddaecf 100644 --- a/packages/vue-table/src/reactivity.ts +++ b/packages/vue-table/src/reactivity.ts @@ -1,4 +1,11 @@ -import { computed, shallowRef, watch } from 'vue' +import { + computed, + getCurrentScope, + onScopeDispose, + shallowRef, + watch, +} from 'vue' +import { batch, createAtom } from '@tanstack/vue-store' import type { TableAtomOptions, TableReactivityBindings, @@ -6,6 +13,8 @@ import type { import type { Atom, Observer, ReadonlyAtom } from '@tanstack/vue-store' import type { ComputedRef, ShallowRef } from 'vue' +const optionsStoreDebugName = 'table/optionsStore' + function observerToCallback( observerOrNext: Observer | ((value: T) => void), ): (value: T) => void { @@ -49,24 +58,47 @@ function refToWritableAtom(source: ShallowRef): Atom { /** * Creates the table-core reactivity bindings used by the Vue adapter. * - * Readonly table atoms are backed by Vue `computed` refs and writable atoms by - * `shallowRef`. Subscriptions use synchronous `watch` callbacks so table store - * updates are visible to Vue render and computed work immediately. + * Table state atoms are backed by TanStack Store atoms. The options store stays + * framework-native because row-model APIs read `table.options` directly during + * render. Readonly table atoms bridge Store dependency tracking into Vue computed + * refs. */ export function vueReactivity(): TableReactivityBindings { return { createOptionsStore: true, schedule: (fn) => queueMicrotask(() => fn()), createReadonlyAtom: (fn: () => T, _options?: TableAtomOptions) => { - return refToReadonlyAtom(computed(fn)) + const storeAtom = createAtom(() => fn(), { + compare: _options?.compare, + }) + const version = shallowRef(0) + const subscription = storeAtom.subscribe(() => { + version.value += 1 + }) + if (getCurrentScope()) { + onScopeDispose(() => subscription.unsubscribe()) + } + + return refToReadonlyAtom( + computed(() => { + version.value + return storeAtom.get() + }), + ) }, createWritableAtom: ( value: T, _options?: TableAtomOptions, ): Atom => { - return refToWritableAtom(shallowRef(value) as ShallowRef) + if (_options?.debugName === optionsStoreDebugName) { + return refToWritableAtom(shallowRef(value) as ShallowRef) + } + + return createAtom(value, { + compare: _options?.compare, + }) }, untrack: (fn) => fn(), - batch: (fn) => fn(), + batch, } } diff --git a/packages/vue-table/src/useTable.ts b/packages/vue-table/src/useTable.ts index dc25973de1..3c12f30c62 100644 --- a/packages/vue-table/src/useTable.ts +++ b/packages/vue-table/src/useTable.ts @@ -62,7 +62,15 @@ export type VueTable< TFeatures extends TableFeatures, TData extends RowData, TSelected = TableState, -> = Table & { +> = Omit, 'store'> & { + /** + * @deprecated Prefer `table.state` for render reads, + * `table.atoms..get()` for slice snapshots, or + * `table.Subscribe` / `useSelector(table.store, selector)` for explicit + * subscriptions. `table.store.state` is a current-value snapshot and is easy + * to misuse in render code. + */ + readonly store: Table['store'] /** * Store mode: `selector` required. Source mode: pass `source` (atom or store); omit * `selector` for the whole value (identity), or pass `selector` to project. Split @@ -94,7 +102,9 @@ export type VueTable< }): VNode | Array } /** - * The selected state of the table. This state may not match the structure of `table.store.state` because it is selected by the `selector` function that you pass as the 2nd argument to `useTable`. + * The selected state of the table. This state may not match the structure of + * the full table state because it is selected by the selector function that + * you pass as the 2nd argument to `useTable`. * * @example * const table = useTable(options, (state) => ({ globalFilter: state.globalFilter })) // only globalFilter is part of the selected state @@ -147,18 +157,15 @@ export function useTable< ) } - const mergedOptions = { - ...tableOptions, + const mergedOptions = mergeProxy(tableOptions, { _features: { coreReativityFeature: vueReactivity(), ...tableOptions._features, }, - } + }) as TableOptionsWithReactiveData const resolvedOptions = mergeProxy( - getOptionsWithReactiveValues( - mergedOptions as TableOptionsWithReactiveData
{JSON.stringify(table.state.columnOrder, null, 2) }
{JSON.stringify(table.store.state.columnPinning, null, 2)}
{JSON.stringify(table.state.columnPinning, null, 2)}
- {JSON.stringify(table.store.state, null, 2)} + {JSON.stringify(table.state, null, 2)}
{JSON.stringify(table.state, null, 2)}
{JSON.stringify(table.store.state.columnVisibility, null, 2) + {JSON.stringify(table.state.columnVisibility, null, 2) } diff --git a/examples/svelte/composable-tables/src/components/PaginationControls.svelte b/examples/svelte/composable-tables/src/components/PaginationControls.svelte index ee6f9ad636..e56b58121f 100644 --- a/examples/svelte/composable-tables/src/components/PaginationControls.svelte +++ b/examples/svelte/composable-tables/src/components/PaginationControls.svelte @@ -38,7 +38,7 @@ Page - {(table.store.state.pagination.pageIndex + 1).toLocaleString()} of {table.getPageCount().toLocaleString()} + {(table.state.pagination.pageIndex + 1).toLocaleString()} of {table.getPageCount().toLocaleString()} @@ -47,7 +47,7 @@ type="number" min="1" max={table.getPageCount()} - value={table.store.state.pagination.pageIndex + 1} + value={table.state.pagination.pageIndex + 1} onchange={(e) => { const page = e.currentTarget.value ? Number(e.currentTarget.value) - 1 : 0 table.setPageIndex(page) @@ -55,7 +55,7 @@ /> { table.setPageSize(Number(e.currentTarget.value)) }} diff --git a/examples/svelte/composable-tables/src/components/ProductsTable.svelte b/examples/svelte/composable-tables/src/components/ProductsTable.svelte index eba8efbdf6..596db98a56 100644 --- a/examples/svelte/composable-tables/src/components/ProductsTable.svelte +++ b/examples/svelte/composable-tables/src/components/ProductsTable.svelte @@ -62,14 +62,14 @@ }) // Reactive derived values from table state - let sorting = $derived(table.store.state.sorting) - let columnFilters = $derived(table.store.state.columnFilters) + let sorting = $derived(table.state.sorting) + let columnFilters = $derived(table.state.columnFilters) // IMPORTANT: Derive rows from table state so Svelte tracks the dependency. // We must read a $state value that changes on every table update. // JSON.stringify forces a deep read, ensuring Svelte sees the dependency. const rows = $derived.by(() => { - JSON.stringify(table.store.state) + JSON.stringify(table.state) return table.getRowModel().rows }) diff --git a/examples/svelte/composable-tables/src/components/UsersTable.svelte b/examples/svelte/composable-tables/src/components/UsersTable.svelte index ed85bff648..a83910e657 100644 --- a/examples/svelte/composable-tables/src/components/UsersTable.svelte +++ b/examples/svelte/composable-tables/src/components/UsersTable.svelte @@ -72,16 +72,16 @@ }) // Reactive derived values from table state. - // Reading table.store.state creates a $state dependency (via the notifier) + // Reading table.state creates a $state dependency (via the notifier) // that triggers re-renders when any table state changes. - let sorting = $derived(table.store.state.sorting) - let columnFilters = $derived(table.store.state.columnFilters) + let sorting = $derived(table.state.sorting) + let columnFilters = $derived(table.state.columnFilters) // IMPORTANT: Derive rows from table state so Svelte tracks the dependency. // We must read a $state value that changes on every table update. // JSON.stringify forces a deep read, ensuring Svelte sees the dependency. const rows = $derived.by(() => { - JSON.stringify(table.store.state) + JSON.stringify(table.state) return table.getRowModel().rows }) diff --git a/examples/svelte/filters-fuzzy/src/App.svelte b/examples/svelte/filters-fuzzy/src/App.svelte index 6034180890..e562313191 100644 --- a/examples/svelte/filters-fuzzy/src/App.svelte +++ b/examples/svelte/filters-fuzzy/src/App.svelte @@ -107,8 +107,8 @@ ) $effect(() => { - if (table.store.state.columnFilters[0]?.id === 'fullName') { - if (table.store.state.sorting[0]?.id !== 'fullName') { + if (table.state.columnFilters[0]?.id === 'fullName') { + if (table.state.sorting[0]?.id !== 'fullName') { table.setSorting([{ id: 'fullName', desc: false }]) } diff --git a/examples/vue/column-visibility/src/App.tsx b/examples/vue/column-visibility/src/App.tsx index 0d400685be..faca800839 100644 --- a/examples/vue/column-visibility/src/App.tsx +++ b/examples/vue/column-visibility/src/App.tsx @@ -182,7 +182,7 @@ export default defineComponent({ - {JSON.stringify(table.store.state, null, 2)} + {JSON.stringify(table.state, null, 2)} ) }, diff --git a/examples/vue/expanding/src/App.tsx b/examples/vue/expanding/src/App.tsx index 4bad74a9ba..a72af6bb64 100644 --- a/examples/vue/expanding/src/App.tsx +++ b/examples/vue/expanding/src/App.tsx @@ -294,7 +294,7 @@ export default defineComponent({ Page - {(table.store.state.pagination.pageIndex + 1).toLocaleString()} of{' '} + {(table.state.pagination.pageIndex + 1).toLocaleString()} of{' '} {table.getPageCount().toLocaleString()} @@ -304,7 +304,7 @@ export default defineComponent({ type="number" min="1" max={table.getPageCount()} - value={table.store.state.pagination.pageIndex + 1} + value={table.state.pagination.pageIndex + 1} onInput={(event: Event) => { const target = event.currentTarget as HTMLInputElement const page = target.value ? Number(target.value) - 1 : 0 @@ -314,7 +314,7 @@ export default defineComponent({ /> { const target = event.currentTarget as HTMLSelectElement table.setPageSize(Number(target.value)) @@ -328,7 +328,7 @@ export default defineComponent({ {table.getRowModel().rows.length.toLocaleString()} Rows - {JSON.stringify(table.store.state, null, 2)} + {JSON.stringify(table.state, null, 2)} ) }, diff --git a/packages/angular-table-devtools/eslint.config.js b/packages/angular-table-devtools/eslint.config.js new file mode 100644 index 0000000000..892f5314df --- /dev/null +++ b/packages/angular-table-devtools/eslint.config.js @@ -0,0 +1,8 @@ +// @ts-check + +import rootConfig from '../../eslint.config.js' + +/** @type {any} */ +const config = [...rootConfig] + +export default config diff --git a/packages/angular-table-devtools/package.json b/packages/angular-table-devtools/package.json new file mode 100644 index 0000000000..6d5d25e4c4 --- /dev/null +++ b/packages/angular-table-devtools/package.json @@ -0,0 +1,54 @@ +{ + "name": "@tanstack/angular-table-devtools", + "version": "9.0.0-alpha.43", + "description": "Angular devtools for TanStack Table.", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/table.git", + "directory": "packages/angular-table-devtools" + }, + "homepage": "https://tanstack.com/table", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "keywords": [ + "angular", + "tanstack", + "table", + "devtools" + ], + "scripts": { + "clean": "rimraf ./build && rimraf ./dist", + "test:eslint": "eslint ./src", + "test:lib": "vitest --passWithNoTests", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc", + "test:build": "publint --strict", + "build": "tsdown" + }, + "type": "module", + "types": "dist/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./production": "./dist/production.js", + "./package.json": "./package.json" + }, + "sideEffects": false, + "engines": { + "node": ">=20" + }, + "files": [ + "dist", + "src" + ], + "dependencies": { + "@tanstack/devtools-utils": "^0.5.0", + "@tanstack/table-core": "workspace:*", + "@tanstack/table-devtools": "workspace:*" + }, + "peerDependencies": { + "@angular/core": ">=21.0.0" + } +} diff --git a/packages/angular-table-devtools/src/TableDevtools.ts b/packages/angular-table-devtools/src/TableDevtools.ts new file mode 100644 index 0000000000..f8c9ff90d7 --- /dev/null +++ b/packages/angular-table-devtools/src/TableDevtools.ts @@ -0,0 +1,34 @@ +import { TableDevtoolsCore } from '@tanstack/table-devtools' +import { createAngularPanel } from '@tanstack/devtools-utils/angular' +import type { DevtoolsPanelProps } from '@tanstack/devtools-utils/angular' + +export interface TableDevtoolsAngularInit extends Partial {} + +const [TableDevtoolsPanelBase, TableDevtoolsPanelNoOpBase] = + createAngularPanel(TableDevtoolsCore) + +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 = TableDevtoolsPanelBase() + return panel(() => resolvePanelProps(props()), host) + } + +export const TableDevtoolsPanelNoOp: TableDevtoolsPanelComponent = + () => (props, host) => { + const panel = TableDevtoolsPanelNoOpBase() + return () => panel + } diff --git a/packages/angular-table-devtools/src/index.ts b/packages/angular-table-devtools/src/index.ts new file mode 100644 index 0000000000..224ae9fee8 --- /dev/null +++ b/packages/angular-table-devtools/src/index.ts @@ -0,0 +1,20 @@ +import { isDevMode } from '@angular/core' +import * as plugin from './plugin' +import * as Devtools from './TableDevtools' +import * as inject from './injectTanStackTableDevtools' + +export const TableDevtoolsPanel = isDevMode() + ? Devtools.TableDevtoolsPanel + : Devtools.TableDevtoolsPanelNoOp + +export const tableDevtoolsPlugin = isDevMode() + ? plugin.tableDevtoolsPlugin + : plugin.tableDevtoolsNoOpPlugin + +export type { TableDevtoolsAngularInit } from './TableDevtools' + +export type { InjectTanStackTableDevtoolsOptions } from './injectTanStackTableDevtools' + +export const injectTanStackTableDevtools = isDevMode() + ? inject.injectTanStackTableDevtools + : inject.injectTanStackTableDevtoolsNoOp diff --git a/packages/angular-table-devtools/src/injectTanStackTableDevtools.ts b/packages/angular-table-devtools/src/injectTanStackTableDevtools.ts new file mode 100644 index 0000000000..d21be1abf6 --- /dev/null +++ b/packages/angular-table-devtools/src/injectTanStackTableDevtools.ts @@ -0,0 +1,68 @@ +import { + removeTableDevtoolsTarget, + upsertTableDevtoolsTarget, +} from '@tanstack/table-devtools' +import { + APP_ID, + DestroyRef, + Injector, + assertInInjectionContext, + effect, + inject, +} from '@angular/core' +import type { Table } from '@tanstack/table-core' + +function normalizeName(name?: string) { + const trimmedName = name?.trim() + return trimmedName ? trimmedName : undefined +} + +let autoId = 0 +function generateId(): string { + const appId = inject(APP_ID) + return `tanstacktable-${appId}_${autoId++}${Date.now().toString(36)}` +} + +export interface InjectTanStackTableDevtoolsOptions { + table: Table | undefined + name: string + enabled?: () => boolean + injector?: Injector +} + +export function injectTanStackTableDevtools( + options: () => InjectTanStackTableDevtoolsOptions, +): void { + const registrationId = generateId() + const enabled = () => options().enabled?.() ?? true + assertInInjectionContext(injectTanStackTableDevtools) + const injector = options().injector ?? inject(Injector) + const destroyRef = inject(DestroyRef) + + effect( + (onCleanup) => { + const { table, name } = options() + const enabledValue = enabled() + if (!enabledValue || !table) { + removeTableDevtoolsTarget(registrationId) + } + upsertTableDevtoolsTarget({ + id: registrationId, + table: table, + name: normalizeName(name), + }) + onCleanup(() => { + removeTableDevtoolsTarget(registrationId) + }) + }, + { injector }, + ) + + destroyRef.onDestroy(() => { + removeTableDevtoolsTarget(registrationId) + }) +} + +export function injectTanStackTableDevtoolsNoOp( + options: () => InjectTanStackTableDevtoolsOptions, +): void {} diff --git a/packages/angular-table-devtools/src/plugin.ts b/packages/angular-table-devtools/src/plugin.ts new file mode 100644 index 0000000000..4db67c28b9 --- /dev/null +++ b/packages/angular-table-devtools/src/plugin.ts @@ -0,0 +1,13 @@ +import { createAngularPlugin } from '@tanstack/devtools-utils/angular' +import { TableDevtoolsPanel } from './TableDevtools' + +type TableDevtoolsPluginFactory = ReturnType[0] + +const plugins = createAngularPlugin({ + name: 'TanStack Table', + render: TableDevtoolsPanel, +}) + +export const tableDevtoolsPlugin: TableDevtoolsPluginFactory = plugins[0] +export const tableDevtoolsNoOpPlugin: TableDevtoolsPluginFactory = + plugins[1] as any diff --git a/packages/angular-table-devtools/src/production.ts b/packages/angular-table-devtools/src/production.ts new file mode 100644 index 0000000000..f3e96534ef --- /dev/null +++ b/packages/angular-table-devtools/src/production.ts @@ -0,0 +1,5 @@ +export { TableDevtoolsPanel } from './TableDevtools' +export type { TableDevtoolsAngularInit } from './TableDevtools' +export { tableDevtoolsPlugin } from './plugin' +export { injectTanStackTableDevtools } from './injectTanStackTableDevtools' +export type { InjectTanStackTableDevtoolsOptions } from './injectTanStackTableDevtools' diff --git a/packages/angular-table-devtools/tsconfig.json b/packages/angular-table-devtools/tsconfig.json new file mode 100644 index 0000000000..7cd68e0598 --- /dev/null +++ b/packages/angular-table-devtools/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": [ + "src", + "tests", + "eslint.config.js", + "vite.config.ts", + "tsdown.config.ts" + ] +} diff --git a/packages/angular-table-devtools/tsdown.config.ts b/packages/angular-table-devtools/tsdown.config.ts new file mode 100644 index 0000000000..63b0b0bd20 --- /dev/null +++ b/packages/angular-table-devtools/tsdown.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + plugins: [], + entry: ['./src/index.ts', './src/production.ts'], + format: ['esm'], + unbundle: true, + dts: true, + sourcemap: true, + clean: true, + minify: false, + fixedExtension: false, + exports: true, + publint: { + strict: true, + }, +}) diff --git a/packages/angular-table-devtools/vite.config.ts b/packages/angular-table-devtools/vite.config.ts new file mode 100644 index 0000000000..8feed8cff3 --- /dev/null +++ b/packages/angular-table-devtools/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import packageJson from './package.json' + +const config = defineConfig({ + plugins: [], + test: { + name: packageJson.name, + dir: './', + watch: false, + environment: 'jsdom', + globals: true, + }, +}) + +export default config diff --git a/packages/angular-table/skills/angular/compose-with-tanstack-store/SKILL.md b/packages/angular-table/skills/angular/compose-with-tanstack-store/SKILL.md index ef5391d9fd..4ce220dcb8 100644 --- a/packages/angular-table/skills/angular/compose-with-tanstack-store/SKILL.md +++ b/packages/angular-table/skills/angular/compose-with-tanstack-store/SKILL.md @@ -60,7 +60,7 @@ tracks the dependency. ```ts this.table.atoms.pagination.get() // current value (reactive) this.table.atoms.pagination.subscribe(obs) // RxJS observer form -this.table.store.state.pagination // flat snapshot read +this.table.state.pagination // flat snapshot read this.table.baseAtoms.pagination.set(...) // direct internal write (avoid) ``` diff --git a/packages/angular-table/skills/angular/migrate-v8-to-v9/SKILL.md b/packages/angular-table/skills/angular/migrate-v8-to-v9/SKILL.md index 3d0603c35c..35e9269a50 100644 --- a/packages/angular-table/skills/angular/migrate-v8-to-v9/SKILL.md +++ b/packages/angular-table/skills/angular/migrate-v8-to-v9/SKILL.md @@ -3,7 +3,7 @@ name: angular/migrate-v8-to-v9 description: > Mechanical v8 → v9 migration for `@tanstack/angular-table`: `createAngularTable` → `injectTable`, `get*RowModel()` options → `_rowModels` factories with explicit `*Fns`, - required `_features` via `tableFeatures()`, `state` access via `table.store.state` instead + required `_features` via `tableFeatures()`, `state` access via `table.state` instead of `table.getState()`, `createColumnHelper()` generic-order flip, every type now requires `TFeatures`, `enablePinning` split into `enableColumnPinning` / `enableRowPinning`, `sortingFn` → `sortFn` rename pile, `ColumnSizingInfo` → `ColumnResizing` @@ -124,21 +124,21 @@ Row-model and feature lookup tables → [`references/v8-to-v9-mapping.md`](refer --- -## 3. State access: `getState()` → `table.store.state` (and atoms) +## 3. State access: `getState()` → `table.state` (and atoms) ```ts // v8 const { sorting, pagination } = table.getState() // v9 — flat snapshot -const { sorting, pagination } = table.store.state +const { sorting, pagination } = table.state // v9 — per slice (signal-backed in Angular) const sorting = table.atoms.sorting.get() const pagination = table.atoms.pagination.get() ``` -In Angular, all three (`table.atoms.`, `table.store.state`, +In Angular, all three (`table.atoms.`, `table.state`, `table.baseAtoms.`) are signal-backed — reading them inside a template, `computed(...)`, or `effect(...)` registers an Angular dependency automatically. No `toSignal(...)` wrappers needed. @@ -275,7 +275,7 @@ v8 backed reactivity with manual memoized getters. v9's adapter `computed` and every writable atom with an Angular `signal`. Consequences: - **No `toSignal(...)` adapters around table state.** Read `table.atoms.x.get()` - / `table.store.state.x` directly inside templates, `computed`, `effect`. + / `table.state.x` directly inside templates, `computed`, `effect`. - **`computed(...)` is for derivation / equality, not for "make it reactive".** Use `{ equal: shallow }` from `@tanstack/angular-table` on object/array slices to skip downstream work on no-op updates. @@ -306,7 +306,7 @@ v8 backed reactivity with manual memoized getters. v9's adapter - [ ] Update `createColumnHelper()` → `createColumnHelper()`. - [ ] Update every `ColumnDef` / `Cell` etc. to include `TFeatures`. -- [ ] Replace `table.getState()` reads with `table.store.state` (or +- [ ] Replace `table.getState()` reads with `table.state` (or `table.atoms..get()` for per-slice reactivity). - [ ] Remove any usage of the v8 single `onStateChange` — split into per-slice `on[State]Change`. @@ -366,9 +366,9 @@ _rowModels: { Same for sorting, pagination, expanding, grouping, faceting. Selection, visibility, ordering, pinning, sizing, resizing do **not** need a row model. -### 4. (HIGH) `getState()` → `table.store.state` text replacement loses reactivity +### 4. (HIGH) `getState()` → `table.state` text replacement loses reactivity -Bulk-replacing `table.getState().x` with `table.store.state.x` works for _current +Bulk-replacing `table.getState().x` with `table.state.x` works for _current value_ reads, but if you used a `computed`/`memo` around `getState()` for reactivity, switch to `table.atoms.x.get()` — it's already signal-backed and needs no wrapper. diff --git a/packages/angular-table/skills/angular/production-readiness/SKILL.md b/packages/angular-table/skills/angular/production-readiness/SKILL.md index 8f0b5764c8..a5caaa42a2 100644 --- a/packages/angular-table/skills/angular/production-readiness/SKILL.md +++ b/packages/angular-table/skills/angular/production-readiness/SKILL.md @@ -6,7 +6,7 @@ description: > stable references OUTSIDE the `injectTable` initializer; pass only the `*Fns` your data needs to `createSortedRowModel` / `createFilteredRowModel` / `createGroupedRowModel`; use `ChangeDetectionStrategy.OnPush`; lean on signal-backed atoms (`table.atoms..get()`) - instead of broad `table.store.state` reads where granularity matters; use `{ equal: shallow }` + instead of broad `table.state` reads where granularity matters; use `{ equal: shallow }` on object/array `computed` selectors; set `getRowId` for stable identity; track by `id` in every `@for`; defer cell components with `flexRenderComponent` only when you need its options; scope DI tokens via `[tanStackTable*]` directives to kill prop drilling. @@ -148,13 +148,13 @@ All `examples/angular/*` use `OnPush`. Match that. --- -## 4. Read narrowly — `table.atoms..get()` over `table.store.state` +## 4. Read narrowly — `table.atoms..get()` over `table.state` Both surfaces are signal-backed. The difference is _which signal_ gets read. ```ts // Wider — depends on the flat snapshot signal (recomputes when ANY registered slice changes) -const pageIndex = computed(() => this.table.store.state.pagination.pageIndex) +const pageIndex = computed(() => this.table.state.pagination.pageIndex) // Narrower — depends only on the pagination atom const pageIndex = computed(() => this.table.atoms.pagination.get().pageIndex) diff --git a/packages/angular-table/skills/angular/table-state/SKILL.md b/packages/angular-table/skills/angular/table-state/SKILL.md index 441a19ddfa..4c09c5a000 100644 --- a/packages/angular-table/skills/angular/table-state/SKILL.md +++ b/packages/angular-table/skills/angular/table-state/SKILL.md @@ -145,7 +145,7 @@ A table instance has three ways to look at its state: | ------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- | | `table.baseAtoms.` | writable TanStack Store atom (always exists for registered slices) | low-level direct write; rare | | `table.atoms.` | **readonly** derived atom per registered feature; backed by Angular `computed` | reading current value or driving reactivity | -| `table.store.state` | flat snapshot object of every registered slice; backed by Angular `computed` | reading multiple slices at once, devtools | +| `table.state` | flat snapshot object of every registered slice; backed by Angular `computed` | reading multiple slices at once, devtools | All three are signal-backed in Angular. Reading any of them inside a template, `computed(...)`, or `effect(...)` registers an Angular dependency. @@ -155,7 +155,7 @@ All three are signal-backed in Angular. Reading any of them inside a template, const pagination = this.table.atoms.pagination.get() // Same value, flat shape -const pagination2 = this.table.store.state.pagination +const pagination2 = this.table.state.pagination // Reactive derivation with custom equality import { computed } from '@angular/core' diff --git a/packages/angular-table/src/injectTable.ts b/packages/angular-table/src/injectTable.ts index a60e18910d..85dd1fb569 100644 --- a/packages/angular-table/src/injectTable.ts +++ b/packages/angular-table/src/injectTable.ts @@ -6,6 +6,7 @@ import { inject, untracked, } from '@angular/core' +import { injectSelector } from '@tanstack/angular-store' import { constructTable } from '@tanstack/table-core' import { lazyInit } from './lazySignalInitializer' import { angularReactivity } from './reactivity' @@ -20,6 +21,7 @@ import type { Table, TableFeatures, TableOptions, + TableState, } from '@tanstack/table-core' export type SubscribeSource = @@ -31,7 +33,19 @@ export type SubscribeSource = export type AngularTable< TFeatures extends TableFeatures, TData extends RowData, -> = Table +> = Table & { + /** + * @deprecated Prefer `table.state` for template/render reads, + * `table.atoms..get()` for slice snapshots, or Angular computed values + * around explicit selectors. `table.state` is a current-value snapshot + * and is easy to misuse in render code. + */ + readonly store: Table['store'] + /** + * The current table state exposed for template/render reads. + */ + readonly state: Readonly> +} /** * Creates and returns an Angular-reactive table instance. @@ -107,6 +121,15 @@ export function injectTable< coreReativityFeature: angularReactivity(injector), ...options()._features, }, + }) as AngularTable + const stateSignal = injectSelector(table.store, undefined, { injector }) + + Object.defineProperty(table, 'state', { + get() { + return stateSignal() + }, + configurable: true, + enumerable: true, }) let isMount = true diff --git a/packages/angular-table/src/reactivity.ts b/packages/angular-table/src/reactivity.ts index ad6907c124..09834cdab6 100644 --- a/packages/angular-table/src/reactivity.ts +++ b/packages/angular-table/src/reactivity.ts @@ -1,5 +1,6 @@ -import { NgZone, computed, signal, untracked } from '@angular/core' +import { DestroyRef, NgZone, computed, signal, untracked } from '@angular/core' import { toObservable } from '@angular/core/rxjs-interop' +import { batch, createAtom } from '@tanstack/angular-store' import type { Atom, Observer, ReadonlyAtom } from '@tanstack/angular-store' import type { TableAtomOptions, @@ -7,6 +8,8 @@ import type { } from '@tanstack/table-core/reactivity' import type { Injector, Signal, WritableSignal } from '@angular/core' +const optionsStoreDebugName = 'table/optionsStore' + function signalToReadonlyAtom( signal: Signal, injector: Injector, @@ -14,9 +17,13 @@ function signalToReadonlyAtom( return Object.assign(signal, { get: () => signal(), subscribe: (observer: Observer) => { - return toObservable(computed(signal), { injector: injector }).subscribe( - observer, - ) + const subscription = toObservable(computed(signal), { + injector: injector, + }).subscribe(observer) + + return { + unsubscribe: () => subscription.unsubscribe(), + } }, }) } @@ -33,9 +40,13 @@ function signalToWritableAtom( }, get: () => signal(), subscribe: (observer: Observer) => { - return toObservable(computed(signal), { injector: injector }).subscribe( - observer, - ) + const subscription = toObservable(computed(signal), { + injector: injector, + }).subscribe(observer) + + return { + unsubscribe: () => subscription.unsubscribe(), + } }, }) } @@ -43,34 +54,59 @@ function signalToWritableAtom( /** * Creates the table-core reactivity bindings used by the Angular adapter. * - * Readonly table atoms are backed by Angular `computed` signals and writable - * atoms by Angular `signal`. Subscriptions bridge through `toObservable` with - * the caller's injector so table APIs can be consumed from Angular `computed` - * and `effect` calls. + * Table state atoms are backed by TanStack Store atoms. The options store stays + * framework-native because row-model APIs read `table.options` directly during + * render. Readonly table atoms bridge Store dependency tracking into Angular + * computed signals. */ export function angularReactivity(injector: Injector): TableReactivityBindings { const ngZone = injector.get(NgZone) + const destroyRef = injector.get(DestroyRef) + return { createOptionsStore: true, schedule: (fn) => ngZone.runOutsideAngular(() => queueMicrotask(fn)), createReadonlyAtom: (fn: () => T, options?: TableAtomOptions) => { - const signal = computed(() => fn(), { - equal: options?.compare, - debugName: options?.debugName, + const storeAtom = createAtom(() => fn(), { + compare: options?.compare, }) - return signalToReadonlyAtom(signal, injector) + const version = signal(0, { + equal: () => false, + }) + const subscription = storeAtom.subscribe(() => { + version.update((value) => value + 1) + }) + destroyRef.onDestroy(() => subscription.unsubscribe()) + + const value = computed( + () => { + version() + return storeAtom.get() + }, + { + equal: options?.compare, + debugName: options?.debugName, + }, + ) + return signalToReadonlyAtom(value, injector) }, createWritableAtom: ( value: T, options?: TableAtomOptions, ): Atom => { - const writableSignal = signal(value, { - equal: options?.compare, - debugName: options?.debugName, + if (options?.debugName === optionsStoreDebugName) { + const writableSignal = signal(value, { + equal: options.compare, + debugName: options.debugName, + }) + return signalToWritableAtom(writableSignal, injector) + } + + return createAtom(value, { + compare: options?.compare, }) - return signalToWritableAtom(writableSignal, injector) }, untrack: untracked, - batch: (fn) => fn(), + batch, } } diff --git a/packages/angular-table/tests/angularReactivityFeature.test.ts b/packages/angular-table/tests/angularReactivityFeature.test.ts index 35f381d716..44657345da 100644 --- a/packages/angular-table/tests/angularReactivityFeature.test.ts +++ b/packages/angular-table/tests/angularReactivityFeature.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test, vi } from 'vitest' import { computed, effect, signal } from '@angular/core' import { TestBed } from '@angular/core/testing' +import { createAtom } from '@tanstack/angular-store' import { injectTable, stockFeatures } from '../src' -import type { ColumnDef } from '../src' +import type { ColumnDef, RowSelectionState } from '../src' import type { WritableSignal } from '@angular/core' describe('angularReactivityFeature', () => { @@ -102,5 +103,43 @@ describe('angularReactivityFeature', () => { [false], ]) }) + + test('methods within effect react to external atom changes', () => { + const rowSelectionAtom = createAtom({}) + const table = TestBed.runInInjectionContext(() => + injectTable(() => ({ + data: data(), + _features: { ...stockFeatures }, + columns: columns, + getRowId: (row) => row.id, + atoms: { + rowSelection: rowSelectionAtom, + }, + })), + ) + const isSelectedRow1Captor = vi.fn<(val: boolean) => void>() + const tableStateCaptor = vi.fn<(val: RowSelectionState) => void>() + + TestBed.runInInjectionContext(() => { + effect(() => { + isSelectedRow1Captor(table.getRow('1').getIsSelected()) + }) + effect(() => { + tableStateCaptor(table.state.rowSelection) + }) + }) + + TestBed.tick() + expect(isSelectedRow1Captor).toHaveBeenCalledTimes(1) + expect(tableStateCaptor).toHaveBeenCalledTimes(1) + + rowSelectionAtom.set({ 1: true }) + TestBed.tick() + + expect(isSelectedRow1Captor).toHaveBeenCalledTimes(2) + expect(tableStateCaptor).toHaveBeenCalledTimes(2) + expect(isSelectedRow1Captor.mock.calls).toEqual([[false], [true]]) + expect(tableStateCaptor.mock.calls).toEqual([[{}], [{ 1: true }]]) + }) }) }) diff --git a/packages/lit-table/skills/lit/lit-table-controller/SKILL.md b/packages/lit-table/skills/lit/lit-table-controller/SKILL.md index 70d2aea1ba..9062252303 100644 --- a/packages/lit-table/skills/lit/lit-table-controller/SKILL.md +++ b/packages/lit-table/skills/lit/lit-table-controller/SKILL.md @@ -210,12 +210,12 @@ class DashboardElement extends LitElement { ## Reading State Off the Controller -The controller's `.table(...)` return value carries everything you usually need: feature methods, `FlexRender`, `Subscribe`, and the `state` projection. Direct reads off `table.atoms..get()` and `table.store.state.` are current-value reads; reactivity comes from the host invalidation subscriptions the controller already wires up. +The controller's `.table(...)` return value carries everything you usually need: feature methods, `FlexRender`, `Subscribe`, and the `state` projection. Direct reads off `table.atoms..get()` and `table.state.` are current-value reads; reactivity comes from the host invalidation subscriptions the controller already wires up. ```ts // Inside render(): const pagination = table.atoms.pagination.get() // current value -const snapshot = table.store.state // current full state +const snapshot = table.state // current full state const selected = table.state // projected via the selector you passed to .table() ``` diff --git a/packages/lit-table/skills/lit/table-state/SKILL.md b/packages/lit-table/skills/lit/table-state/SKILL.md index 8c46af79d0..cb7f17489e 100644 --- a/packages/lit-table/skills/lit/table-state/SKILL.md +++ b/packages/lit-table/skills/lit/table-state/SKILL.md @@ -161,7 +161,7 @@ Direct atom / store reads return the current value without subscribing to change ```ts const pagination = table.atoms.pagination.get() const sorting = table.atoms.sorting.get() -const snapshot = table.store.state +const snapshot = table.state ``` ### 4. `table.Subscribe` in templates diff --git a/packages/lit-table/src/TableController.ts b/packages/lit-table/src/TableController.ts index 44c84e397a..447b30be9f 100644 --- a/packages/lit-table/src/TableController.ts +++ b/packages/lit-table/src/TableController.ts @@ -31,7 +31,14 @@ export type LitTable< TFeatures extends TableFeatures, TData extends RowData, TSelected = TableState, -> = Table & { +> = Omit, 'store'> & { + /** + * @deprecated Prefer `table.state` for render reads, + * `table.atoms..get()` for slice snapshots, or `table.Subscribe` for + * explicit subscriptions. `table.store.state` is a current-value snapshot and + * is easy to misuse in render code. + */ + readonly store: Table['store'] /** * Subscribe to a selected slice of table state, or to a single source (atom or store). * @@ -74,7 +81,7 @@ export type LitTable< } /** * The selected state of the table. This state may not match the structure of - * `table.store.state` because it is selected by the `selector` function that + * the full table state because it is selected by the selector function that * you pass as the 2nd argument to `controller.table()`. * * @example @@ -224,7 +231,7 @@ export class TableController< return (selector?.(tableInstance.store.state) ?? tableInstance.store.state) as TSelected }, - } + } as unknown as LitTable } private _setupSubscriptions() { diff --git a/packages/preact-table/skills/preact/getting-started/SKILL.md b/packages/preact-table/skills/preact/getting-started/SKILL.md index 5f6f1d28ec..38a73a1997 100644 --- a/packages/preact-table/skills/preact/getting-started/SKILL.md +++ b/packages/preact-table/skills/preact/getting-started/SKILL.md @@ -200,7 +200,7 @@ Source: `examples/preact/basic-use-table/src/main.tsx`. ## Step 5 — Drive features with feature APIs -Reach for `table.setSorting(...)`, `table.setPageIndex(...)`, `table.nextPage()`, `column.toggleVisibility()`, `row.toggleSelected()`, etc. — never edit `table.store.state` directly. +Reach for `table.setSorting(...)`, `table.setPageIndex(...)`, `table.nextPage()`, `column.toggleVisibility()`, `row.toggleSelected()`, etc. — never edit `table.state` directly. ```tsx table.setPageIndex(0)} disabled={!table.getCanPreviousPage()}>First diff --git a/packages/preact-table/skills/preact/table-state/SKILL.md b/packages/preact-table/skills/preact/table-state/SKILL.md index a9d09073b3..2abd73ba0f 100644 --- a/packages/preact-table/skills/preact/table-state/SKILL.md +++ b/packages/preact-table/skills/preact/table-state/SKILL.md @@ -252,7 +252,7 @@ function Pager({ table }) { } ``` -`.get()` and `table.store.state` are current-value reads, not subscriptions. The component never re-renders when the atom changes. +`.get()` and `table.state` are current-value reads, not subscriptions. The component never re-renders when the atom changes. Source: `docs/framework/preact/guide/table-state.md`. ### HIGH Passing both `atoms.X` and `state.X` for the same slice diff --git a/packages/preact-table/src/useTable.ts b/packages/preact-table/src/useTable.ts index 2e61faefa1..ddaf2919ab 100644 --- a/packages/preact-table/src/useTable.ts +++ b/packages/preact-table/src/useTable.ts @@ -20,7 +20,15 @@ export type PreactTable< TFeatures extends TableFeatures, TData extends RowData, TSelected = TableState, -> = Table & { +> = Omit, 'store'> & { + /** + * @deprecated Prefer `table.state` for render reads, + * `table.atoms..get()` for slice snapshots, or + * `table.Subscribe` / `useSelector(table.store, selector)` for explicit + * subscriptions. `table.store.state` is a current-value snapshot and is easy + * to misuse in render code. + */ + readonly store: Table['store'] /** * A Preact HOC (Higher Order Component) that allows you to subscribe to the table state. * @@ -71,7 +79,9 @@ export type PreactTable< props: FlexRenderProps, ) => ComponentChildren /** - * The selected state of the table. This state may not match the structure of `table.store.state` because it is selected by the `selector` function that you pass as the 2nd argument to `useTable`. + * The selected state of the table. This state may not match the structure of + * the full table state because it is selected by the selector function that + * you pass as the 2nd argument to `useTable`. */ readonly state: Readonly } @@ -115,7 +125,7 @@ export function useTable< coreReativityFeature: preactReactivity(), ...tableOptions._features, }, - }) as PreactTable + }) as unknown as PreactTable tableInstance.Subscribe = ((props: any) => { const source = props.source ?? tableInstance.store diff --git a/packages/react-table-devtools/src/useTanStackTableDevtools.ts b/packages/react-table-devtools/src/useTanStackTableDevtools.ts index 456f5d8f66..f880651ca5 100644 --- a/packages/react-table-devtools/src/useTanStackTableDevtools.ts +++ b/packages/react-table-devtools/src/useTanStackTableDevtools.ts @@ -5,6 +5,7 @@ import { removeTableDevtoolsTarget, upsertTableDevtoolsTarget, } from '@tanstack/table-devtools' +import { useEffect } from 'react' import type { RowData, Table, TableFeatures } from '@tanstack/table-core' export interface UseTanStackTableDevtoolsOptions { @@ -25,24 +26,32 @@ export function useTanStackTableDevtools< options?: UseTanStackTableDevtoolsOptions, ): void { const registrationId = React.useId() + const normalizedName = normalizeName(name) + + const instanceId = + // instanceId from react table adapter (if it exists) allows for stable devtools registration even if the table instance changes + (table as unknown as { instanceId?: string }).instanceId || + `${registrationId}${normalizedName ? `-${normalizedName}` : ``}` + const enabled = options?.enabled ?? true - React.useEffect(() => { + useEffect(() => { if (!enabled || !table) { - removeTableDevtoolsTarget(registrationId) + removeTableDevtoolsTarget(instanceId) return } upsertTableDevtoolsTarget({ - id: registrationId, + id: instanceId, table, - name: normalizeName(name), + name: normalizedName, }) return () => { - removeTableDevtoolsTarget(registrationId) + removeTableDevtoolsTarget(instanceId) } - }, [enabled, name, registrationId, table]) + // eslint-disable-next-line @eslint-react/exhaustive-deps,react-hooks/exhaustive-deps + }, [enabled, registrationId, instanceId]) } export function useTanStackTableDevtoolsNoOp< diff --git a/packages/react-table/skills/react/compose-with-tanstack-store/SKILL.md b/packages/react-table/skills/react/compose-with-tanstack-store/SKILL.md index ae658f6001..924c7e4284 100644 --- a/packages/react-table/skills/react/compose-with-tanstack-store/SKILL.md +++ b/packages/react-table/skills/react/compose-with-tanstack-store/SKILL.md @@ -179,7 +179,7 @@ function Table({ data, filter }) { | `` / `` | ✓ | Surgical re-render boundaries inside the tree | | `useSelector(table.atoms.X)` | ✓ | Narrowest possible subscription to one slice | | `table.atoms.X.get()` | ✗ current-value read | Inside event handlers / effects | -| `table.store.state` | ✗ current-value read | Debugging / one-shot reads | +| `table.state` | ✗ current-value read | Debugging / one-shot reads | | Write path | Owner | Effect | | ------------------------------- | ----------------- | ---------------------------------------------------------------------------------- | diff --git a/packages/react-table/skills/react/migrate-v8-to-v9/SKILL.md b/packages/react-table/skills/react/migrate-v8-to-v9/SKILL.md index 1eb711865a..c03616f1d0 100644 --- a/packages/react-table/skills/react/migrate-v8-to-v9/SKILL.md +++ b/packages/react-table/skills/react/migrate-v8-to-v9/SKILL.md @@ -6,7 +6,7 @@ description: > memory has a v9 equivalent enumerated below: `useReactTable` → `useTable`, root `get*RowModel` options → `_rowModels` with factory + *Fns parameter, `createColumnHelper` → `createColumnHelper`, - `table.getState()` → `table.store.state` / `table.state` / `table.atoms.X.get()`, + `table.getState()` → `table.state` / `table.state` / `table.atoms.X.get()`, `sortingFn` → `sortFn`, `enablePinning` → split, `_`-prefixed APIs unprefixed, `ColumnSizing` split into `columnSizingFeature` + `columnResizingFeature`. For incremental migration, `useLegacyTable` from `@tanstack/react-table/legacy` @@ -120,12 +120,12 @@ const state = table.getState() const cells = row._getAllCellsByColumnId() // v9 -const all = table.store.state // flat snapshot +const all = table.state // flat snapshot const sorting = table.atoms.sorting.get() // per-slice atom const cells = row.getAllCellsByColumnId() // no underscore — APIs unprefixed ``` -In components, prefer `` over `table.store.state` for reactivity (see `tanstack-table/react/table-state`). +In components, prefer `` over `table.state` for reactivity (see `tanstack-table/react/table-state`). ### Renames @@ -329,7 +329,7 @@ function Toolbar({ table }) { } ``` -`getState` was removed. Use `table.store.state` for a flat snapshot, `table.state` if you passed a `useTable` selector, or `` for reactive reads. +`getState` was removed. Use `table.state` for a flat snapshot, `table.state` if you passed a `useTable` selector, or `` for reactive reads. Source: `docs/framework/react/guide/migrating.md`; `examples/react/basic-subscribe/src/main.tsx`. ### HIGH `enablePinning: true` on v9 diff --git a/packages/react-table/skills/react/production-readiness/SKILL.md b/packages/react-table/skills/react/production-readiness/SKILL.md index 31151548b1..e20a948c93 100644 --- a/packages/react-table/skills/react/production-readiness/SKILL.md +++ b/packages/react-table/skills/react/production-readiness/SKILL.md @@ -269,7 +269,7 @@ function SelectedCount({ table }) { } ``` -`` still selects from `table.store.state` (the full state). For a single slice, `useSelector(table.atoms.X)` skips even constructing the snapshot. +`` still selects from `table.state` (the full state). For a single slice, `useSelector(table.atoms.X)` skips even constructing the snapshot. Source: `docs/framework/react/guide/table-state.md`. ### MEDIUM Hoisting heavy table state reads above virtualizers diff --git a/packages/react-table/skills/react/table-state/SKILL.md b/packages/react-table/skills/react/table-state/SKILL.md index d2de2f220a..3ffb6ac58b 100644 --- a/packages/react-table/skills/react/table-state/SKILL.md +++ b/packages/react-table/skills/react/table-state/SKILL.md @@ -258,7 +258,7 @@ function Pager({ table }) { } ``` -`.get()` and `table.store.state` are current-value reads, not subscriptions. The component never re-renders when the atom changes. +`.get()` and `table.state` are current-value reads, not subscriptions. The component never re-renders when the atom changes. Source: `docs/framework/react/guide/table-state.md`; `examples/react/basic-subscribe/src/main.tsx`. ### HIGH Passing both `atoms.X` and `state.X` for the same slice diff --git a/packages/react-table/src/useLegacyTable.ts b/packages/react-table/src/useLegacyTable.ts index daa0ad7474..989f916624 100644 --- a/packages/react-table/src/useLegacyTable.ts +++ b/packages/react-table/src/useLegacyTable.ts @@ -279,7 +279,7 @@ export type LegacyReactTable = ReactTable< > & { /** * Returns the current table state. - * @deprecated In v9, access state directly via `table.state` or use `table.store.state` for the full state. + * @deprecated In v9, access state directly via `table.state` or use `table.state` for the full state. */ getState: () => TableState /** diff --git a/packages/react-table/src/useTable.ts b/packages/react-table/src/useTable.ts index 8272983306..5ac76f1f55 100644 --- a/packages/react-table/src/useTable.ts +++ b/packages/react-table/src/useTable.ts @@ -1,6 +1,6 @@ 'use client' -import { useMemo, useState } from 'react' +import { useId, useMemo, useRef, useState } from 'react' import { constructTable } from '@tanstack/table-core' import { shallow, useSelector } from '@tanstack/react-store' import { reactReactivity } from './reactivity' @@ -22,7 +22,19 @@ export type ReactTable< TFeatures extends TableFeatures, TData extends RowData, TSelected = TableState, -> = Table & { +> = Omit, 'store'> & { + /** + * @deprecated Prefer `table.state` for render reads, + * `table.atoms..get()` for slice snapshots, or + * `table.Subscribe` / `useSelector(table.store, selector)` for explicit + * subscriptions. `table.store.state` is a current-value snapshot and is easy + * to misuse in render code. + */ + readonly store: Table['store'] + /** + * A stable id reference for table instance + */ + instanceId?: string /** * A React HOC (Higher Order Component) that allows you to subscribe to the table state. * @@ -95,7 +107,9 @@ export type ReactTable< props: FlexRenderProps, ) => ReactNode /** - * The selected state of the table. This state may not match the structure of `table.store.state` because it is selected by the `selector` function that you pass as the 2nd argument to `useTable`. + * The selected state of the table. This state may not match the structure of + * the full table state because it is selected by the selector function that + * you pass as the 2nd argument to `useTable`. * * @example * const table = useTable(options, (state) => ({ globalFilter: state.globalFilter })) // only globalFilter is part of the selected state @@ -105,6 +119,7 @@ export type ReactTable< readonly state: Readonly } +let tableId = 0 /** * Creates a React table instance backed by TanStack Store atoms. * @@ -137,6 +152,13 @@ export function useTable< tableOptions: TableOptions, selector?: (state: TableState) => TSelected, ): ReactTable { + const instanceIdRef = useRef(undefined) + if (!instanceIdRef.current) { + instanceIdRef.current = + 'randomUUID' in globalThis.crypto + ? globalThis.crypto.randomUUID() + : `table-${++tableId}` + } const [table] = useState(() => { const tableInstance = constructTable({ ...tableOptions, @@ -144,7 +166,7 @@ export function useTable< coreReativityFeature: reactReactivity(), ...tableOptions._features, }, - }) as ReactTable + }) as unknown as ReactTable tableInstance.Subscribe = ((props: any) => { const source = props.source ?? tableInstance.store @@ -156,6 +178,7 @@ export function useTable< }) as ReactTable['Subscribe'] tableInstance.FlexRender = FlexRender + tableInstance.instanceId = instanceIdRef.current return tableInstance }) diff --git a/packages/solid-table/skills/solid/production-readiness/SKILL.md b/packages/solid-table/skills/solid/production-readiness/SKILL.md index 4a56e7b84e..52383cf5bb 100644 --- a/packages/solid-table/skills/solid/production-readiness/SKILL.md +++ b/packages/solid-table/skills/solid/production-readiness/SKILL.md @@ -238,10 +238,11 @@ A clear "didn't think about the bundle" tell. Use only the features you render. Keep `createVirtualizer` in the component that owns the scroll container, not high up in the tree. Otherwise scroll-driven recompute fires across the page. -### MEDIUM — re-reading `table.store.state` in JSX when an atom would do +### MEDIUM — re-reading `table.state` in JSX -`table.store.state.pagination` works, but `table.atoms.pagination.get()` or -`useSelector(table.atoms.pagination)` is the per-slice path. Prefer the slice. +Use `table.state()` for component-level reactive reads, or +`table.atoms.pagination.get()` / `useSelector(table.atoms.pagination)` for +per-slice reads. Avoid direct `table.state` reads in JSX. ### MEDIUM — `autoResetPageIndex: true` on a server-driven table diff --git a/packages/solid-table/skills/solid/table-state/SKILL.md b/packages/solid-table/skills/solid/table-state/SKILL.md index adc5252fd7..bf1dd15f0b 100644 --- a/packages/solid-table/skills/solid/table-state/SKILL.md +++ b/packages/solid-table/skills/solid/table-state/SKILL.md @@ -37,14 +37,14 @@ state directly through table APIs inside reactive scopes and never need A `createTable(...)` call produces a `SolidTable` with several state surfaces: -- `table.baseAtoms.` — internal writable atoms (signals). -- `table.atoms.` — readonly derived atoms (memos). One per registered feature slice. -- `table.store` — flat readonly TanStack Store snapshot. `table.store.state.pagination` reads the current value. +- `table.baseAtoms.` — internal writable TanStack Store atoms. Treat these as write plumbing, not a render read surface. +- `table.atoms.` — readonly derived atoms (memos). One per registered feature slice. Use `table.atoms.pagination.get()` for slice-level reactive reads. +- `table.store` — flat readonly TanStack Store snapshot for explicit subscriptions. Prefer `table.state()` or `table.atoms..get()` in JSX. - `table.state()` — **a Solid accessor**, not a value. Returns the result of the selector passed as the second argument to `createTable`. Default selector is identity. State slices only exist for features registered through `_features`. If `rowSortingFeature` is not in `_features`, then `table.atoms.sorting`, -`table.store.state.sorting`, and `state.sorting` are all absent (TS error + missing at runtime). +`table.state().sorting`, and `state.sorting` are all absent (TS error + missing at runtime). ## Creating a table — native signals diff --git a/packages/solid-table/src/createTable.ts b/packages/solid-table/src/createTable.ts index 266cbb74c9..f29ce6c75d 100644 --- a/packages/solid-table/src/createTable.ts +++ b/packages/solid-table/src/createTable.ts @@ -28,7 +28,15 @@ export type SolidTable< TFeatures extends TableFeatures, TData extends RowData, TSelected = TableState, -> = Table & { +> = Omit, 'store'> & { + /** + * @deprecated Prefer `table.state()` for component-level reactive reads, + * `table.atoms..get()` for slice-level reactive reads, or + * `table.Subscribe` / `useSelector(table.store, selector)` for explicit + * subscriptions. Reading `table.state` directly does not follow Solid's + * accessor convention and may not update render code as expected. + */ + readonly store: Table['store'] /** * Subscribe to the store (selector required) or a single source (atom or store). * Source **without** `selector` is a separate overload so children receive @@ -52,7 +60,9 @@ export type SolidTable< }): JSX.Element } /** - * The selected state of the table. This state may not match the structure of `table.store.state` because it is selected by the `selector` function that you pass as the 2nd argument to `createTable`. + * The selected state of the table. This state may not match the structure of + * the full table state because it is selected by the selector function that + * you pass as the 2nd argument to `createTable`. * * @example * const table = createTable(options, (state) => ({ globalFilter: state.globalFilter })) // only globalFilter is part of the selected state @@ -125,7 +135,7 @@ export function createTable< mergedOptions, ) as TableOptions - const table = constructTable(resolvedOptions) as SolidTable< + const table = constructTable(resolvedOptions) as unknown as SolidTable< TFeatures, TData, TSelected diff --git a/packages/solid-table/src/reactivity.ts b/packages/solid-table/src/reactivity.ts index 66571918cd..1d21f0825e 100644 --- a/packages/solid-table/src/reactivity.ts +++ b/packages/solid-table/src/reactivity.ts @@ -1,11 +1,12 @@ import { - batch, createMemo, createSignal, observable, + onCleanup, runWithOwner, untrack, } from 'solid-js' +import { batch, createAtom } from '@tanstack/solid-store' import type { Accessor, Owner, Setter } from 'solid-js' import type { TableAtomOptions, @@ -13,6 +14,8 @@ import type { } from '@tanstack/table-core/reactivity' import type { Atom, Observer, ReadonlyAtom } from '@tanstack/solid-store' +const optionsStoreDebugName = 'table/optionsStore' + function signalToReadonlyAtom( signal: Accessor, owner: Owner, @@ -26,10 +29,10 @@ function signalToReadonlyAtom( } function signalToWritableAtom( - signalTuple: [Accessor, Setter], + signal: Accessor, + setSignal: Setter, owner: Owner, ): Atom { - const [signal, setSignal] = signalTuple return Object.assign(signal, { set: (updater: T | ((prevVal: T) => T)) => { typeof updater === 'function' @@ -46,30 +49,54 @@ function signalToWritableAtom( /** * Creates the table-core reactivity bindings used by the Solid adapter. * - * Readonly table atoms are backed by Solid memos and writable table atoms are - * backed by Solid signals. Subscriptions run with the captured owner so table - * APIs can safely participate in Solid computations. + * Table state atoms are backed by TanStack Store atoms. The options store stays + * framework-native because row-model APIs read `table.options` directly during + * render. Readonly table atoms bridge Store dependency tracking into Solid memos. */ export function solidReactivity(owner: Owner): TableReactivityBindings { return { createOptionsStore: true, schedule: (fn) => queueMicrotask(() => fn()), createReadonlyAtom: (fn: () => T, options?: TableAtomOptions) => { - const signal = createMemo(() => fn(), { - equals: options?.compare, - name: options?.debugName, + const storeAtom = createAtom(() => fn(), { + compare: options?.compare, + }) + const [version, setVersion] = createSignal(0, { equals: false }) + runWithOwner(owner, () => { + const subscription = storeAtom.subscribe(() => { + setVersion((value) => value + 1) + }) + onCleanup(() => subscription.unsubscribe()) }) + + const signal = createMemo( + () => { + version() + return storeAtom.get() + }, + undefined, + { + equals: options?.compare, + name: options?.debugName, + }, + ) return signalToReadonlyAtom(signal, owner) }, createWritableAtom: ( value: T, options?: TableAtomOptions, ): Atom => { - const writableSignal = createSignal(value, { - equals: options?.compare, - name: options?.debugName, + if (options?.debugName === optionsStoreDebugName) { + const [signal, setSignal] = createSignal(value, { + equals: options.compare, + name: options.debugName, + }) + return signalToWritableAtom(signal, setSignal, owner) + } + + return createAtom(value, { + compare: options?.compare, }) - return signalToWritableAtom(writableSignal, owner) }, untrack: untrack, batch: batch, diff --git a/packages/solid-table/tests/unit/reactivity.test.ts b/packages/solid-table/tests/unit/reactivity.test.ts new file mode 100644 index 0000000000..36bfa4dcfa --- /dev/null +++ b/packages/solid-table/tests/unit/reactivity.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'vitest' +import { createRoot, getOwner } from 'solid-js' +import { createAtom } from '@tanstack/solid-store' +import { solidReactivity } from '../../src/reactivity' + +describe('solidReactivity', () => { + test('readonly atoms update when they read external TanStack Store atoms', () => { + createRoot((dispose) => { + const owner = getOwner()! + const reactivity = solidReactivity(owner) + const external = createAtom(1) + const doubled = reactivity.createReadonlyAtom(() => external.get() * 2, { + debugName: 'doubled', + }) + + expect(doubled.get()).toBe(2) + + external.set(2) + + expect(doubled.get()).toBe(4) + dispose() + }) + }) +}) diff --git a/packages/svelte-table/skills/svelte/compose-with-tanstack-store/SKILL.md b/packages/svelte-table/skills/svelte/compose-with-tanstack-store/SKILL.md index 9a9a255c41..6a353ea38d 100644 --- a/packages/svelte-table/skills/svelte/compose-with-tanstack-store/SKILL.md +++ b/packages/svelte-table/skills/svelte/compose-with-tanstack-store/SKILL.md @@ -71,7 +71,7 @@ function logSort() { } ``` -`table.store.state` is the full snapshot equivalent. +`table.state` is the full snapshot equivalent. ## Pattern 2 — Reactive selector via `createTable` diff --git a/packages/svelte-table/skills/svelte/production-readiness/SKILL.md b/packages/svelte-table/skills/svelte/production-readiness/SKILL.md index 8832e88107..466fa22173 100644 --- a/packages/svelte-table/skills/svelte/production-readiness/SKILL.md +++ b/packages/svelte-table/skills/svelte/production-readiness/SKILL.md @@ -160,7 +160,7 @@ function exportSelected() { } ``` -`table.store.state` is the same idea for a full snapshot. +`table.state` is the same idea for a full snapshot. ## 6. Key every `{#each}` block on a stable id diff --git a/packages/svelte-table/skills/svelte/table-state/SKILL.md b/packages/svelte-table/skills/svelte/table-state/SKILL.md index 50f0cff4f7..63d6de8f56 100644 --- a/packages/svelte-table/skills/svelte/table-state/SKILL.md +++ b/packages/svelte-table/skills/svelte/table-state/SKILL.md @@ -109,7 +109,7 @@ Read the atom directly. Cheapest path; only reactive when called inside a rune-t ```ts const sorting = table.atoms.sorting.get() const pagination = table.atoms.pagination.get() -const flat = table.store.state +const flat = table.state ``` ### Reactive read inside markup — `table.state` selector diff --git a/packages/svelte-table/src/createTable.svelte.ts b/packages/svelte-table/src/createTable.svelte.ts index 283b161337..5a8451ac8a 100644 --- a/packages/svelte-table/src/createTable.svelte.ts +++ b/packages/svelte-table/src/createTable.svelte.ts @@ -15,9 +15,19 @@ export type SvelteTable< TFeatures extends TableFeatures, TData extends RowData, TSelected = TableState, -> = Table & { +> = Omit, 'store'> & { /** - * The selected state of the table. This state may not match the structure of `table.store.state` because it is selected by the `selector` function that you pass as the 2nd argument to `createTable`. + * @deprecated Prefer `table.state` for render reads, + * `table.atoms..get()` for slice snapshots, or + * `useSelector(table.store, selector)` for explicit subscriptions. + * `table.store.state` is a current-value snapshot and is easy to misuse in + * render code. + */ + readonly store: Table['store'] + /** + * The selected state of the table. This state may not match the structure of + * the full table state because it is selected by the selector function that + * you pass as the 2nd argument to `createTable`. * * @example * const table = createTable(options, (state) => ({ globalFilter: state.globalFilter })) // only globalFilter is part of the selected state @@ -82,7 +92,7 @@ export function createTable< ) as TableOptions // 3. Construct table - const table = constructTable(resolvedOptions) as SvelteTable< + const table = constructTable(resolvedOptions) as unknown as SvelteTable< TFeatures, TData, TSelected diff --git a/packages/svelte-table/src/reactivity.svelte.ts b/packages/svelte-table/src/reactivity.svelte.ts index 37887d6e2c..ae229e60f5 100644 --- a/packages/svelte-table/src/reactivity.svelte.ts +++ b/packages/svelte-table/src/reactivity.svelte.ts @@ -1,10 +1,13 @@ -import { flushSync, untrack } from 'svelte' +import { untrack } from 'svelte' +import { batch, createAtom } from '@tanstack/svelte-store' import type { TableAtomOptions, TableReactivityBindings, } from '@tanstack/table-core/reactivity' import type { Atom, Observer, ReadonlyAtom } from '@tanstack/svelte-store' +const optionsStoreDebugName = 'table/optionsStore' + function observerToCallback( observerOrNext: Observer | ((value: T) => void), ): (value: T) => void { @@ -28,19 +31,52 @@ function subscribeToRune( return { unsubscribe } } +function createRuneWritableAtom(initialValue: T): Atom { + let value = $state(initialValue) + + return { + set: (updater: T | ((prevVal: T) => T)) => { + value = + typeof updater === 'function' + ? (updater as (prevVal: T) => T)(value) + : updater + }, + get: () => value, + subscribe: ((observerOrNext: Observer | ((value: T) => void)) => { + return subscribeToRune(() => value, observerOrNext) + }) as Atom['subscribe'], + } +} + /** * Creates the table-core reactivity bindings used by the Svelte adapter. * - * Readonly table atoms are backed by `$derived.by`, writable atoms by `$state`, - * and subscriptions bridge through rune effects so table APIs participate in - * Svelte dependency tracking. + * Table state atoms are backed by TanStack Store atoms. The options store stays + * framework-native because row-model APIs read `table.options` directly during + * render. Readonly table atoms bridge Store dependency tracking into `$derived.by`. */ export function svelteReactivity(): TableReactivityBindings { return { createOptionsStore: true, schedule: (fn) => queueMicrotask(() => fn()), createReadonlyAtom: (fn: () => T, _options?: TableAtomOptions) => { - const value = $derived.by(fn) + const storeAtom = createAtom(() => fn(), { + compare: _options?.compare, + }) + let version = $state(0) + + $effect(() => { + const subscription = storeAtom.subscribe(() => { + version += 1 + }) + + return () => subscription.unsubscribe() + }) + + const value = $derived.by(() => { + version + return storeAtom.get() + }) return { get: () => value, @@ -53,22 +89,15 @@ export function svelteReactivity(): TableReactivityBindings { initialValue: T, _options?: TableAtomOptions, ): Atom => { - let value = $state(initialValue) - - return { - set: (updater: T | ((prevVal: T) => T)) => { - value = - typeof updater === 'function' - ? (updater as (prevVal: T) => T)(value) - : updater - }, - get: () => value, - subscribe: ((observerOrNext: Observer | ((value: T) => void)) => { - return subscribeToRune(() => value, observerOrNext) - }) as Atom['subscribe'], + if (_options?.debugName === optionsStoreDebugName) { + return createRuneWritableAtom(initialValue) } + + return createAtom(initialValue, { + compare: _options?.compare, + }) }, untrack: untrack, - batch: (fn) => flushSync(fn), + batch, } } diff --git a/packages/table-devtools/eslint.config.js b/packages/table-devtools/eslint.config.js index 5880eb7bfa..9fb656d60d 100644 --- a/packages/table-devtools/eslint.config.js +++ b/packages/table-devtools/eslint.config.js @@ -1,13 +1,9 @@ // @ts-check +import solid from 'eslint-plugin-solid/configs/recommended' import rootConfig from '../../eslint.config.js' /** @type {any} */ -const config = [ - ...rootConfig, - { - rules: {}, - }, -] +const config = [...rootConfig, solid] export default config diff --git a/packages/table-devtools/package.json b/packages/table-devtools/package.json index b1528fcaef..d81b2890b8 100644 --- a/packages/table-devtools/package.json +++ b/packages/table-devtools/package.json @@ -59,6 +59,7 @@ }, "devDependencies": { "@tanstack/table-core": "workspace:*", + "eslint-plugin-solid": "^0.14.5", "vite-plugin-solid": "^2.11.12" } } diff --git a/packages/table-devtools/src/TableContextProvider.tsx b/packages/table-devtools/src/TableContextProvider.tsx index 5b333f30cf..26aa5119fb 100644 --- a/packages/table-devtools/src/TableContextProvider.tsx +++ b/packages/table-devtools/src/TableContextProvider.tsx @@ -15,7 +15,7 @@ import type { RowData, Table, TableFeatures } from '@tanstack/table-core' import type { TableDevtoolsRegistration } from './tableTarget' type TableDevtoolsTabId = 'features' | 'state' | 'options' | 'rows' | 'columns' -type AnyTable = Table +type AnyTable = Table<{}, RowData> interface TableDevtoolsContextValue { targets: Accessor> @@ -31,12 +31,12 @@ const TableDevtoolsContext = createContext< >(undefined) export const TableContextProvider: ParentComponent = (props) => { - const [targets, setTargets] = createSignal>( - getTableDevtoolsTargets(), - ) + const initialTargets = getTableDevtoolsTargets() + const [targets, setTargets] = + createSignal>(initialTargets) const [selectedTargetId, setSelectedTargetId] = createSignal< string | undefined - >(targets()[0]?.id) + >(initialTargets[0]?.id) const [activeTab, setActiveTab] = createSignal('features') const selectedTarget = createMemo(() => diff --git a/packages/table-devtools/src/components/ColumnsPanel.tsx b/packages/table-devtools/src/components/ColumnsPanel.tsx index bd168f12af..8395b6d400 100644 --- a/packages/table-devtools/src/components/ColumnsPanel.tsx +++ b/packages/table-devtools/src/components/ColumnsPanel.tsx @@ -1,4 +1,4 @@ -import { For } from 'solid-js' +import { For, Show, createMemo } from 'solid-js' import { useTableDevtoolsContext } from '../TableContextProvider' import { useTableStore } from '../useTableStore' import { useStyles } from '../styles/use-styles' @@ -32,65 +32,61 @@ export function ColumnsPanel() { const styles = useStyles() const { table } = useTableDevtoolsContext() - const tableInstance = table() const tableState = useTableStore( - tableInstance ? tableInstance.store : undefined, + () => table()?.store, (state) => state, ) - if (!tableInstance) { - return - } - - const getColumns = (): Array => { - tableState?.() - const tableWithColumnFns = tableInstance as unknown as { - getAllFlatColumns?: () => Array - getAllLeafColumns?: () => Array + const columns = createMemo>(() => { + const tableInstance = table() + if (!tableInstance) { + return [] } + tableState() + return ( - tableWithColumnFns.getAllFlatColumns?.() ?? - tableWithColumnFns.getAllLeafColumns?.() ?? + tableInstance.getAllFlatColumns?.() ?? + tableInstance.getAllLeafColumns?.() ?? [] ) - } - - const columns = getColumns() + }) return ( - - Columns ({columns.length}) - - - - - # - id - depth - accessor - columnDef - - - - - {(column, index) => ( - - {index() + 1} - {column.id} - {column.depth} - - {column.accessorFn ? '✓' : '○'} - - - {getColumnDefSummary(column)} - - - )} - - - + } when={table()}> + + Columns ({columns().length}) + + + + + # + id + depth + accessor + columnDef + + + + + {(column, index) => ( + + {index() + 1} + {column.id} + {column.depth} + + {column.accessorFn ? '✓' : '○'} + + + {getColumnDefSummary(column)} + + + )} + + + + - + ) } diff --git a/packages/table-devtools/src/components/FeaturesPanel.tsx b/packages/table-devtools/src/components/FeaturesPanel.tsx index 2deecafbe7..b8eb23ebe3 100644 --- a/packages/table-devtools/src/components/FeaturesPanel.tsx +++ b/packages/table-devtools/src/components/FeaturesPanel.tsx @@ -1,4 +1,4 @@ -import { For } from 'solid-js' +import { For, Show, createMemo } from 'solid-js' import { coreFeatures, stockFeatures } from '@tanstack/table-core' import { useTableDevtoolsContext } from '../TableContextProvider' import { useTableStore } from '../useTableStore' @@ -6,6 +6,8 @@ import { useStyles } from '../styles/use-styles' import { NoTableConnected } from './NoTableConnected' import { ResizableSplit } from './ResizableSplit' +import type { RowData, Table } from '@tanstack/table-core' + type FnBuckets = Partial< Record<'filterFns' | 'sortFns' | 'aggregationFns', Record> > @@ -101,12 +103,16 @@ const ROW_MODEL_TO_GETTER: Record< } function getRowCountForModel( - tableInstance: { [key: string]: unknown } | undefined, + tableInstance: Table<{}, RowData> | undefined, rowModelName: string, ): number { const getter = ROW_MODEL_TO_GETTER[rowModelName] - if (!getter || typeof tableInstance?.[getter] !== 'function') return 0 - const result = (tableInstance[getter] as () => { rows?: Array })() + if (!getter || !tableInstance) return 0 + + const tableRecord = tableInstance as unknown as Record + if (typeof tableRecord[getter] !== 'function') return 0 + + const result = (tableRecord[getter] as () => { rows?: Array })() return result.rows?.length ?? 0 } @@ -126,43 +132,58 @@ export function FeaturesPanel() { const styles = useStyles() const { table } = useTableDevtoolsContext() - const tableInstance = table() const tableState = useTableStore( - tableInstance ? tableInstance.store : undefined, + () => table()?.store, (state) => state, ) + const tableOptions = useTableStore( + () => { + const tableInstance = table() + return tableInstance?.optionsStore ?? tableInstance?.store + }, + () => table()?.options as unknown, + ) - if (!tableInstance) { - return - } + const tableFeatures = createMemo((): Set => { + const tableInstance = table() + if (!tableInstance) return new Set() - const getTableFeatures = (): Set => { - tableState?.() - return new Set(Object.keys(tableInstance?._features ?? {})) - } + tableState() + return new Set(Object.keys(tableInstance._features ?? {})) + }) - const getRowModelNames = (): Array => { - tableState?.() - return Object.keys(tableInstance?.options._rowModels ?? {}) - } + const rowModelNames = createMemo((): Array => { + const tableInstance = table() + if (!tableInstance) return [] + + tableState() + tableOptions() + + return Object.keys(tableInstance.options._rowModels ?? {}) + }) const getFnNames = ( kind: 'filterFns' | 'sortFns' | 'aggregationFns', ): Array => { - tableState?.() - const rowModelFns = toFnBuckets(tableInstance?._rowModelFns) - const optionFns = toFnBuckets(tableInstance?.options) + const tableInstance = table() + if (!tableInstance) return [] + + tableState() + tableOptions() + + const rowModelFns = toFnBuckets(tableInstance._rowModelFns) + const optionFns = toFnBuckets(tableInstance.options) return Object.keys(rowModelFns[kind] ?? optionFns[kind] ?? {}) } - const getAdditionalPlugins = (): Array => { - const tableFeatures = getTableFeatures() + const additionalPlugins = createMemo((): Array => { + const currentFeatures = tableFeatures() const knownFeatures = new Set([ ...CORE_FEATURE_NAMES, ...STOCK_FEATURE_NAMES, ]) - return [...tableFeatures].filter((f) => !knownFeatures.has(f)).sort() - } + return [...currentFeatures].filter((f) => !knownFeatures.has(f)).sort() + }) const getRowModelFunctions = (rowModelName: string): Array => { const fnKind = ROW_MODEL_TO_FN_KIND[rowModelName] @@ -170,22 +191,46 @@ export function FeaturesPanel() { return getFnNames(fnKind) } - const tableFeatures = getTableFeatures() - const rowModelNames = getRowModelNames() - const enabledFeatureEstimate = [...tableFeatures].reduce( - (total, featureName) => { + const enabledFeatureEstimate = createMemo(() => + [...tableFeatures()].reduce((total, featureName) => { return total + (FEATURE_SIZE_ESTIMATES_BYTES[featureName] ?? 0) - }, - 0, + }, 0), + ) + const enabledRowModelEstimate = createMemo(() => + [...new Set(rowModelNames())] + .map((rowModelName) => normalizeRowModelEstimateKey(rowModelName)) + .filter((rowModelName, index, all) => all.indexOf(rowModelName) === index) + .reduce((total, rowModelName) => { + return total + (ROW_MODEL_SIZE_ESTIMATES_BYTES[rowModelName] ?? 0) + }, 0), + ) + const totalEstimatedBundleSize = createMemo( + () => enabledFeatureEstimate() + enabledRowModelEstimate(), ) - const enabledRowModelEstimate = [...new Set(rowModelNames)] - .map((rowModelName) => normalizeRowModelEstimateKey(rowModelName)) - .filter((rowModelName, index, all) => all.indexOf(rowModelName) === index) - .reduce((total, rowModelName) => { - return total + (ROW_MODEL_SIZE_ESTIMATES_BYTES[rowModelName] ?? 0) - }, 0) - const totalEstimatedBundleSize = - enabledFeatureEstimate + enabledRowModelEstimate + + const rowModels = createMemo(() => { + const tableInstance = table() + if (!tableInstance) return [] + + tableState() + + return rowModelNames().map((rowModelName) => { + const sharedLabel = ROW_MODEL_SHARED_SIZE_LABELS[rowModelName] + + return { + rowModelName, + fns: getRowModelFunctions(rowModelName), + rowCount: getRowCountForModel(tableInstance, rowModelName), + estimateLabel: + sharedLabel ?? + formatEstimatedSize( + ROW_MODEL_SIZE_ESTIMATES_BYTES[ + normalizeRowModelEstimateKey(rowModelName) + ], + ), + } + }) + }) const renderFeatureItem = ( name: string, @@ -202,142 +247,139 @@ export function FeaturesPanel() { ) return ( - - - Features - - - Estimated table-core package - - - Registered features - {formatEstimatedSize(enabledFeatureEstimate)} - - - Client row models - {formatEstimatedSize(enabledRowModelEstimate)} - - - Total - {formatEstimatedSize(totalEstimatedBundleSize)} + } when={table()}> + + + Features + + + Estimated table-core package + + + Registered features + {formatEstimatedSize(enabledFeatureEstimate())} + + + Client row models + {formatEstimatedSize(enabledRowModelEstimate())} + + + Total + {formatEstimatedSize(totalEstimatedBundleSize())} + + + Allocated from the current `size-limit` metric: minified and + brotlied. + - - Allocated from the current `size-limit` metric: minified and - brotlied. + + + Core Features + + {(name) => + renderFeatureItem( + name, + tableFeatures().has(name), + formatEstimatedSize(FEATURE_SIZE_ESTIMATES_BYTES[name]), + ) + } + - - - - Core Features - - {(name) => - renderFeatureItem( - name, - tableFeatures.has(name), - formatEstimatedSize(FEATURE_SIZE_ESTIMATES_BYTES[name]), - ) - } - - - - - Stock Features - - {(name) => - renderFeatureItem( - name, - tableFeatures.has(name), - formatEstimatedSize(FEATURE_SIZE_ESTIMATES_BYTES[name]), - ) - } - - - {getAdditionalPlugins().length > 0 && ( - Additional Plugins + Stock Features - - {(name) => renderFeatureItem(name, true, 'custom')} + + {(name) => + renderFeatureItem( + name, + tableFeatures().has(name), + formatEstimatedSize(FEATURE_SIZE_ESTIMATES_BYTES[name]), + ) + } - )} - > - } - right={ - <> - - Client Side Row Models and Fns - - - {(rowModelName) => { - const fns = getRowModelFunctions(rowModelName) - const rowCount = getRowCountForModel( - tableInstance, - rowModelName, - ) - const sharedLabel = ROW_MODEL_SHARED_SIZE_LABELS[rowModelName] - const estimateLabel = - sharedLabel ?? - formatEstimatedSize( - ROW_MODEL_SIZE_ESTIMATES_BYTES[ - normalizeRowModelEstimateKey(rowModelName) - ], - ) - return ( + + {additionalPlugins().length > 0 && ( + + + Additional Plugins + + + {(name) => renderFeatureItem(name, true, 'custom')} + + + )} + > + } + right={ + <> + + Client Side Row Models and Fns + + + {(rowModel) => ( - {rowModelName} + + {rowModel.rowModelName} + - {rowCount} rows, {estimateLabel} + {rowModel.rowCount} rows, {rowModel.estimateLabel} - + {(fnName) => ( {fnName} )} - ) - }} - - {rowModelNames.length === 0 && ( - No row models configured - )} - - Full package reference:{' '} - {formatEstimatedSize(PACKAGE_SIZE_LIMIT_BYTES)} - - - Execution Order - - {(getter, index) => { - const rowModelKey = getterToRowModelKey(getter) - const isPresent = - rowModelKey !== null && rowModelNames.includes(rowModelKey) - return ( - <> - {index() > 0 && ' → '} - - {getter} - - > - ) - }} + )} - - > - } - /> - + {rowModelNames().length === 0 && ( + + No row models configured + + )} + + Full package reference:{' '} + {formatEstimatedSize(PACKAGE_SIZE_LIMIT_BYTES)} + + + + Execution Order + + + {(getter, index) => { + const rowModelKey = getterToRowModelKey(getter) + const isPresent = + rowModelKey !== null && + rowModelNames().includes(rowModelKey) + + return ( + <> + {index() > 0 && ' → '} + + {getter} + + > + ) + }} + + + > + } + /> + + ) } diff --git a/packages/table-devtools/src/components/OptionsPanel.tsx b/packages/table-devtools/src/components/OptionsPanel.tsx index ab1c6165d6..243b37f8ba 100644 --- a/packages/table-devtools/src/components/OptionsPanel.tsx +++ b/packages/table-devtools/src/components/OptionsPanel.tsx @@ -1,5 +1,5 @@ import { JsonTree } from '@tanstack/devtools-ui' -import { useSelector } from '@tanstack/solid-store' +import { Show, createMemo } from 'solid-js' import { useTableDevtoolsContext } from '../TableContextProvider' import { useTableStore } from '../useTableStore' import { useStyles } from '../styles/use-styles' @@ -21,37 +21,42 @@ export function OptionsPanel() { const styles = useStyles() const { table } = useTableDevtoolsContext() - const tableInstance = table() - const tableState = tableInstance - ? tableInstance.optionsStore - ? useSelector(tableInstance.optionsStore, (state: unknown) => - projectOptionsForTree(state), - ) - : useTableStore(tableInstance.store, () => - projectOptionsForTree(tableInstance.options as unknown), - ) - : undefined + const tableOptions = useTableStore( + () => { + const tableInstance = table() + return tableInstance?.optionsStore ?? tableInstance?.store + }, + () => { + const tableInstance = table() + return tableInstance + ? projectOptionsForTree(tableInstance.options as unknown) + : undefined + }, + ) - if (!tableInstance) { - return - } + const options = createMemo(() => { + const tableInstance = table() + if (!tableInstance) { + return undefined + } - const getState = (): unknown => { - tableState?.() - return tableState?.() - } + tableOptions() + return projectOptionsForTree(tableInstance.options as unknown) + }) return ( - - - Options - - > - } - right={<>>} - /> - + } when={table()}> + + + Options + + > + } + right={<>>} + /> + + ) } diff --git a/packages/table-devtools/src/components/RowsPanel.tsx b/packages/table-devtools/src/components/RowsPanel.tsx index 29d3f48717..af45649ae9 100644 --- a/packages/table-devtools/src/components/RowsPanel.tsx +++ b/packages/table-devtools/src/components/RowsPanel.tsx @@ -1,4 +1,4 @@ -import { For, createSignal } from 'solid-js' +import { For, Show, createEffect, createMemo, createSignal } from 'solid-js' import { JsonTree } from '@tanstack/devtools-ui' import { useTableDevtoolsContext } from '../TableContextProvider' import { useTableStore } from '../useTableStore' @@ -47,35 +47,52 @@ function stringifyValue(value: unknown): string { export function RowsPanel() { const styles = useStyles() const { table } = useTableDevtoolsContext() - const tableInstance = table() const tableState = useTableStore( - tableInstance ? tableInstance.store : undefined, + () => table()?.store, (state) => state, ) + const tableOptions = useTableStore( + () => { + const tableInstance = table() + return tableInstance?.optionsStore ?? tableInstance?.store + }, + () => table()?.options as unknown, + ) const [selectedRowModel, setSelectedRowModel] = createSignal<(typeof ROW_MODEL_GETTERS)[number]>('getRowModel') - if (!tableInstance) { - return - } + const rawData = createMemo((): unknown => { + const tableInstance = table() + if (!tableInstance) return undefined + + tableState() + tableOptions() - const getRawData = (): unknown => { - tableState?.() const data = tableInstance.options.data as ReadonlyArray if (!Array.isArray(data)) return data if (data.length <= ROW_LIMIT) return data as unknown return data.slice(0, ROW_LIMIT) as unknown - } + }) + + const rawDataTotalCount = createMemo((): number => { + const tableInstance = table() + if (!tableInstance) return 0 + + tableState() + tableOptions() - const getRawDataTotalCount = (): number => { - tableState?.() const data = tableInstance.options.data as ReadonlyArray return Array.isArray(data) ? data.length : 0 - } + }) + + const columns = createMemo((): Array => { + const tableInstance = table() + if (!tableInstance) return [] + + tableState() + tableOptions() - const getColumns = (): Array => { - tableState?.() const tableWithColumnFns = tableInstance as unknown as { getVisibleLeafColumns?: () => Array getAllLeafColumns?: () => Array @@ -86,119 +103,144 @@ export function RowsPanel() { tableWithColumnFns.getAllLeafColumns?.() ?? [] ) - } + }) + + const availableGetters = createMemo( + (): Array<(typeof ROW_MODEL_GETTERS)[number]> => { + const tableInstance = table() + if (!tableInstance) return [] + + const tableRecord = tableInstance as unknown as Record + + return ROW_MODEL_GETTERS.filter( + (name) => typeof tableRecord[name] === 'function', + ) + }, + ) + + createEffect(() => { + const getters = availableGetters() + if (getters.length === 0) return - const getAllRows = (): Array => { - tableState?.() - selectedRowModel() - const getter = tableInstance?.[selectedRowModel()] as + const currentGetter = selectedRowModel() + if (!getters.includes(currentGetter)) { + setSelectedRowModel(getters[0]!) + } + }) + + const allRows = createMemo((): Array => { + const tableInstance = table() + if (!tableInstance) return [] + + tableState() + + const tableRecord = tableInstance as unknown as Record + const getter = tableRecord[selectedRowModel()] as | (() => { rows: Array }) | undefined + return getter?.().rows ?? [] - } + }) - const getRows = (): Array => { - const rows = getAllRows() - return rows.length <= ROW_LIMIT ? rows : rows.slice(0, ROW_LIMIT) - } + const rows = createMemo((): Array => { + const nextRows = allRows() + if (nextRows.length <= ROW_LIMIT) return nextRows + return nextRows.slice(0, ROW_LIMIT) + }) - const getRowsTotalCount = (): number => getAllRows().length + const rowsTotalCount = createMemo(() => allRows().length) const getCells = (row: AnyRow): Array => { - tableState?.() const rowWithMaybeVisibleCells = row as unknown as { getVisibleCells?: () => Array } return rowWithMaybeVisibleCells.getVisibleCells?.() ?? row.getAllCells() } - const getAvailableGetters = (): Array<(typeof ROW_MODEL_GETTERS)[number]> => { - return ROW_MODEL_GETTERS.filter( - (name) => typeof tableInstance[name] === 'function', - ) - } - return ( - - - - Raw Data - {getRawDataTotalCount() > ROW_LIMIT && ( - - {' '} - (First {ROW_LIMIT} rows) - - )} - - - > - } - right={ - <> - - Rows ({getRows().length} - {getRowsTotalCount() > ROW_LIMIT && ` of ${getRowsTotalCount()}`}) - {getRowsTotalCount() > ROW_LIMIT && ( - - {' '} - — First {ROW_LIMIT} rows - - )} - - - View: - - setSelectedRowModel( - e.currentTarget.value as (typeof ROW_MODEL_GETTERS)[number], - ) - } - > - - {(getterName) => ( - {getterName} - )} - - - - - - - - # - - {(column) => ( - {column.id} - )} - - - - - - {(row) => ( - - {row.id} - - {(cell) => ( - - {stringifyValue(cell.getValue())} - - )} - - + } when={table()}> + + + + Raw Data + {rawDataTotalCount() > ROW_LIMIT && ( + + {' '} + (First {ROW_LIMIT} rows) + + )} + + + > + } + right={ + <> + + Rows ({rows().length} + {rowsTotalCount() > ROW_LIMIT && ` of ${rowsTotalCount()}`}) + {rowsTotalCount() > ROW_LIMIT && ( + + {' '} + — First {ROW_LIMIT} rows + + )} + + + View: + + setSelectedRowModel( + e.currentTarget + .value as (typeof ROW_MODEL_GETTERS)[number], + ) + } + > + + {(getterName) => ( + {getterName} )} - - - - > - } - /> - + + + + + + + # + + {(column) => ( + {column.id} + )} + + + + + + {(row) => ( + + {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 13f4e19fa6..c321e30d4f 100644 --- a/packages/table-devtools/src/components/Shell.tsx +++ b/packages/table-devtools/src/components/Shell.tsx @@ -1,4 +1,4 @@ -import { Match, Show, Switch } from 'solid-js' +import { For, Match, Show, Switch } from 'solid-js' import { Header, HeaderLogo, MainPanel, Select } from '@tanstack/devtools-ui' import { useTableDevtoolsContext } from '../TableContextProvider' import { useStyles } from '../styles/use-styles' @@ -49,31 +49,35 @@ export function Shell() { - 0}> - - {(_selectedTargetId) => ( - setSelectedTargetId(value)} - /> - )} - + 0 && tableOptions()}> + {(tableOptions) => ( + + {(selectedTargetId) => ( + setSelectedTargetId(value)} + /> + )} + + )} - {tabs.map((tab) => ( - setActiveTab(tab.id)} - > - {tab.label} - - ))} + + {(tab) => ( + setActiveTab(tab.id)} + > + {tab.label} + + )} + diff --git a/packages/table-devtools/src/components/StatePanel.tsx b/packages/table-devtools/src/components/StatePanel.tsx index 2b03b5320c..0f47ccde97 100644 --- a/packages/table-devtools/src/components/StatePanel.tsx +++ b/packages/table-devtools/src/components/StatePanel.tsx @@ -1,6 +1,6 @@ -import { For, createSignal } from 'solid-js' +import { For, Show, createEffect, createMemo, createSignal } from 'solid-js' import { JsonTree } from '@tanstack/devtools-ui' -import { batch, useSelector } from '@tanstack/solid-store' +import { batch } from '@tanstack/solid-store' import { useTableDevtoolsContext } from '../TableContextProvider' import { useTableStore } from '../useTableStore' import { useStyles } from '../styles/use-styles' @@ -22,41 +22,49 @@ export function StatePanel() { const [storeCopied, setStoreCopied] = createSignal(false) const [pasteError, setPasteError] = createSignal(null) - const tableInstance = table() // 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( - tableInstance ? tableInstance.store : undefined, + () => table()?.store, (state) => state, ) - const tableOptions = tableInstance - ? tableInstance.optionsStore - ? useSelector(tableInstance.optionsStore, (opts) => opts) - : useTableStore(tableInstance.store, () => tableInstance.options) - : undefined - - if (!tableInstance) { - return - } + const tableOptions = useTableStore( + () => { + const tableInstance = table() + return tableInstance?.optionsStore ?? tableInstance?.store + }, + () => table()?.options as unknown, + ) - const getInitialState = (): unknown => { - tableState?.() - tableOptions?.() - return tableInstance.initialState as unknown - } + const initialState = createMemo((): unknown => { + const tableInstance = table() + if (!tableInstance) return undefined - const getStoreState = (): unknown => { - tableState?.() - tableOptions?.() - return tableInstance.store.state as unknown - } + tableState() + tableOptions() + + return tableInstance.initialState + }) + + const storeState = createMemo((): unknown => { + const tableInstance = table() + if (!tableInstance) return undefined + + tableState() + tableOptions() + + return tableInstance.store.state + }) + + const atomSlices = createMemo((): Array => { + const tableInstance = table() + if (!tableInstance) return [] - const getAtomSlices = (): Array => { // Touch subscriptions so this recomputes on state or option change. - tableState?.() - tableOptions?.() + tableState() + tableOptions() - const options = tableInstance.options as Record + const options = tableInstance.options as unknown as Record const externalAtoms = (options.atoms as Record | undefined) ?? {} const externalState = @@ -82,7 +90,7 @@ export function StatePanel() { source, } }) - } + }) const copyToClipboard = async ( value: unknown, @@ -98,8 +106,11 @@ export function StatePanel() { } const handlePaste = async () => { + const tableInstance = table() if (!tableInstance) return + setPasteError(null) + try { const text = await navigator.clipboard.readText() const parsed = JSON.parse(text) @@ -130,77 +141,75 @@ export function StatePanel() { } const handleReset = () => { - tableInstance.reset() + table()?.reset() } return ( - - - initialState - - - copyToClipboard(getInitialState(), setInitialStateCopied) - } - disabled={!tableInstance} - > - {initialStateCopied() ? 'Copied!' : 'Copy'} - - - - > - } - middle={ - <> - Atoms - - - Reset to initialState - - - - {(slice) => } - - > - } - right={ - <> - Store - - copyToClipboard(getStoreState(), setStoreCopied)} - disabled={!tableInstance} - > - {storeCopied() ? 'Copied!' : 'Copy'} - - - Paste - - - {pasteError() && ( - {pasteError()} - )} - - > - } - /> - + } when={table()}> + + + initialState + + + copyToClipboard(initialState(), setInitialStateCopied) + } + > + {initialStateCopied() ? 'Copied!' : 'Copy'} + + + + > + } + middle={ + <> + Atoms + + + Reset to initialState + + + + {(slice) => } + + > + } + right={ + <> + Store + + copyToClipboard(storeState(), setStoreCopied)} + > + {storeCopied() ? 'Copied!' : 'Copy'} + + + Paste + + + {pasteError() && ( + {pasteError()} + )} + + > + } + /> + + ) } diff --git a/packages/table-devtools/src/components/ThreeWayResizableSplit.tsx b/packages/table-devtools/src/components/ThreeWayResizableSplit.tsx index fb629a230e..3a5803172e 100644 --- a/packages/table-devtools/src/components/ThreeWayResizableSplit.tsx +++ b/packages/table-devtools/src/components/ThreeWayResizableSplit.tsx @@ -20,6 +20,7 @@ export function ThreeWayResizableSplit(props: ThreeWayResizableSplitProps) { const makeDragHandler = (which: 'left' | 'right'): ((e: MouseEvent) => void) => + // eslint-disable-next-line solid/reactivity (e) => { e.preventDefault() const handleEl = e.currentTarget as HTMLElement diff --git a/packages/table-devtools/src/tableTarget.ts b/packages/table-devtools/src/tableTarget.ts index 0811d957a9..12e86b9646 100644 --- a/packages/table-devtools/src/tableTarget.ts +++ b/packages/table-devtools/src/tableTarget.ts @@ -1,3 +1,4 @@ +import { createEffect, createRoot, createSignal } from 'solid-js' import type { RowData, Table, TableFeatures } from '@tanstack/table-core' type AnyTable = Table @@ -18,18 +19,11 @@ export interface UpsertTableDevtoolsTargetOptions { name?: string } -const registrations = new Map() -const listeners = new Set() +const [registrationsMap, setRegistrationsMap] = createSignal< + Map +>(new Map()) let fallbackNameCounter = 1 -function emitTargets() { - const targets = getTableDevtoolsTargets() - - for (const listener of listeners) { - listener(targets) - } -} - function normalizeName(name?: string) { const trimmedName = name?.trim() return trimmedName ? trimmedName : undefined @@ -38,15 +32,13 @@ function normalizeName(name?: string) { export function upsertTableDevtoolsTarget( options: UpsertTableDevtoolsTargetOptions, ) { + const registrations = registrationsMap() const existingRegistration = registrations.get(options.id) const name = normalizeName(options.name) if (existingRegistration) { - registrations.set(options.id, { - ...existingRegistration, - table: options.table, - name, - }) + existingRegistration.table = options.table + existingRegistration.name = name } else { registrations.set(options.id, { id: options.id, @@ -56,27 +48,31 @@ export function upsertTableDevtoolsTarget( }) } - emitTargets() + setRegistrationsMap(new Map(registrations.entries())) } export function removeTableDevtoolsTarget(id: string) { + const registrations = registrationsMap() if (!registrations.delete(id)) { return } - emitTargets() + setRegistrationsMap(new Map(registrations.entries())) } export function getTableDevtoolsTargets(): Array { - return Array.from(registrations.values()) + return Array.from(registrationsMap().values()) } export function subscribeTableDevtoolsTargets(listener: Listener) { - listeners.add(listener) - - return () => { - listeners.delete(listener) - } + let disposeRoot = () => {} + createRoot((dispose) => { + disposeRoot = dispose + createEffect(() => { + listener(getTableDevtoolsTargets()) + }) + }) + return disposeRoot } export function setTableDevtoolsTarget(table: Table | undefined) { diff --git a/packages/table-devtools/src/useTableStore.ts b/packages/table-devtools/src/useTableStore.ts index 43c2fe748e..70fbfa2ec4 100644 --- a/packages/table-devtools/src/useTableStore.ts +++ b/packages/table-devtools/src/useTableStore.ts @@ -1,4 +1,6 @@ -import { createSignal, onCleanup } from 'solid-js' +import { createEffect, createSignal, onCleanup } from 'solid-js' +import type { Accessor } from 'solid-js' +import type { Readable } from '@tanstack/solid-store' /** * Subscribes to a table store and returns a reactive signal. @@ -6,28 +8,25 @@ import { createSignal, onCleanup } from 'solid-js' * { unsubscribe } object return (store 0.9.x). */ export function useTableStore( - store: - | { state: T; subscribe: (listener: () => void) => unknown } - | null - | undefined, + storeAccessor: Accessor | null | undefined>, selector: (state: T) => U = (s) => s as unknown as U, -): (() => U) | undefined { - if (!store) return undefined +): Accessor { + const initialValue = storeAccessor()?.get() + const [signal, setSignal] = createSignal( + initialValue ? selector(initialValue) : undefined, + ) - const [signal, setSignal] = createSignal(selector(store.state)) - const result = store.subscribe(() => { - setSignal(() => selector(store.state)) - }) + createEffect(() => { + const store = storeAccessor() + if (!store) return + + const subscription = store.subscribe(() => { + setSignal(() => selector(store.get())) + }) - onCleanup(() => { - if (typeof result === 'function') { - ;(result as () => void)() - } else if ( - result && - typeof (result as { unsubscribe?: () => void }).unsubscribe === 'function' - ) { - ;(result as { unsubscribe: () => void }).unsubscribe() - } + onCleanup(() => { + subscription.unsubscribe() + }) }) return signal diff --git a/packages/vue-table/skills/vue/compose-with-tanstack-store/SKILL.md b/packages/vue-table/skills/vue/compose-with-tanstack-store/SKILL.md index 44a82817cc..ddfa0696ba 100644 --- a/packages/vue-table/skills/vue/compose-with-tanstack-store/SKILL.md +++ b/packages/vue-table/skills/vue/compose-with-tanstack-store/SKILL.md @@ -117,8 +117,8 @@ useSelector(table.atoms.sorting) // reactive ref-like computed(() => table.atoms.sorting.get()) // alternative // (b) Flat store — full snapshot. -table.store.state // readonly -table.store.state.sorting // current value +table.state // readonly +table.state.sorting // current value // (c) useTable selector — typed reactive projection. const table = useTable(opts, (s) => ({ sorting: s.sorting })) @@ -285,7 +285,7 @@ const sorting = computed(() => sortingAtom.get()) ### Hallucinating pre-v9 API names (CRITICAL) -`useVueTable`, `table.getState()` — both v8. v9 uses `useTable` and `table.store.state` / +`useVueTable`, `table.getState()` — both v8. v9 uses `useTable` and `table.state` / `table.state` / `table.atoms..get()`. See `tanstack-table/vue/migrate-v8-to-v9`. ### "API missing" because feature not in `_features` (CRITICAL — v9-specific) diff --git a/packages/vue-table/skills/vue/migrate-v8-to-v9/SKILL.md b/packages/vue-table/skills/vue/migrate-v8-to-v9/SKILL.md index 1b96c40688..47b2c6d186 100644 --- a/packages/vue-table/skills/vue/migrate-v8-to-v9/SKILL.md +++ b/packages/vue-table/skills/vue/migrate-v8-to-v9/SKILL.md @@ -5,7 +5,7 @@ description: > → `useTable`, move `getCoreRowModel`/`getSortedRowModel`/etc. options into `_rowModels` factories, add the mandatory `_features` via `tableFeatures({...})`, update `createColumnHelper()` → `createColumnHelper()`, rename - `sortingFn`/`sortingFns` → `sortFn`/`sortFns`, swap `table.getState()` for `table.store.state` + `sortingFn`/`sortingFns` → `sortFn`/`sortFns`, swap `table.getState()` for `table.state` / `table.state` / `table.atoms..get()`, and prefer `` over the legacy `:render`/`:props` shape. Vue has NO `/legacy` entrypoint — migration is a direct rewrite. The Vue adapter installs `vueReactivity()` automatically. @@ -133,7 +133,7 @@ const table = useTable({ | `state.columnSizingInfo` | `state.columnResizing` | | `onColumnSizingInfoChange` | `onColumnResizingChange` | | `ColumnSizing` feature | `columnSizingFeature` + `columnResizingFeature` (split) | -| `table.getState()` | `table.store.state` (full) / `table.state` (selector) / `table.atoms..get()` | +| `table.getState()` | `table.state` (full) / `table.state` (selector) / `table.atoms..get()` | | `row._getAllCellsByColumnId()` | `row.getAllCellsByColumnId()` (underscore removed) | | `table._getFacetedRowModel()` / `_getFacetedMinMaxValues()` / `_getFacetedUniqueValues()` | Same names without leading underscore | | `` | `` / `:header` / `:footer` (preferred; legacy still works) | @@ -193,7 +193,7 @@ const sorting = table.getState().sorting // v9 — pick the narrowest read. const sorting = table.atoms.sorting.get() // narrowest, no full state object built -const snapshot = table.store.state // full readonly view +const snapshot = table.state // full readonly view const table = useTable(opts, (s) => ({ sorting: s.sorting })) // selected reactive state table.state.sorting // typed selector output ``` diff --git a/packages/vue-table/skills/vue/table-state/SKILL.md b/packages/vue-table/skills/vue/table-state/SKILL.md index 510c57d38c..bcfa69f9ef 100644 --- a/packages/vue-table/skills/vue/table-state/SKILL.md +++ b/packages/vue-table/skills/vue/table-state/SKILL.md @@ -127,7 +127,7 @@ The Vue adapter calls `vueReactivity()` and installs it as `coreReativityFeature const sorting = table.atoms.sorting.get() // (b) Flat readonly store — every registered slice as one object -const snapshot = table.store.state +const snapshot = table.state // (c) Vue selected state — the value returned from useTable's 2nd arg const table = useTable( diff --git a/packages/vue-table/src/reactivity.ts b/packages/vue-table/src/reactivity.ts index e04475636d..2f27ddaecf 100644 --- a/packages/vue-table/src/reactivity.ts +++ b/packages/vue-table/src/reactivity.ts @@ -1,4 +1,11 @@ -import { computed, shallowRef, watch } from 'vue' +import { + computed, + getCurrentScope, + onScopeDispose, + shallowRef, + watch, +} from 'vue' +import { batch, createAtom } from '@tanstack/vue-store' import type { TableAtomOptions, TableReactivityBindings, @@ -6,6 +13,8 @@ import type { import type { Atom, Observer, ReadonlyAtom } from '@tanstack/vue-store' import type { ComputedRef, ShallowRef } from 'vue' +const optionsStoreDebugName = 'table/optionsStore' + function observerToCallback( observerOrNext: Observer | ((value: T) => void), ): (value: T) => void { @@ -49,24 +58,47 @@ function refToWritableAtom(source: ShallowRef): Atom { /** * Creates the table-core reactivity bindings used by the Vue adapter. * - * Readonly table atoms are backed by Vue `computed` refs and writable atoms by - * `shallowRef`. Subscriptions use synchronous `watch` callbacks so table store - * updates are visible to Vue render and computed work immediately. + * Table state atoms are backed by TanStack Store atoms. The options store stays + * framework-native because row-model APIs read `table.options` directly during + * render. Readonly table atoms bridge Store dependency tracking into Vue computed + * refs. */ export function vueReactivity(): TableReactivityBindings { return { createOptionsStore: true, schedule: (fn) => queueMicrotask(() => fn()), createReadonlyAtom: (fn: () => T, _options?: TableAtomOptions) => { - return refToReadonlyAtom(computed(fn)) + const storeAtom = createAtom(() => fn(), { + compare: _options?.compare, + }) + const version = shallowRef(0) + const subscription = storeAtom.subscribe(() => { + version.value += 1 + }) + if (getCurrentScope()) { + onScopeDispose(() => subscription.unsubscribe()) + } + + return refToReadonlyAtom( + computed(() => { + version.value + return storeAtom.get() + }), + ) }, createWritableAtom: ( value: T, _options?: TableAtomOptions, ): Atom => { - return refToWritableAtom(shallowRef(value) as ShallowRef) + if (_options?.debugName === optionsStoreDebugName) { + return refToWritableAtom(shallowRef(value) as ShallowRef) + } + + return createAtom(value, { + compare: _options?.compare, + }) }, untrack: (fn) => fn(), - batch: (fn) => fn(), + batch, } } diff --git a/packages/vue-table/src/useTable.ts b/packages/vue-table/src/useTable.ts index dc25973de1..3c12f30c62 100644 --- a/packages/vue-table/src/useTable.ts +++ b/packages/vue-table/src/useTable.ts @@ -62,7 +62,15 @@ export type VueTable< TFeatures extends TableFeatures, TData extends RowData, TSelected = TableState, -> = Table & { +> = Omit, 'store'> & { + /** + * @deprecated Prefer `table.state` for render reads, + * `table.atoms..get()` for slice snapshots, or + * `table.Subscribe` / `useSelector(table.store, selector)` for explicit + * subscriptions. `table.store.state` is a current-value snapshot and is easy + * to misuse in render code. + */ + readonly store: Table['store'] /** * Store mode: `selector` required. Source mode: pass `source` (atom or store); omit * `selector` for the whole value (identity), or pass `selector` to project. Split @@ -94,7 +102,9 @@ export type VueTable< }): VNode | Array } /** - * The selected state of the table. This state may not match the structure of `table.store.state` because it is selected by the `selector` function that you pass as the 2nd argument to `useTable`. + * The selected state of the table. This state may not match the structure of + * the full table state because it is selected by the selector function that + * you pass as the 2nd argument to `useTable`. * * @example * const table = useTable(options, (state) => ({ globalFilter: state.globalFilter })) // only globalFilter is part of the selected state @@ -147,18 +157,15 @@ export function useTable< ) } - const mergedOptions = { - ...tableOptions, + const mergedOptions = mergeProxy(tableOptions, { _features: { coreReativityFeature: vueReactivity(), ...tableOptions._features, }, - } + }) as TableOptionsWithReactiveData const resolvedOptions = mergeProxy( - getOptionsWithReactiveValues( - mergedOptions as TableOptionsWithReactiveData
{JSON.stringify(table.state.columnVisibility, null, 2) }