diff --git a/.changeset/lean-trees-shake.md b/.changeset/lean-trees-shake.md new file mode 100644 index 0000000000..fc7fb4d544 --- /dev/null +++ b/.changeset/lean-trees-shake.md @@ -0,0 +1,8 @@ +--- +'@tanstack/table-core': patch +'@tanstack/table-devtools': patch +--- + +Improve tree-shaking. Importing one built-in `filterFn_*`, `sortFn_*`, or `aggregationFn_*` now bundles only that function instead of its whole module. The core row model, header building, and the filtered, sorted, and grouped row models no longer import code from features a table doesn't register, so a minimal table is about 0.6 kB (brotli) smaller and grouping without `rowAggregationFeature` no longer bundles the aggregation executor. Add `table.autoResetSorting()`, and fix the `Table_RowPagination` type to declare the `autoResetPageIndex` method the table actually has (it was typed as `_autoResetPageIndex`). + +The devtools Features panel now shows measured minified + brotli sizes for every feature, row model, and built-in function, and its total accounts for code that features share. diff --git a/package.json b/package.json index 1273ffb430..9c36885489 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "lint:fix:all": "pnpm run format && nx run-many --targets=lint --fix", "lint:fix": "nx affected --target=lint:fix --exclude=examples/**", "size": "size-limit", + "size:devtools": "node scripts/generate-bundle-sizes.mjs", "test": "pnpm run test:ci", "test:build": "nx affected --target=test:build", "test:ci": "pnpm run test:compiler-examples && nx run-many --targets=test:eslint,test:sherif,test:knip,test:lib,test:types,test:build,build", @@ -62,8 +63,33 @@ }, "size-limit": [ { + "name": "@tanstack/table-core (everything)", "path": "packages/table-core/dist/index.js", "limit": "30 KB" + }, + { + "name": "constructTable (core features only)", + "path": "packages/table-core/dist/index.js", + "import": "{ constructTable }", + "limit": "6 KB" + }, + { + "name": "filterFn_includesString (tree-shaking)", + "path": "packages/table-core/dist/index.js", + "import": "{ filterFn_includesString }", + "limit": "300 B" + }, + { + "name": "sortFn_text (tree-shaking)", + "path": "packages/table-core/dist/index.js", + "import": "{ sortFn_text }", + "limit": "300 B" + }, + { + "name": "aggregationFn_sum (tree-shaking)", + "path": "packages/table-core/dist/index.js", + "import": "{ aggregationFn_sum }", + "limit": "300 B" } ], "devDependencies": { diff --git a/packages/table-core/src/core/columns/coreColumnsFeature.utils.ts b/packages/table-core/src/core/columns/coreColumnsFeature.utils.ts index 7a6ea4bed7..ebef927f76 100644 --- a/packages/table-core/src/core/columns/coreColumnsFeature.utils.ts +++ b/packages/table-core/src/core/columns/coreColumnsFeature.utils.ts @@ -57,7 +57,7 @@ export function column_getLeafColumns< return callMemoOrStaticFn( column.table, - 'getOrderColumns', + 'getOrderColumnsFn', table_getOrderColumnsFn, )(leafColumns as any) as any } @@ -229,7 +229,7 @@ export function table_getAllLeafColumns< ) return callMemoOrStaticFn( table, - 'getOrderColumns', + 'getOrderColumnsFn', table_getOrderColumnsFn, )(leafColumns) as Array> } diff --git a/packages/table-core/src/core/featureStubs.ts b/packages/table-core/src/core/featureStubs.ts new file mode 100644 index 0000000000..004cfd87fb --- /dev/null +++ b/packages/table-core/src/core/featureStubs.ts @@ -0,0 +1,128 @@ +import type { Table_CellSelection } from '../features/cell-selection/cellSelectionFeature.types' +import type { + Column_ColumnVisibility, + Table_ColumnVisibility, +} from '../features/column-visibility/columnVisibilityFeature.types' +import type { + Column_GlobalFiltering, + Table_GlobalFiltering, +} from '../features/global-filtering/globalFilteringFeature.types' +import type { FilterFn } from '../features/column-filtering/columnFilteringFeature.types' +import type { aggregateColumnValue } from '../features/row-aggregation/rowAggregationFeature.utils' +import type { Table_RowExpanding } from '../features/row-expanding/rowExpandingFeature.types' +import type { Table_RowPagination } from '../features/row-pagination/rowPaginationFeature.types' +import type { Table_RowSorting } from '../features/row-sorting/rowSortingFeature.types' +import type { Column } from '../types/Column' +import type { Table_Internal } from '../types/Table' +import type { TableFeatures } from '../types/TableFeatures' +import type { RowData } from '../types/type-utils' + +/** + * Stubs for optional features, used by the core and by row models that + * another feature owns. + * + * Importing a feature's utils from shared code would bundle that feature into + * every table that uses the shared code, registered or not. Each stub calls + * the API the feature registers on the table or column when it is present, + * and otherwise behaves as if the feature were absent. + * + * Internal: not exported from any package entry point. + */ + +// Column visibility + +/** Whether a column is visible; every column is without the feature. */ +export function getIsColumnVisible(column: object): boolean { + return (column as Partial).getIsVisible?.() ?? true +} + +/** Visible leaf columns; all leaf columns without the feature. */ +export function getVisibleLeafColumns< + TFeatures extends TableFeatures, + TData extends RowData, +>( + table: Table_Internal, +): Array> { + return ( + ( + table as Partial> + ).getVisibleLeafColumns?.() ?? table.getAllLeafColumns() + ) +} + +// Global filtering + +/** The resolved global filter function, if the feature is registered. */ +export function getGlobalFilterFn< + TFeatures extends TableFeatures, + TData extends RowData, +>( + table: Table_Internal, +): FilterFn | undefined { + return ( + table as Partial> + ).getGlobalFilterFn?.() +} + +/** Whether a column takes part in global filtering; none do without the feature. */ +export function getCanGlobalFilter(column: object): boolean { + return ( + (column as Partial).getCanGlobalFilter?.() ?? false + ) +} + +// Auto resets + +/** Resets expanded state if row expanding is registered. */ +export function autoResetExpanded< + TFeatures extends TableFeatures, + TData extends RowData, +>(table: Table_Internal): void { + ;( + table as Partial> + ).autoResetExpanded?.() +} + +/** Resets the page index if row pagination is registered. */ +export function autoResetPageIndex< + TFeatures extends TableFeatures, + TData extends RowData, +>(table: Table_Internal): void { + ;( + table as Partial> + ).autoResetPageIndex?.() +} + +/** Resets sorting if row sorting is registered. */ +export function autoResetSorting< + TFeatures extends TableFeatures, + TData extends RowData, +>(table: Table_Internal): void { + ;(table as Partial>).autoResetSorting?.() +} + +/** Resets cell selection if cell selection is registered. */ +export function autoResetCellSelection< + TFeatures extends TableFeatures, + TData extends RowData, +>(table: Table_Internal): void { + ;( + table as Partial> + ).autoResetCellSelection?.() +} + +// Row aggregation + +/** + * The aggregation executor `rowAggregationFeature` stores on the table, or + * `undefined` when the feature is not registered. + */ +export function getAggregateColumnValue< + TFeatures extends TableFeatures, + TData extends RowData, +>( + table: Table_Internal, +): typeof aggregateColumnValue | undefined { + return (table as { _aggregateColumnValue?: typeof aggregateColumnValue }) + ._aggregateColumnValue +} diff --git a/packages/table-core/src/core/headers/buildHeaderGroups.ts b/packages/table-core/src/core/headers/buildHeaderGroups.ts index c7e7420174..31867c22ba 100644 --- a/packages/table-core/src/core/headers/buildHeaderGroups.ts +++ b/packages/table-core/src/core/headers/buildHeaderGroups.ts @@ -1,5 +1,4 @@ -import { callMemoOrStaticFn } from '../../utils' -import { column_getIsVisible } from '../../features/column-visibility/columnVisibilityFeature.utils' +import { getIsColumnVisible } from '../featureStubs' import { constructHeader } from './constructHeader' import type { Table_Internal } from '../../types/Table' import type { CellData, RowData } from '../../types/type-utils' @@ -19,10 +18,7 @@ function getMaxHeaderDepth< for (let i = 0; i < columns.length; i++) { const column = columns[i]! - if ( - callMemoOrStaticFn(column, 'getIsVisible', column_getIsVisible) && - column.columns.length - ) { + if (getIsColumnVisible(column) && column.columns.length) { maxDepth = Math.max( maxDepth, getMaxHeaderDepth(column.columns, depth + 1), @@ -168,9 +164,7 @@ function updateHeaderSpans< >(headers: Array>): void { for (let i = 0; i < headers.length; i++) { const header = headers[i]! - if ( - !callMemoOrStaticFn(header.column, 'getIsVisible', column_getIsVisible) - ) { + if (!getIsColumnVisible(header.column)) { continue } @@ -181,9 +175,7 @@ function updateHeaderSpans< for (let j = 0; j < header.subHeaders.length; j++) { const child = header.subHeaders[j]! - if ( - !callMemoOrStaticFn(child.column, 'getIsVisible', column_getIsVisible) - ) { + if (!getIsColumnVisible(child.column)) { continue } diff --git a/packages/table-core/src/core/headers/coreHeadersFeature.utils.ts b/packages/table-core/src/core/headers/coreHeadersFeature.utils.ts index 2bf5f108e6..edd1e7bbd7 100644 --- a/packages/table-core/src/core/headers/coreHeadersFeature.utils.ts +++ b/packages/table-core/src/core/headers/coreHeadersFeature.utils.ts @@ -1,9 +1,4 @@ -import { getDefaultColumnPinningState } from '../../features/column-pinning/columnPinningFeature.utils' -import { - column_getIsVisible, - table_getVisibleLeafColumns, -} from '../../features/column-visibility/columnVisibilityFeature.utils' -import { callMemoOrStaticFn } from '../../utils' +import { getIsColumnVisible, getVisibleLeafColumns } from '../featureStubs' import { buildHeaderGroups } from './buildHeaderGroups' import type { Table_Internal } from '../../types/Table' import type { Header } from '../../types/Header' @@ -86,14 +81,13 @@ export function table_getHeaderGroups< TFeatures extends TableFeatures, TData extends RowData, >(table: Table_Internal) { - const { start, end } = - table.atoms.columnPinning?.get() ?? getDefaultColumnPinningState() + // Pinning and visibility are optional features; without them nothing is + // pinned and every leaf column is visible + const columnPinning = table.atoms.columnPinning?.get() + const start = columnPinning?.start ?? [] + const end = columnPinning?.end ?? [] const allColumns = table.getAllColumns() - const leafColumns = callMemoOrStaticFn( - table, - 'getVisibleLeafColumns', - table_getVisibleLeafColumns, - ) + const leafColumns = getVisibleLeafColumns(table) // Fast path: no columns are pinned — skip per-side lookups, partition, and spread. if (!start.length && !end.length) { @@ -105,10 +99,7 @@ export function table_getHeaderGroups< const leftColumns: typeof leafColumns = [] for (let i = 0; i < start.length; i++) { const column = leafColumnsById[start[i]!] - if ( - column && - callMemoOrStaticFn(column, 'getIsVisible', column_getIsVisible) - ) { + if (column && getIsColumnVisible(column)) { leftColumns.push(column) } } @@ -116,10 +107,7 @@ export function table_getHeaderGroups< const rightColumns: typeof leafColumns = [] for (let i = 0; i < end.length; i++) { const column = leafColumnsById[end[i]!] - if ( - column && - callMemoOrStaticFn(column, 'getIsVisible', column_getIsVisible) - ) { + if (column && getIsColumnVisible(column)) { rightColumns.push(column) } } diff --git a/packages/table-core/src/core/row-models/createCoreRowModel.ts b/packages/table-core/src/core/row-models/createCoreRowModel.ts index 21b543cf4a..0a3d28ac5a 100644 --- a/packages/table-core/src/core/row-models/createCoreRowModel.ts +++ b/packages/table-core/src/core/row-models/createCoreRowModel.ts @@ -1,9 +1,11 @@ import { constructRow } from '../rows/constructRow' import { makeObjectMap, skipFirstRun, tableMemo } from '../../utils' -import { table_autoResetCellSelection } from '../../features/cell-selection/cellSelectionFeature.utils' -import { table_autoResetExpanded } from '../../features/row-expanding/rowExpandingFeature.utils' -import { table_autoResetPageIndex } from '../../features/row-pagination/rowPaginationFeature.utils' -import { table_autoResetSorting } from '../../features/row-sorting/rowSortingFeature.utils' +import { + autoResetCellSelection, + autoResetExpanded, + autoResetPageIndex, + autoResetSorting, +} from '../featureStubs' import type { Table_Internal } from '../../types/Table' import type { RowModel } from './coreRowModelsFeature.types' import type { TableFeatures } from '../../types/TableFeatures' @@ -29,10 +31,10 @@ export function createCoreRowModel< memoDeps: () => [table.options.data], fn: () => _createCoreRowModel(table, table.options.data), onAfterUpdate: skipFirstRun(() => { - table_autoResetExpanded(table) - table_autoResetPageIndex(table) - table_autoResetSorting(table) - table_autoResetCellSelection(table) + autoResetExpanded(table) + autoResetPageIndex(table) + autoResetSorting(table) + autoResetCellSelection(table) }), }) } diff --git a/packages/table-core/src/features/cell-spanning/cellSpanningFeature.utils.ts b/packages/table-core/src/features/cell-spanning/cellSpanningFeature.utils.ts index 4239603a7a..54ec06c395 100644 --- a/packages/table-core/src/features/cell-spanning/cellSpanningFeature.utils.ts +++ b/packages/table-core/src/features/cell-spanning/cellSpanningFeature.utils.ts @@ -14,8 +14,10 @@ import type { // Span index -const EMPTY_ROW_SPANS: Record = makeObjectMap() -const EMPTY_COLUMN_INDEXES: Record = makeObjectMap() +const EMPTY_ROW_SPANS: Record = + /* @__PURE__ */ makeObjectMap() +const EMPTY_COLUMN_INDEXES: Record = + /* @__PURE__ */ makeObjectMap() const EMPTY_COL_SPANS: Array = [] const NO_SECTION_STARTS: ReadonlyArray = [] diff --git a/packages/table-core/src/features/column-filtering/createFilteredRowModel.ts b/packages/table-core/src/features/column-filtering/createFilteredRowModel.ts index 1c7bef531a..49f0127406 100644 --- a/packages/table-core/src/features/column-filtering/createFilteredRowModel.ts +++ b/packages/table-core/src/features/column-filtering/createFilteredRowModel.ts @@ -1,10 +1,10 @@ import { makeObjectMap, skipFirstRun, tableMemo } from '../../utils' import { table_getColumn } from '../../core/columns/coreColumnsFeature.utils' import { - column_getCanGlobalFilter, - table_getGlobalFilterFn, -} from '../global-filtering/globalFilteringFeature.utils' -import { table_autoResetPageIndex } from '../row-pagination/rowPaginationFeature.utils' + autoResetPageIndex, + getCanGlobalFilter, + getGlobalFilterFn, +} from '../../core/featureStubs' import { filterRows } from './filterRowsUtils' import { column_getFilterFn } from './columnFilteringFeature.utils' import type { RowData } from '../../types/type-utils' @@ -45,7 +45,7 @@ export function createFilteredRowModel< table.atoms.globalFilter?.get(), ], fn: () => _createFilteredRowModel(table), - onAfterUpdate: skipFirstRun(() => table_autoResetPageIndex(table)), + onAfterUpdate: skipFirstRun(() => autoResetPageIndex(table)), }) } } @@ -102,23 +102,27 @@ function _createFilteredRowModel< const filterableIds = columnFilters?.map((d) => d.id) ?? [] - const globalFilterFn = table_getGlobalFilterFn(table) - - const globallyFilterableColumns = table - .getAllLeafColumns() - .filter((column) => column_getCanGlobalFilter(column)) - - if (hasGlobalFilter && globalFilterFn && globallyFilterableColumns.length) { - filterableIds.push('__global__') - - globallyFilterableColumns.forEach((column) => { - resolvedGlobalFilters.push({ - id: column.id, - filterFn: globalFilterFn, - resolvedValue: - globalFilterFn.resolveFilterValue?.(globalFilter) ?? globalFilter, + // A global filter value only exists when globalFilteringFeature is + // registered, so its global filter fn is resolved only when one is set + if (hasGlobalFilter) { + const globalFilterFn = getGlobalFilterFn(table) + + const globallyFilterableColumns = globalFilterFn + ? table.getAllLeafColumns().filter(getCanGlobalFilter) + : [] + + if (globalFilterFn && globallyFilterableColumns.length) { + filterableIds.push('__global__') + + globallyFilterableColumns.forEach((column) => { + resolvedGlobalFilters.push({ + id: column.id, + filterFn: globalFilterFn, + resolvedValue: + globalFilterFn.resolveFilterValue?.(globalFilter) ?? globalFilter, + }) }) - }) + } } // Flag the pre-filtered row model with each filter state diff --git a/packages/table-core/src/features/column-filtering/filterFns.ts b/packages/table-core/src/features/column-filtering/filterFns.ts index fc94971170..f646904967 100644 --- a/packages/table-core/src/features/column-filtering/filterFns.ts +++ b/packages/table-core/src/features/column-filtering/filterFns.ts @@ -37,6 +37,7 @@ import type { Row } from '../../types/Row' * are tested. When calling a filter function directly (outside of a table), * apply it yourself: `fn(row, columnId, fn.resolveFilterValue?.(value) ?? value)`. */ +/* @__NO_SIDE_EFFECTS__ */ export function constructFilterFn< TFeatures extends TableFeatures = any, TData extends RowData = any, @@ -74,7 +75,7 @@ export function constructFilterFn< * * Uses JavaScript `===` comparison and auto-removes empty filter values. */ -export const filterFn_equals = constructFilterFn({ +export const filterFn_equals = /* @__PURE__ */ constructFilterFn({ filter: (dataValue, filterValue) => dataValue === filterValue, autoRemove: (val: any) => testFalsy(val), }) @@ -85,7 +86,7 @@ export const filterFn_equals = constructFilterFn({ * Uses JavaScript `==` comparison and auto-removes empty filter values. This is * useful for matching string input against numeric row values. */ -export const filterFn_weakEquals = constructFilterFn({ +export const filterFn_weakEquals = /* @__PURE__ */ constructFilterFn({ filter: (dataValue, filterValue) => dataValue == filterValue, autoRemove: (val: any) => testFalsy(val), }) @@ -97,12 +98,14 @@ export const filterFn_weakEquals = constructFilterFn({ * * Matching is case-sensitive and empty filter values are auto-removed. */ -export const filterFn_includesStringSensitive = constructFilterFn({ - filter: (dataValue, filterValue) => Boolean(dataValue?.includes(filterValue)), - autoRemove: (val: any) => testFalsy(val), - resolveFilterValue: (val: any) => String(val), - resolveDataValue: (val: any) => (val == null ? undefined : String(val)), -}) +export const filterFn_includesStringSensitive = + /* @__PURE__ */ constructFilterFn({ + filter: (dataValue, filterValue) => + Boolean(dataValue?.includes(filterValue)), + autoRemove: (val: any) => testFalsy(val), + resolveFilterValue: (val: any) => String(val), + resolveDataValue: (val: any) => (val == null ? undefined : String(val)), + }) /** * Keeps rows whose stringified column value includes the filter text. @@ -110,7 +113,7 @@ export const filterFn_includesStringSensitive = constructFilterFn({ * Both values are lowercased before comparison, and empty filter values are * auto-removed. */ -export const filterFn_includesString = constructFilterFn({ +export const filterFn_includesString = /* @__PURE__ */ constructFilterFn({ filter: (dataValue, filterValue) => Boolean(dataValue?.includes(filterValue)), autoRemove: (val: any) => testFalsy(val), resolveFilterValue: (val: any) => String(val).toLowerCase(), @@ -124,7 +127,7 @@ export const filterFn_includesString = constructFilterFn({ * Both values are lowercased before comparison, and empty filter values are * auto-removed. */ -export const filterFn_equalsString = constructFilterFn({ +export const filterFn_equalsString = /* @__PURE__ */ constructFilterFn({ filter: (dataValue, filterValue) => dataValue === filterValue, autoRemove: (val: any) => testFalsy(val), resolveFilterValue: (val: any) => String(val).toLowerCase(), @@ -137,12 +140,14 @@ export const filterFn_equalsString = constructFilterFn({ * * Matching is case-sensitive and empty filter values are auto-removed. */ -export const filterFn_equalsStringSensitive = constructFilterFn({ - filter: (dataValue, filterValue) => dataValue === filterValue, - autoRemove: (val: any) => testFalsy(val), - resolveFilterValue: (val: any) => String(val), - resolveDataValue: (val: any) => (val == null ? undefined : String(val)), -}) +export const filterFn_equalsStringSensitive = /* @__PURE__ */ constructFilterFn( + { + filter: (dataValue, filterValue) => dataValue === filterValue, + autoRemove: (val: any) => testFalsy(val), + resolveFilterValue: (val: any) => String(val), + resolveDataValue: (val: any) => (val == null ? undefined : String(val)), + }, +) /** * Keeps rows whose stringified column value starts with the filter text. @@ -150,7 +155,7 @@ export const filterFn_equalsStringSensitive = constructFilterFn({ * Both values are lowercased before comparison, and empty filter values are * auto-removed. */ -export const filterFn_startsWith = constructFilterFn({ +export const filterFn_startsWith = /* @__PURE__ */ constructFilterFn({ filter: (dataValue, filterValue) => Boolean(dataValue?.startsWith(filterValue)), autoRemove: (val: any) => testFalsy(val), @@ -165,7 +170,7 @@ export const filterFn_startsWith = constructFilterFn({ * Both values are lowercased before comparison, and empty filter values are * auto-removed. */ -export const filterFn_endsWith = constructFilterFn({ +export const filterFn_endsWith = /* @__PURE__ */ constructFilterFn({ filter: (dataValue, filterValue) => Boolean(dataValue?.endsWith(filterValue)), autoRemove: (val: any) => testFalsy(val), resolveFilterValue: (val: any) => String(val).toLowerCase(), @@ -182,7 +187,7 @@ export const filterFn_endsWith = constructFilterFn({ * filter value acts as an on/off flag: `false` and blank values are * auto-removed. */ -export const filterFn_empty = constructFilterFn({ +export const filterFn_empty = /* @__PURE__ */ constructFilterFn({ filter: (dataValue) => testValueEmpty(dataValue), autoRemove: (val: any) => testFalsy(val) || val === false, }) @@ -194,7 +199,7 @@ export const filterFn_empty = constructFilterFn({ * filter value acts as an on/off flag: `false` and blank values are * auto-removed. */ -export const filterFn_notEmpty = constructFilterFn({ +export const filterFn_notEmpty = /* @__PURE__ */ constructFilterFn({ filter: (dataValue) => !testValueEmpty(dataValue), autoRemove: (val: any) => testFalsy(val) || val === false, }) @@ -207,7 +212,7 @@ export const filterFn_notEmpty = constructFilterFn({ * Numeric values are compared numerically when both sides can be coerced to * numbers; otherwise normalized strings are compared. */ -export const filterFn_greaterThan = constructFilterFn({ +export const filterFn_greaterThan = /* @__PURE__ */ constructFilterFn({ filter: (dataValue, filterValue) => compareGreaterThan(dataValue, filterValue), autoRemove: (val: any) => testFalsy(val), @@ -218,7 +223,7 @@ export const filterFn_greaterThan = constructFilterFn({ * * Delegates to the built-in greater-than and strict-equality comparisons. */ -export const filterFn_greaterThanOrEqualTo = constructFilterFn({ +export const filterFn_greaterThanOrEqualTo = /* @__PURE__ */ constructFilterFn({ filter: (dataValue, filterValue) => compareGreaterThanOrEqualTo(dataValue, filterValue), autoRemove: (val: any) => testFalsy(val), @@ -229,7 +234,7 @@ export const filterFn_greaterThanOrEqualTo = constructFilterFn({ * * This is implemented as the inverse of greater-than-or-equal comparison. */ -export const filterFn_lessThan = constructFilterFn({ +export const filterFn_lessThan = /* @__PURE__ */ constructFilterFn({ filter: (dataValue, filterValue) => !compareGreaterThanOrEqualTo(dataValue, filterValue), autoRemove: (val: any) => testFalsy(val), @@ -240,7 +245,7 @@ export const filterFn_lessThan = constructFilterFn({ * * This is implemented as the inverse of greater-than comparison. */ -export const filterFn_lessThanOrEqualTo = constructFilterFn({ +export const filterFn_lessThanOrEqualTo = /* @__PURE__ */ constructFilterFn({ filter: (dataValue, filterValue) => !compareGreaterThan(dataValue, filterValue), autoRemove: (val: any) => testFalsy(val), @@ -253,7 +258,7 @@ export const filterFn_lessThanOrEqualTo = constructFilterFn({ * * Blank range endpoints are treated as open-ended. */ -export const filterFn_between = constructFilterFn({ +export const filterFn_between = /* @__PURE__ */ constructFilterFn({ filter: (dataValue, filterValues: [unknown, unknown]) => compareBetween(dataValue, filterValues, false), autoRemove: (val: any) => @@ -266,7 +271,7 @@ export const filterFn_between = constructFilterFn({ * * Blank range endpoints are treated as open-ended. */ -export const filterFn_betweenInclusive = constructFilterFn({ +export const filterFn_betweenInclusive = /* @__PURE__ */ constructFilterFn({ filter: (dataValue, filterValues: [unknown, unknown]) => compareBetween(dataValue, filterValues, true), autoRemove: (val: any) => @@ -282,7 +287,7 @@ export const filterFn_betweenInclusive = constructFilterFn({ * range: non-numeric row values (`null`, `undefined`, strings, booleans) * never match. */ -export const filterFn_inNumberRange = constructFilterFn({ +export const filterFn_inNumberRange = /* @__PURE__ */ constructFilterFn({ filter: (dataValue: number, filterValue: [number, number]) => { // Guard against non-numeric values: JavaScript's loose relational // coercion would otherwise let values such as `null`, `''` and booleans @@ -328,7 +333,7 @@ export const filterFn_inNumberRange = constructFilterFn({ * parseable date strings. Blank or invalid endpoints become open-ended and * reversed endpoints are swapped. Rows without a valid date never match. */ -export const filterFn_inDateRange = constructFilterFn({ +export const filterFn_inDateRange = /* @__PURE__ */ constructFilterFn({ filter: (dataValue: number, filterValue: [number, number]) => { const [min, max] = filterValue return dataValue >= min && dataValue <= max @@ -361,7 +366,7 @@ export const filterFn_inDateRange = constructFilterFn({ /** * Keeps rows whose scalar column value equals at least one filter value. */ -export const filterFn_arrHas = constructFilterFn({ +export const filterFn_arrHas = /* @__PURE__ */ constructFilterFn({ filter: (dataValue, filterValue: Array) => { for (let i = 0; i < filterValue.length; i++) { if (dataValue === filterValue[i]) { @@ -376,7 +381,7 @@ export const filterFn_arrHas = constructFilterFn({ /** * Keeps rows whose array or string column value includes at least one filter value. */ -export const filterFn_arrIncludes = constructFilterFn({ +export const filterFn_arrIncludes = /* @__PURE__ */ constructFilterFn({ filter: (dataValue, filterValue: Array) => { if (typeof dataValue !== 'string' && !Array.isArray(dataValue)) { return false @@ -395,7 +400,7 @@ export const filterFn_arrIncludes = constructFilterFn({ /** * Keeps rows whose array column value includes every filter value. */ -export const filterFn_arrIncludesAll = constructFilterFn({ +export const filterFn_arrIncludesAll = /* @__PURE__ */ constructFilterFn({ filter: (dataValue, filterValue: Array) => { if (!Array.isArray(dataValue)) return false for (let i = 0; i < filterValue.length; i++) { @@ -411,7 +416,7 @@ export const filterFn_arrIncludesAll = constructFilterFn({ /** * Keeps rows whose array column value includes at least one filter value. */ -export const filterFn_arrIncludesSome = constructFilterFn({ +export const filterFn_arrIncludesSome = /* @__PURE__ */ constructFilterFn({ filter: (dataValue, filterValue: Array) => { if (!Array.isArray(dataValue)) return false for (let i = 0; i < filterValue.length; i++) { diff --git a/packages/table-core/src/features/column-grouping/createGroupedRowModel.ts b/packages/table-core/src/features/column-grouping/createGroupedRowModel.ts index d78fcea280..b37f44f8f1 100644 --- a/packages/table-core/src/features/column-grouping/createGroupedRowModel.ts +++ b/packages/table-core/src/features/column-grouping/createGroupedRowModel.ts @@ -1,12 +1,12 @@ import { hasOwn, makeObjectMap, tableMemo } from '../../utils' import { constructRow } from '../../core/rows/constructRow' import { table_getColumn } from '../../core/columns/coreColumnsFeature.utils' -import { table_autoResetExpanded } from '../row-expanding/rowExpandingFeature.utils' -import { table_autoResetPageIndex } from '../row-pagination/rowPaginationFeature.utils' +import { normalizeUniqueAggregationRows } from '../row-aggregation/rowAggregationFeature.utils' import { - aggregateColumnValue, - normalizeUniqueAggregationRows, -} from '../row-aggregation/rowAggregationFeature.utils' + autoResetExpanded, + autoResetPageIndex, + getAggregateColumnValue, +} from '../../core/featureStubs' import type { Row_ColumnGrouping } from './columnGroupingFeature.types' import type { Column_Internal } from '../../types/Column' import type { TableFeatures } from '../../types/TableFeatures' @@ -62,8 +62,8 @@ export function createGroupedRowModel< // reference does not change which rows belong on the current page or // which group ids are expanded. if (rowInputsChanged) { - table_autoResetExpanded(table) - table_autoResetPageIndex(table) + autoResetExpanded(table) + autoResetPageIndex(table) } }, }) @@ -188,12 +188,17 @@ function _createGroupedRowModel< return aggregationCache[colId] } + // rowAggregationFeature provides the executor, so grouping + // without aggregation never bundles it + const aggregate = getAggregateColumnValue(table) const column = table.getColumn(colId) as any - if (typeof column.getAggregationFns !== 'function') return undefined + if (!aggregate || typeof column.getAggregationFns !== 'function') { + return undefined + } const cache = ((row as any)._aggregationValuesCache ??= makeObjectMap()) - cache[colId] = aggregateColumnValue({ + cache[colId] = aggregate({ subRows, column, groupingRow: row, diff --git a/packages/table-core/src/features/row-aggregation/aggregationFns.ts b/packages/table-core/src/features/row-aggregation/aggregationFns.ts index 22fa2e716f..2e0d682833 100755 --- a/packages/table-core/src/features/row-aggregation/aggregationFns.ts +++ b/packages/table-core/src/features/row-aggregation/aggregationFns.ts @@ -1,4 +1,22 @@ -import { constructAggregationFn } from './rowAggregationFeature.types' +import type { AggregationFnDef } from './rowAggregationFeature.types' +import type { RowData } from '../../types/type-utils' +import type { TableFeatures } from '../../types/TableFeatures' + +/** + * Creates a typed context-based aggregation definition for a column or + * aggregation-function registry. + */ +/* @__NO_SIDE_EFFECTS__ */ +export function constructAggregationFn< + TFeatures extends TableFeatures = any, + TData extends RowData = any, + TValue = unknown, + TResult = unknown, +>( + definition: AggregationFnDef, +): AggregationFnDef { + return definition +} type RangeValue = Date | number @@ -31,7 +49,7 @@ function toRangeNumber(value: RangeValue): number { * Sums numeric selected-row values. Non-number values contribute zero. As in * the previous API, `NaN` is a number and therefore propagates through the sum. */ -export const aggregationFn_sum = constructAggregationFn< +export const aggregationFn_sum = /* @__PURE__ */ constructAggregationFn< any, any, unknown, @@ -60,7 +78,7 @@ export const aggregationFn_sum = constructAggregationFn< * Finds the minimum numeric or Date value from the selected rows. Invalid value * types are ignored; `NaN` preserves the legacy numeric seeding behavior. */ -export const aggregationFn_min = constructAggregationFn< +export const aggregationFn_min = /* @__PURE__ */ constructAggregationFn< any, any, unknown, @@ -109,7 +127,7 @@ export const aggregationFn_min = constructAggregationFn< * Finds the maximum numeric or Date value from the selected rows. Invalid value * types are ignored; `NaN` preserves the legacy numeric seeding behavior. */ -export const aggregationFn_max = constructAggregationFn< +export const aggregationFn_max = /* @__PURE__ */ constructAggregationFn< any, any, unknown, @@ -159,7 +177,7 @@ export const aggregationFn_max = constructAggregationFn< * Empty inputs return * `[undefined, undefined]`, preserving the previous built-in result shape. */ -export const aggregationFn_extent = constructAggregationFn< +export const aggregationFn_extent = /* @__PURE__ */ constructAggregationFn< any, any, unknown, @@ -232,7 +250,7 @@ export const aggregationFn_extent = constructAggregationFn< * Averages number and number-like row values. Nullish and non-numeric values * are ignored; other values retain the legacy unary-plus coercion behavior. */ -export const aggregationFn_mean = constructAggregationFn< +export const aggregationFn_mean = /* @__PURE__ */ constructAggregationFn< any, any, unknown, @@ -260,7 +278,7 @@ export const aggregationFn_mean = constructAggregationFn< * ignored, matching the `sum`/`min`/`max`/`mean` aggregations. Returns * `undefined` when no numeric values remain. */ -export const aggregationFn_median = constructAggregationFn< +export const aggregationFn_median = /* @__PURE__ */ constructAggregationFn< any, any, unknown, @@ -283,7 +301,7 @@ export const aggregationFn_median = constructAggregationFn< }) /** Collects distinct row values using JavaScript `Set` semantics. */ -export const aggregationFn_unique = constructAggregationFn< +export const aggregationFn_unique = /* @__PURE__ */ constructAggregationFn< any, any, unknown, @@ -300,7 +318,7 @@ export const aggregationFn_unique = constructAggregationFn< }) /** Counts distinct row values using JavaScript `Set` semantics. */ -export const aggregationFn_uniqueCount = constructAggregationFn< +export const aggregationFn_uniqueCount = /* @__PURE__ */ constructAggregationFn< any, any, unknown, @@ -317,7 +335,7 @@ export const aggregationFn_uniqueCount = constructAggregationFn< }) /** Counts rows, independently of the column's values. */ -export const aggregationFn_count = constructAggregationFn< +export const aggregationFn_count = /* @__PURE__ */ constructAggregationFn< any, any, unknown, @@ -335,7 +353,7 @@ export const aggregationFn_count = constructAggregationFn< }) /** Returns the first row's value, including a nullish value. */ -export const aggregationFn_first = constructAggregationFn< +export const aggregationFn_first = /* @__PURE__ */ constructAggregationFn< any, any, unknown, @@ -347,7 +365,7 @@ export const aggregationFn_first = constructAggregationFn< }) /** Returns the last row's value, including a nullish value. */ -export const aggregationFn_last = constructAggregationFn< +export const aggregationFn_last = /* @__PURE__ */ constructAggregationFn< any, any, unknown, diff --git a/packages/table-core/src/features/row-aggregation/rowAggregationFeature.ts b/packages/table-core/src/features/row-aggregation/rowAggregationFeature.ts index 3c69aba06f..5d0bc71d0a 100644 --- a/packages/table-core/src/features/row-aggregation/rowAggregationFeature.ts +++ b/packages/table-core/src/features/row-aggregation/rowAggregationFeature.ts @@ -1,5 +1,6 @@ import { assignPrototypeAPIs } from '../../utils' import { + aggregateColumnValue, cell_getIsAggregated, column_getAggregationFns, column_getAggregationValue, @@ -23,6 +24,13 @@ export const rowAggregationFeature: TableFeature = { manualAggregation: false, }), + initTableInstanceData: (table) => { + // @ts-ignore - _aggregateColumnValue is row aggregation table instance + // data. The grouped row model reads the executor from here so tables that + // group without aggregating don't bundle it. + table._aggregateColumnValue = aggregateColumnValue + }, + assignCellPrototype: (prototype, table) => { assignPrototypeAPIs('rowAggregationFeature', prototype, table, { cell_getIsAggregated: { diff --git a/packages/table-core/src/features/row-aggregation/rowAggregationFeature.types.ts b/packages/table-core/src/features/row-aggregation/rowAggregationFeature.types.ts index d35386a02d..b99e6c6038 100644 --- a/packages/table-core/src/features/row-aggregation/rowAggregationFeature.types.ts +++ b/packages/table-core/src/features/row-aggregation/rowAggregationFeature.types.ts @@ -76,21 +76,6 @@ export interface AggregationFnDef< ) => TResult } -/** - * Creates a typed context-based aggregation definition for a column or - * aggregation-function registry. - */ -export function constructAggregationFn< - TFeatures extends TableFeatures = any, - TData extends RowData = any, - TValue = unknown, - TResult = unknown, ->( - definition: AggregationFnDef, -): AggregationFnDef { - return definition -} - /** Aggregation-definition registry carried by a table feature set. */ export interface RowModelFns_RowAggregation< in out TFeatures extends TableFeatures, diff --git a/packages/table-core/src/features/row-expanding/rowExpandingFeature.types.ts b/packages/table-core/src/features/row-expanding/rowExpandingFeature.types.ts index ea7338abdc..615648a989 100644 --- a/packages/table-core/src/features/row-expanding/rowExpandingFeature.types.ts +++ b/packages/table-core/src/features/row-expanding/rowExpandingFeature.types.ts @@ -73,6 +73,12 @@ export interface Table_RowExpanding< in out _TFeatures extends TableFeatures, in out _TData extends RowData, > { + /** + * Resets expanded state after the table data or grouping changes. + * + * Honors `autoResetAll`, `autoResetExpanded`, and `manualExpanding`. Called + * by the row models; you rarely need to invoke it yourself. + */ autoResetExpanded: () => void /** * Checks whether at least one row can be expanded. diff --git a/packages/table-core/src/features/row-pagination/rowPaginationFeature.types.ts b/packages/table-core/src/features/row-pagination/rowPaginationFeature.types.ts index a713cb8016..734308ac94 100644 --- a/packages/table-core/src/features/row-pagination/rowPaginationFeature.types.ts +++ b/packages/table-core/src/features/row-pagination/rowPaginationFeature.types.ts @@ -48,7 +48,14 @@ export interface Table_RowPagination< in out _TFeatures extends TableFeatures, in out _TData extends RowData, > { - _autoResetPageIndex: () => void + /** + * Resets the page index to `0` after page-altering changes such as new + * data, filtering, sorting, or grouping. + * + * Honors `autoResetAll`, `autoResetPageIndex`, and `manualPagination`. + * Called by the row models; you rarely need to invoke it yourself. + */ + autoResetPageIndex: () => void /** * Checks whether the current page index can move forward. */ diff --git a/packages/table-core/src/features/row-sorting/createSortedRowModel.ts b/packages/table-core/src/features/row-sorting/createSortedRowModel.ts index dee54c1932..0ba7126c57 100644 --- a/packages/table-core/src/features/row-sorting/createSortedRowModel.ts +++ b/packages/table-core/src/features/row-sorting/createSortedRowModel.ts @@ -3,7 +3,7 @@ import { skipFirstRun, tableMemo, } from '../../utils' -import { table_autoResetPageIndex } from '../row-pagination/rowPaginationFeature.utils' +import { autoResetPageIndex } from '../../core/featureStubs' import { column_getCanSort, column_getSortFn } from './rowSortingFeature.utils' import type { Column_Internal } from '../../types/Column' import type { TableFeatures } from '../../types/TableFeatures' @@ -40,7 +40,7 @@ export function createSortedRowModel< table.getPreSortedRowModel(), ], fn: () => _createSortedRowModel(table), - onAfterUpdate: skipFirstRun(() => table_autoResetPageIndex(table)), + onAfterUpdate: skipFirstRun(() => autoResetPageIndex(table)), }) } } diff --git a/packages/table-core/src/features/row-sorting/rowSortingFeature.ts b/packages/table-core/src/features/row-sorting/rowSortingFeature.ts index f9448eea8f..e767d83a2a 100644 --- a/packages/table-core/src/features/row-sorting/rowSortingFeature.ts +++ b/packages/table-core/src/features/row-sorting/rowSortingFeature.ts @@ -17,6 +17,7 @@ import { column_getToggleSortingHandler, column_toggleSorting, getDefaultSortingState, + table_autoResetSorting, table_resetSorting, table_setSorting, } from './rowSortingFeature.utils' @@ -93,6 +94,9 @@ export const rowSortingFeature: TableFeature = { constructTableAPIs(table) { assignTableAPIs('rowSortingFeature', table, { + table_autoResetSorting: { + fn: () => table_autoResetSorting(table), + }, table_setSorting: { fn: (updater) => table_setSorting(table, updater), }, diff --git a/packages/table-core/src/features/row-sorting/rowSortingFeature.types.ts b/packages/table-core/src/features/row-sorting/rowSortingFeature.types.ts index 264e14aed3..a4a08e0227 100644 --- a/packages/table-core/src/features/row-sorting/rowSortingFeature.types.ts +++ b/packages/table-core/src/features/row-sorting/rowSortingFeature.types.ts @@ -267,6 +267,14 @@ export interface Table_RowSorting< in out _TFeatures extends TableFeatures, in out _TData extends RowData, > { + /** + * Resets sorting after the table data changes when explicitly enabled. + * + * Honors `autoResetAll` and `autoResetSorting`; sorting is preserved by + * default. Called by the core row model; you rarely need to invoke it + * yourself. + */ + autoResetSorting: () => void /** * Resets `sorting` to `initialState.sorting`. * diff --git a/packages/table-core/src/features/row-sorting/sortFns.ts b/packages/table-core/src/features/row-sorting/sortFns.ts index 2ac7815c73..5d9a356e00 100644 --- a/packages/table-core/src/features/row-sorting/sortFns.ts +++ b/packages/table-core/src/features/row-sorting/sortFns.ts @@ -35,6 +35,7 @@ export const reSplitAlphaNumeric = /([0-9]+)/gm * }) * ``` */ +/* @__NO_SIDE_EFFECTS__ */ export function constructSortFn< TFeatures extends TableFeatures = any, TData extends RowData = any, @@ -72,7 +73,7 @@ export function constructSortFn< * * This comparator returns ascending-order results; descending order is applied by the sorting row model. */ -export const sortFn_alphanumeric = constructSortFn({ +export const sortFn_alphanumeric = /* @__PURE__ */ constructSortFn({ resolveDataValue: (dataValue) => toString(dataValue).toLowerCase(), sort: (dataValueA, dataValueB) => compareAlphanumeric(dataValueA, dataValueB), }) @@ -82,10 +83,13 @@ export const sortFn_alphanumeric = constructSortFn({ * * This comparator returns ascending-order results; descending order is applied by the sorting row model. */ -export const sortFn_alphanumericCaseSensitive = constructSortFn({ - resolveDataValue: (dataValue) => toString(dataValue), - sort: (dataValueA, dataValueB) => compareAlphanumeric(dataValueA, dataValueB), -}) +export const sortFn_alphanumericCaseSensitive = /* @__PURE__ */ constructSortFn( + { + resolveDataValue: (dataValue) => toString(dataValue), + sort: (dataValueA, dataValueB) => + compareAlphanumeric(dataValueA, dataValueB), + }, +) // The text sort is more basic (less numeric support) // but is much faster @@ -94,7 +98,7 @@ export const sortFn_alphanumericCaseSensitive = constructSortFn({ * * This comparator returns ascending-order results; descending order is applied by the sorting row model. */ -export const sortFn_text = constructSortFn({ +export const sortFn_text = /* @__PURE__ */ constructSortFn({ resolveDataValue: (dataValue) => toString(dataValue).toLowerCase(), sort: (dataValueA, dataValueB) => compareBasic(dataValueA, dataValueB), }) @@ -106,7 +110,7 @@ export const sortFn_text = constructSortFn({ * * This comparator returns ascending-order results; descending order is applied by the sorting row model. */ -export const sortFn_textCaseSensitive = constructSortFn({ +export const sortFn_textCaseSensitive = /* @__PURE__ */ constructSortFn({ resolveDataValue: (dataValue) => toString(dataValue), sort: (dataValueA, dataValueB) => compareBasic(dataValueA, dataValueB), }) @@ -116,7 +120,7 @@ export const sortFn_textCaseSensitive = constructSortFn({ * * This comparator returns ascending-order results; descending order is applied by the sorting row model. */ -export const sortFn_datetime = constructSortFn({ +export const sortFn_datetime = /* @__PURE__ */ constructSortFn({ resolveDataValue: (dataValue) => toDateSortValue(dataValue), // Can handle nullish values // Use > and < because == (and ===) doesn't work with @@ -130,7 +134,7 @@ export const sortFn_datetime = constructSortFn({ * * This comparator returns ascending-order results; descending order is applied by the sorting row model. */ -export const sortFn_basic = constructSortFn({ +export const sortFn_basic = /* @__PURE__ */ constructSortFn({ sort: (dataValueA, dataValueB) => compareBasic(dataValueA, dataValueB), }) diff --git a/packages/table-devtools/src/bundleSizes.ts b/packages/table-devtools/src/bundleSizes.ts new file mode 100644 index 0000000000..55f6583f7e --- /dev/null +++ b/packages/table-devtools/src/bundleSizes.ts @@ -0,0 +1,252 @@ +// Generated by scripts/generate-bundle-sizes.mjs. Do not edit by hand; +// rebuild table-core and run `pnpm size:devtools` to refresh. +// +// Sizes are bytes after minification and brotli compression, the metric +// `pnpm size` (size-limit) reports. `coreFeatures` splits `core` by each +// core feature's standalone size. Other entries are the cost of adding that +// item: features on top of `core`, row models on top of their feature, and +// fns on top of their feature and row model. +// +// `estimate` combines items without double counting shared code. Each item +// is [measured size, size the curve predicts, ...indexes into chunks], where +// a chunk is the minified size of one top-level declaration the item adds. + +export const bundleSizes = { + tableCoreVersion: '9.2.6', + package: 24501, + core: 5499, + coreFeatures: { + coreCellsFeature: 444, + coreColumnsFeature: 1135, + coreHeadersFeature: 1213, + coreRowModelsFeature: 886, + coreRowsFeature: 1106, + coreTablesFeature: 716, + }, + features: { + cellSelectionFeature: 3601, + cellSpanningFeature: 1053, + columnFacetingFeature: 271, + columnFilteringFeature: 899, + columnGroupingFeature: 683, + columnOrderingFeature: 778, + columnPinningFeature: 1506, + columnResizingFeature: 1167, + columnSizingFeature: 1119, + columnVisibilityFeature: 891, + globalFilteringFeature: 467, + rowAggregationFeature: 1211, + rowExpandingFeature: 966, + rowPaginationFeature: 881, + rowPinningFeature: 963, + rowSelectionFeature: 1702, + rowSortingFeature: 1302, + }, + rowModels: { + coreRowModel: 0, + filteredRowModel: 639, + groupedRowModel: 728, + sortedRowModel: 425, + expandedRowModel: 133, + paginatedRowModel: 276, + facetedRowModel: 445, + facetedMinMaxValues: 253, + facetedUniqueValues: 223, + }, + filterFns: { + arrHas: 112, + arrIncludes: 105, + arrIncludesAll: 96, + arrIncludesSome: 104, + between: 240, + betweenInclusive: 238, + empty: 85, + endsWith: 108, + equals: 73, + equalsString: 94, + equalsStringSensitive: 91, + inDateRange: 184, + inNumberRange: 188, + includesString: 108, + includesStringSensitive: 103, + notEmpty: 107, + startsWith: 107, + weakEquals: 78, + }, + sortFns: { + alphanumeric: 442, + alphanumericCaseSensitive: 439, + basic: 2, + datetime: 49, + text: 57, + textCaseSensitive: 52, + }, + aggregationFns: { + count: 63, + extent: 261, + first: 49, + last: 58, + max: 186, + mean: 76, + median: 93, + min: 184, + sum: 83, + unique: 48, + uniqueCount: 47, + }, + estimate: { + coreMinified: 24548, + curve: [0.4061, 0.94], + chunks: [ + 112, 162, 118, 32, 91, 477, 179, 227, 137, 162, 350, 586, 662, 213, 194, + 515, 360, 49, 74, 178, 184, 537, 149, 28, 61, 1037, 163, 195, 909, 168, + 167, 420, 122, 158, 96, 769, 108, 239, 124, 209, 162, 240, 214, 1226, 332, + 347, 567, 136, 143, 630, 342, 391, 861, 414, 52, 38, 43, 25, 27, 324, 322, + 115, 1589, 102, 201, 249, 84, 197, 209, 209, 60, 255, 255, 267, 49, 451, + 230, 171, 68, 105, 107, 311, 222, 115, 117, 44, 115, 158, 86, 91, 112, 62, + 100, 56, 338, 113, 93, 64, 209, 205, 248, 190, 184, 193, 403, 47, 329, + 182, 104, 120, 68, 109, 629, 214, 421, 139, 274, 131, 283, 315, 309, 72, + 171, 138, 320, 314, 332, 132, 126, 135, 217, 211, 220, 147, 141, 150, 302, + 87, 255, 171, 51, 72, 160, 115, 101, 2400, 76, 176, 26, 282, 59, 62, 288, + 315, 96, 140, 140, 174, 199, 156, 110, 167, 170, 161, 66, 305, 103, 102, + 137, 78, 168, 230, 137, 137, 117, 167, 214, 45, 229, 70, 166, 72, 112, 78, + 99, 102, 213, 174, 174, 259, 135, 216, 202, 994, 106, 828, 751, 218, 165, + 58, 188, 64, 271, 167, 112, 93, 120, 242, 245, 534, 153, 69, 134, 137, + 104, 25, 25, 97, 237, 66, 156, 240, 131, 126, 217, 126, 87, 146, 156, 67, + 63, 59, 120, 216, 97, 62, 66, 156, 133, 365, 65, 71, 186, 105, 168, 233, + 449, 178, 62, 72, 182, 457, 370, 68, 210, 222, 219, 88, 251, 295, 122, + 211, 110, 118, 301, 92, 70, 75, 147, 163, 167, 360, 669, 309, 449, 157, + 342, 159, 62, 380, 39, 172, 68, 51, 43, 60, 97, 132, 456, 208, 211, 599, + 147, 232, 121, 119, 120, 99, 98, 183, 61, 67, 1033, 124, 497, 528, 130, + 337, 1419, 67, 1168, 431, 1234, 161, 302, 1252, 290, 1186, 1197, 305, 230, + 184, 1147, 316, 532, 648, 316, 344, 480, 335, 480, 334, 155, 218, 202, + 202, 160, 161, 80, 322, 169, 105, 65, 208, 90, 201, 182, 373, 128, 436, + 195, 109, 212, 93, 128, 112, 411, 40, 99, 187, 343, 93, 122, 127, 107, 67, + 113, 112, 910, 44, 47, 77, 86, 107, 65, + ], + features: { + cellSelectionFeature: [ + 3601, 3423, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, + 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, + 53, + ], + cellSpanningFeature: [ + 1053, 658, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, + ], + columnFacetingFeature: [271, 302, 67, 68, 69, 70, 71, 72, 73], + columnFilteringFeature: [ + 899, 657, 0, 1, 2, 3, 4, 5, 6, 54, 74, 75, 76, 77, 78, 79, 80, 81, 82, + 83, 84, + ], + columnGroupingFeature: [ + 683, 527, 0, 1, 2, 3, 4, 5, 6, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, + 95, 96, + ], + columnOrderingFeature: [ + 778, 868, 0, 1, 2, 3, 4, 5, 6, 7, 8, 97, 98, 99, 100, 101, 102, 103, + 104, 105, 106, 107, 108, 109, 110, 111, + ], + columnPinningFeature: [ + 1506, 1870, 0, 1, 2, 3, 4, 5, 6, 7, 8, 97, 98, 99, 100, 101, 102, 103, + 104, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, + 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, + ], + columnResizingFeature: [ + 1167, 858, 0, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, + 148, 149, 150, + ], + columnSizingFeature: [ + 1119, 1231, 0, 7, 8, 97, 98, 99, 100, 101, 102, 103, 104, 124, 125, 126, + 137, 138, 139, 140, 141, 151, 152, 153, 154, 155, 156, 157, 158, 159, + 160, 161, 162, 163, + ], + columnVisibilityFeature: [ + 891, 833, 0, 1, 2, 3, 4, 5, 6, 7, 8, 97, 112, 113, 164, 165, 166, 167, + 168, 169, 170, 171, 172, 173, 174, + ], + globalFilteringFeature: [ + 467, 258, 0, 54, 175, 176, 177, 178, 179, 180, 181, 182, + ], + rowAggregationFeature: [ + 1211, 976, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, + 195, 196, 197, 198, + ], + rowExpandingFeature: [ + 966, 800, 0, 1, 2, 3, 4, 5, 6, 199, 200, 201, 202, 203, 204, 205, 206, + 207, 208, 209, 210, 211, 212, 213, 214, + ], + rowPaginationFeature: [ + 881, 752, 0, 1, 2, 3, 4, 5, 6, 215, 216, 217, 218, 219, 220, 221, 222, + 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, + ], + rowPinningFeature: [ + 963, 744, 0, 1, 2, 3, 4, 5, 6, 210, 211, 213, 235, 236, 237, 238, 239, + 240, 241, 242, 243, 244, 245, 246, + ], + rowSelectionFeature: [ + 1702, 1497, 0, 247, 248, 249, 250, 251, 252, 253, 254, 255, 256, 257, + 258, 259, 260, 261, 262, 263, 264, 265, 266, 267, 268, 269, 270, 271, + 272, 273, 274, 275, 276, 277, 278, 279, + ], + rowSortingFeature: [ + 1302, 927, 0, 1, 2, 3, 4, 5, 6, 54, 280, 281, 282, 283, 284, 285, 286, + 287, 288, 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, + ], + }, + rowModels: { + coreRowModel: [0, 0], + filteredRowModel: [639, 863, 300, 301, 302, 303, 304, 305, 306, 307, 308], + groupedRowModel: [728, 803, 185, 187, 189, 309, 310, 311, 312, 313, 314], + sortedRowModel: [425, 597, 247, 315, 316, 317], + expandedRowModel: [133, 395, 318, 319, 320, 321], + paginatedRowModel: [276, 494, 210, 211, 321, 322, 323, 324], + facetedRowModel: [445, 535, 303, 304, 305, 306, 325, 326, 327], + facetedMinMaxValues: [253, 350, 178, 325, 328, 329], + facetedUniqueValues: [223, 350, 178, 325, 330, 331], + }, + filterFns: { + arrHas: [112, 75, 175, 177, 332], + arrIncludes: [105, 88, 175, 177, 333], + arrIncludesAll: [96, 85, 175, 177, 334], + arrIncludesSome: [104, 85, 175, 177, 335], + between: [240, 191, 175, 177, 336, 337, 338, 339], + betweenInclusive: [238, 193, 175, 177, 337, 338, 339, 340], + empty: [85, 78, 175, 177, 341, 342], + endsWith: [108, 86, 175, 177, 343], + equals: [73, 62, 175, 177, 344], + equalsString: [94, 85, 175, 177, 345], + equalsStringSensitive: [91, 81, 175, 177, 346], + inDateRange: [184, 146, 175, 177, 347, 348], + inNumberRange: [188, 133, 175, 177, 349], + includesString: [108, 87, 175, 176, 177], + includesStringSensitive: [103, 83, 175, 177, 350], + notEmpty: [107, 79, 175, 177, 342, 351], + startsWith: [107, 87, 175, 177, 352], + weakEquals: [78, 62, 175, 177, 353], + }, + sortFns: { + alphanumeric: [442, 314, 354, 355, 356, 357, 358, 359, 360, 361, 362], + alphanumericCaseSensitive: [ + 439, 314, 355, 356, 357, 358, 359, 360, 361, 362, 363, + ], + basic: [2, 0], + datetime: [49, 36, 364, 365], + text: [57, 46, 355, 366], + textCaseSensitive: [52, 46, 355, 367], + }, + aggregationFns: { + count: [63, 206, 368, 369, 370], + extent: [261, 275, 368, 369, 370, 371, 372, 373, 374], + first: [49, 196, 368, 369], + last: [58, 196, 368, 369], + max: [186, 275, 368, 369, 370, 371, 372, 373, 374], + mean: [76, 196, 368, 369], + median: [93, 196, 368, 369], + min: [184, 275, 368, 369, 370, 371, 372, 373, 374], + sum: [83, 206, 368, 369, 370], + unique: [48, 196, 368, 369], + uniqueCount: [47, 196, 368, 369], + }, + }, +} diff --git a/packages/table-devtools/src/components/FeaturesPanel.tsx b/packages/table-devtools/src/components/FeaturesPanel.tsx index 0446eede7b..430ee64061 100644 --- a/packages/table-devtools/src/components/FeaturesPanel.tsx +++ b/packages/table-devtools/src/components/FeaturesPanel.tsx @@ -1,20 +1,48 @@ import { For, Show, createMemo } from 'solid-js' -import { coreFeatures, stockFeatures } from '@tanstack/table-core' +import { + aggregationFns as builtInAggregationFns, + filterFns as builtInFilterFns, + sortFns as builtInSortFns, + coreFeatures, + stockFeatures, +} from '@tanstack/table-core' import { useTableDevtoolsContext } from '../TableContextProvider' import { useTableStore } from '../useTableStore' import { useStyles } from '../styles/use-styles' +import { bundleSizes } from '../bundleSizes' +import { estimateBundleSize } from '../estimateBundleSize' import { NoTableConnected } from './NoTableConnected' import { ResizableSplit } from './ResizableSplit' +import type { BundleSizeSelection } from '../estimateBundleSize' import type { TableDevtoolsTable } from '../tableTarget' -type FnBuckets = Partial< - Record<'filterFns' | 'sortFns' | 'aggregationFns', Record> -> +type FnKind = 'filterFns' | 'sortFns' | 'aggregationFns' + +type FnBuckets = Partial>> + +interface RegisteredFn { + name: string + /** The built-in this function is, when it is one */ + builtInName?: string +} function toFnBuckets(value: unknown): FnBuckets { return typeof value === 'object' && value != null ? value : {} } +/** Maps each built-in fn back to its registry name to size registered fns */ +const BUILT_IN_FN_NAMES: Record> = { + filterFns: new Map( + Object.entries(builtInFilterFns).map(([name, fn]) => [fn, name]), + ), + sortFns: new Map( + Object.entries(builtInSortFns).map(([name, fn]) => [fn, name]), + ), + aggregationFns: new Map( + Object.entries(builtInAggregationFns).map(([name, fn]) => [fn, name]), + ), +} + const CORE_REACTIVITY_FEATURE_NAME = 'coreReactivityFeature' const CORE_FEATURE_NAMES: Array = [ @@ -25,39 +53,13 @@ const CORE_FEATURE_NAMES: Array = [ ] const STOCK_FEATURE_NAMES: Array = Object.keys(stockFeatures) -const PACKAGE_SIZE_LIMIT_BYTES = 16_987 - -const FEATURE_SIZE_ESTIMATES_BYTES: Record = { - rowAggregationFeature: 1500, - coreCellsFeature: 358, - coreColumnsFeature: 803, - coreHeadersFeature: 1012, - coreRowModelsFeature: 633, - coreRowsFeature: 695, - coreTablesFeature: 508, - columnFacetingFeature: 953, - columnFilteringFeature: 1266, - columnGroupingFeature: 1141, - columnOrderingFeature: 511, - columnPinningFeature: 995, - columnResizingFeature: 779, - columnSizingFeature: 678, - columnVisibilityFeature: 612, - globalFilteringFeature: 435, - rowExpandingFeature: 650, - rowPaginationFeature: 605, - rowPinningFeature: 671, - rowSelectionFeature: 883, - rowSortingFeature: 798, -} - -const ROW_MODEL_SIZE_ESTIMATES_BYTES: Record = { - coreRowModel: 223, - filteredRowModel: 588, - groupedRowModel: 459, - sortedRowModel: 341, - expandedRowModel: 181, - paginatedRowModel: 209, +const CORE_FEATURE_SIZES: Record = bundleSizes.coreFeatures +const FEATURE_SIZES: Record = bundleSizes.features +const ROW_MODEL_SIZES: Record = bundleSizes.rowModels +const FN_SIZES: Record> = { + filterFns: bundleSizes.filterFns, + sortFns: bundleSizes.sortFns, + aggregationFns: bundleSizes.aggregationFns, } // Row model factories live as slots on the `features` option alongside the @@ -80,10 +82,7 @@ const ROW_MODEL_SHARED_SIZE_LABELS: Record = { preSortedRowModel: 'shared', } -const ROW_MODEL_TO_FN_KIND: Record< - string, - 'filterFns' | 'sortFns' | 'aggregationFns' | null -> = { +const ROW_MODEL_TO_FN_KIND: Record = { filteredRowModel: 'filterFns', preFilteredRowModel: 'filterFns', sortedRowModel: 'sortFns', @@ -137,12 +136,16 @@ function getRowCountForModel( return result.rows?.length ?? 0 } -function formatEstimatedSize(sizeInBytes: number | undefined): string { +function formatSize(sizeInBytes: number | undefined): string { if (typeof sizeInBytes !== 'number') return 'n/a' - return `~${(sizeInBytes / 1000).toFixed(2)} kB brotli` + return `${(sizeInBytes / 1000).toFixed(2)} kB` } -function normalizeRowModelEstimateKey(rowModelName: string): string { +function formatRegisteredFnSize(kind: FnKind, fn: RegisteredFn): string { + return fn.builtInName ? formatSize(FN_SIZES[kind][fn.builtInName]) : 'custom' +} + +function normalizeRowModelSizeKey(rowModelName: string): string { if (rowModelName === 'preFilteredRowModel') return 'filteredRowModel' if (rowModelName === 'preGroupedRowModel') return 'groupedRowModel' if (rowModelName === 'preSortedRowModel') return 'sortedRowModel' @@ -180,9 +183,7 @@ export function FeaturesPanel() { ) }) - const getFnNames = ( - kind: 'filterFns' | 'sortFns' | 'aggregationFns', - ): Array => { + const getRegisteredFns = (kind: FnKind): Array => { const tableInstance = table() if (!tableInstance) return [] @@ -190,7 +191,11 @@ export function FeaturesPanel() { const rowModelFns = toFnBuckets(tableInstance._rowModelFns) const optionFns = toFnBuckets(tableInstance.options) - return Object.keys(rowModelFns[kind] ?? optionFns[kind] ?? {}) + const registry = rowModelFns[kind] ?? optionFns[kind] ?? {} + return Object.entries(registry).map(([name, fn]) => ({ + name, + builtInName: BUILT_IN_FN_NAMES[kind].get(fn), + })) } const additionalPlugins = createMemo((): Array => { @@ -202,32 +207,53 @@ export function FeaturesPanel() { return [...currentFeatures].filter((f) => !knownFeatures.has(f)).sort() }) - const aggregationFunctionNames = createMemo(() => - getFnNames('aggregationFns'), - ) + const registeredFns = createMemo((): Record> => ({ + filterFns: getRegisteredFns('filterFns'), + sortFns: getRegisteredFns('sortFns'), + aggregationFns: getRegisteredFns('aggregationFns'), + })) - const getRowModelFunctions = (rowModelName: string): Array => { + const getRowModelFunctions = (rowModelName: string): Array => { const fnKind = ROW_MODEL_TO_FN_KIND[rowModelName] if (!fnKind) return [] - return getFnNames(fnKind) + return registeredFns()[fnKind] } - const enabledFeatureEstimate = createMemo(() => - [...tableFeatures()].reduce((total, featureName) => { - return total + (FEATURE_SIZE_ESTIMATES_BYTES[featureName] ?? 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(), - ) + // Estimates stack up cumulatively (core, then features, then row models, + // then fns) so the breakdown rows add up to the total even though items + // share code + const sizeEstimate = createMemo(() => { + const builtInNames = (kind: FnKind) => + registeredFns()[kind].flatMap((fn) => + fn.builtInName ? [fn.builtInName] : [], + ) + const withFeatures: BundleSizeSelection = { + features: [...tableFeatures()], + } + const withRowModels: BundleSizeSelection = { + ...withFeatures, + rowModels: rowModelNames(), + } + const withFns: BundleSizeSelection = { + ...withRowModels, + filterFns: builtInNames('filterFns'), + sortFns: builtInNames('sortFns'), + aggregationFns: builtInNames('aggregationFns'), + } + + const core = bundleSizes.core + const features = estimateBundleSize(withFeatures) + const rowModels = estimateBundleSize(withRowModels) + const total = estimateBundleSize(withFns) + + return { + core, + features: features - core, + rowModels: rowModels - features, + fns: total - rowModels, + total, + } + }) const rowModels = createMemo(() => { const tableInstance = table() @@ -237,18 +263,16 @@ export function FeaturesPanel() { return rowModelNames().map((rowModelName) => { const sharedLabel = ROW_MODEL_SHARED_SIZE_LABELS[rowModelName] + const fnKind = ROW_MODEL_TO_FN_KIND[rowModelName] ?? null return { rowModelName, + fnKind, fns: getRowModelFunctions(rowModelName), rowCount: getRowCountForModel(tableInstance, rowModelName), - estimateLabel: + sizeLabel: sharedLabel ?? - formatEstimatedSize( - ROW_MODEL_SIZE_ESTIMATES_BYTES[ - normalizeRowModelEstimateKey(rowModelName) - ], - ), + formatSize(ROW_MODEL_SIZES[normalizeRowModelSizeKey(rowModelName)]), } }) }) @@ -276,23 +300,33 @@ export function FeaturesPanel() {
Features
- Estimated table-core package + Estimated @tanstack/table-core bundle +
+
+ Core + {formatSize(sizeEstimate().core)}
Registered features - {formatEstimatedSize(enabledFeatureEstimate())} + +{formatSize(sizeEstimate().features)}
Client row models - {formatEstimatedSize(enabledRowModelEstimate())} + +{formatSize(sizeEstimate().rowModels)} +
+
+ Built-in fns + +{formatSize(sizeEstimate().fns)}
Total - {formatEstimatedSize(totalEstimatedBundleSize())} + {formatSize(sizeEstimate().total)}
- Allocated from the current `size-limit` metric: minified and - Brotli-compressed. + Minified + brotli, the metric `pnpm size` reports, measured + for v{bundleSizes.tableCoreVersion}. Each item shows what it + adds on its own; the total counts code that items share once. + Excludes the framework adapter and custom features.
@@ -303,7 +337,9 @@ export function FeaturesPanel() { renderFeatureItem( name, tableFeatures().has(name), - formatEstimatedSize(FEATURE_SIZE_ESTIMATES_BYTES[name]), + name === CORE_REACTIVITY_FEATURE_NAME + ? 'adapter' + : formatSize(CORE_FEATURE_SIZES[name]), ) } @@ -318,7 +354,7 @@ export function FeaturesPanel() { renderFeatureItem( name, tableFeatures().has(name), - formatEstimatedSize(FEATURE_SIZE_ESTIMATES_BYTES[name]), + `+${formatSize(FEATURE_SIZES[name])}`, ) } @@ -349,12 +385,19 @@ export function FeaturesPanel() { {rowModel.rowModelName} - {rowModel.rowCount} rows, {rowModel.estimateLabel} + {rowModel.rowCount} rows, {rowModel.sizeLabel} - {(fnName) => ( -
{fnName}
+ {(fn) => ( +
+ {fn.name} + + {rowModel.fnKind + ? formatRegisteredFnSize(rowModel.fnKind, fn) + : ''} + +
)}
@@ -365,12 +408,17 @@ export function FeaturesPanel() {
aggregationFns - {aggregationFunctionNames().length} registered + {registeredFns().aggregationFns.length} registered
- - {(fnName) => ( -
{fnName}
+ + {(fn) => ( +
+ {fn.name} + + {formatRegisteredFnSize('aggregationFns', fn)} + +
)}
@@ -381,8 +429,7 @@ export function FeaturesPanel() { )}
- Full package reference:{' '} - {formatEstimatedSize(PACKAGE_SIZE_LIMIT_BYTES)} + Full package: {formatSize(bundleSizes.package)}
diff --git a/packages/table-devtools/src/estimateBundleSize.ts b/packages/table-devtools/src/estimateBundleSize.ts new file mode 100644 index 0000000000..90d2e071c4 --- /dev/null +++ b/packages/table-devtools/src/estimateBundleSize.ts @@ -0,0 +1,85 @@ +import { bundleSizes } from './bundleSizes' + +type BundleSizeGroup = + 'features' | 'rowModels' | 'filterFns' | 'sortFns' | 'aggregationFns' + +export type BundleSizeSelection = Partial< + Record> +> + +interface BundleSizeEstimateData { + core: number + estimate: { + coreMinified: number + curve: ReadonlyArray + chunks: ReadonlyArray + } & Partial>>> +} + +const BUNDLE_SIZE_GROUPS: ReadonlyArray = [ + 'features', + 'rowModels', + 'filterFns', + 'sortFns', + 'aggregationFns', +] + +/** + * Estimates the brotli size of table-core for a set of registered features, + * row models, and built-in fns. + * + * Per-item sizes overlap because items share code, so summing them + * overcounts. Instead, take the union of the declarations the items add + * (shared code counts once), convert that minified size to brotli with the + * measured size curve, and scale by how the selected items compress compared + * to the curve's prediction for them. Unknown names (custom features or fns) + * are ignored. + */ +export function estimateBundleSize( + selection: BundleSizeSelection, + data: BundleSizeEstimateData = bundleSizes, +): number { + const { core } = data + const { chunks, coreMinified, curve } = data.estimate + const [coefficient = 0, exponent = 1] = curve + + function curveSize(minified: number) { + return coefficient * minified ** exponent + } + + const unionChunks = new Set() + let measured = 0 + let predicted = 0 + + for (const group of BUNDLE_SIZE_GROUPS) { + const items = data.estimate[group] + const names = selection[group] + if (!items || !names) continue + + for (const name of new Set(names)) { + // [measured size, size the curve predicts, ...chunk indexes] + const item = items[name] + if (!item) continue + + measured += item[0] ?? 0 + predicted += item[1] ?? 0 + for (let i = 2; i < item.length; i++) { + unionChunks.add(item[i]!) + } + } + } + + if (!measured || !predicted) return core + measured + + let unionMinified = 0 + for (const chunk of unionChunks) { + unionMinified += chunks[chunk] ?? 0 + } + + const compressionScale = measured / predicted + return Math.round( + core + + (curveSize(coreMinified + unionMinified) - curveSize(coreMinified)) * + compressionScale, + ) +} diff --git a/packages/table-devtools/src/styles/use-styles.ts b/packages/table-devtools/src/styles/use-styles.ts index 254e42979c..56ac42cfc2 100644 --- a/packages/table-devtools/src/styles/use-styles.ts +++ b/packages/table-devtools/src/styles/use-styles.ts @@ -239,6 +239,9 @@ const stylesFactory = (theme: 'light' | 'dark') => { color: ${t(colors.gray[700], colors.gray[200])}; `, rowModelFnItem: css` + display: flex; + align-items: baseline; + gap: ${size[2]}; padding: ${size[1]} 0 ${size[1]} ${size[4]}; font-size: ${font.size.sm}; font-family: ${font.family.mono}; diff --git a/packages/table-devtools/tests/FeaturesPanel.test.tsx b/packages/table-devtools/tests/FeaturesPanel.test.tsx new file mode 100644 index 0000000000..be5563f6b9 --- /dev/null +++ b/packages/table-devtools/tests/FeaturesPanel.test.tsx @@ -0,0 +1,81 @@ +import { render } from 'solid-js/web' +import { afterEach, describe, expect, it } from 'vitest' +import { + constructFilterFn, + constructTable, + columnFilteringFeature, + createFilteredRowModel, + createSortedRowModel, + filterFn_includesString, + rowSortingFeature, + sortFn_alphanumeric, +} from '@tanstack/table-core' +import { storeReactivityBindings } from '@tanstack/table-core/store-reactivity-bindings' +import TableDevtools from '../src/TableDevtools' +import { bundleSizes } from '../src/bundleSizes' +import { estimateBundleSize } from '../src/estimateBundleSize' +import { + getTableDevtoolsTargets, + removeTableDevtoolsTarget, + upsertTableDevtoolsTarget, +} from '../src/tableTarget' + +function kB(bytes: number) { + return `${(bytes / 1000).toFixed(2)} kB` +} + +afterEach(() => { + for (const target of getTableDevtoolsTargets()) { + removeTableDevtoolsTarget(target.id) + } +}) + +describe('FeaturesPanel', () => { + it('shows measured sizes for registered items and a shared-code-aware total', async () => { + const startsWithA = constructFilterFn({ + filter: (value: unknown) => String(value).startsWith('a'), + }) + const table = constructTable({ + key: 'sized-table', + features: { + coreReactivityFeature: storeReactivityBindings(), + columnFilteringFeature, + rowSortingFeature, + filteredRowModel: createFilteredRowModel(), + sortedRowModel: createSortedRowModel(), + filterFns: { includesString: filterFn_includesString, startsWithA }, + sortFns: { alphanumeric: sortFn_alphanumeric }, + }, + columns: [{ accessorKey: 'name' }], + data: [{ name: 'a' }, { name: 'b' }], + }) + const cleanupTarget = upsertTableDevtoolsTarget({ table: table as never }) + + const element = document.createElement('div') + const dispose = render(() => , element) + await Promise.resolve() + const text = element.textContent + + expect(text).toContain(`+${kB(bundleSizes.features.rowSortingFeature)}`) + expect(text).toContain( + `2 rows, ${kB(bundleSizes.rowModels.filteredRowModel)}`, + ) + expect(text).toContain( + `includesString${kB(bundleSizes.filterFns.includesString)}`, + ) + expect(text).toContain('startsWithAcustom') + expect(text).toContain( + `Total${kB( + estimateBundleSize({ + features: ['columnFilteringFeature', 'rowSortingFeature'], + rowModels: ['filteredRowModel', 'sortedRowModel'], + filterFns: ['includesString'], + sortFns: ['alphanumeric'], + }), + )}`, + ) + + dispose() + cleanupTarget?.() + }) +}) diff --git a/packages/table-devtools/tests/estimateBundleSize.test.ts b/packages/table-devtools/tests/estimateBundleSize.test.ts new file mode 100644 index 0000000000..528b2e7fcc --- /dev/null +++ b/packages/table-devtools/tests/estimateBundleSize.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { bundleSizes } from '../src/bundleSizes' +import { estimateBundleSize } from '../src/estimateBundleSize' +import validation from './fixtures/bundleSizeValidation.json' +import type { BundleSizeSelection } from '../src/estimateBundleSize' + +describe('estimateBundleSize', () => { + it('returns the core size for an empty selection', () => { + expect(estimateBundleSize({})).toBe(bundleSizes.core) + }) + + it('ignores core, custom, and unknown names', () => { + expect( + estimateBundleSize({ + features: ['coreRowsFeature', 'myCustomFeature'], + filterFns: ['myCustomFilter'], + }), + ).toBe(bundleSizes.core) + }) + + it('adds a single feature at its measured size', () => { + const size = bundleSizes.features.rowSortingFeature + const estimate = estimateBundleSize({ features: ['rowSortingFeature'] }) + + expect(Math.abs(estimate - (bundleSizes.core + size))).toBeLessThan( + size * 0.02, + ) + }) + + it('counts code shared between items once', () => { + const data = { + core: 1000, + estimate: { + coreMinified: 4000, + curve: [1, 1], + chunks: [100, 200, 300], + features: { + a: [75, 300, 0, 1], + b: [125, 500, 1, 2], + }, + }, + } + + // Each item measures at 25% of its minified size, so the union of + // chunks 0-2 (600 bytes) lands at 150 bytes, not the 200 bytes a sum of + // the two items would give + expect(estimateBundleSize({ features: ['a'] }, data)).toBe(1075) + expect(estimateBundleSize({ features: ['a', 'b'] }, data)).toBe(1150) + }) + + it('stays close to real bundles of random item combinations', () => { + for (const { selection, actual } of validation) { + const estimate = estimateBundleSize(selection as BundleSizeSelection) + expect(Math.abs(estimate / actual - 1)).toBeLessThan(0.05) + } + }) +}) diff --git a/packages/table-devtools/tests/fixtures/bundleSizeValidation.json b/packages/table-devtools/tests/fixtures/bundleSizeValidation.json new file mode 100644 index 0000000000..a53261c35f --- /dev/null +++ b/packages/table-devtools/tests/fixtures/bundleSizeValidation.json @@ -0,0 +1,524 @@ +[ + { + "selection": { + "rowModels": [ + "groupedRowModel", + "facetedMinMaxValues", + "filteredRowModel" + ], + "filterFns": ["inNumberRange"], + "aggregationFns": ["min"], + "features": [ + "columnGroupingFeature", + "columnFacetingFeature", + "columnFilteringFeature", + "rowAggregationFeature" + ] + }, + "actual": 9927 + }, + { + "selection": { + "features": [ + "columnOrderingFeature", + "rowPaginationFeature", + "columnFilteringFeature", + "rowSortingFeature", + "rowAggregationFeature" + ], + "rowModels": ["paginatedRowModel", "filteredRowModel", "sortedRowModel"], + "filterFns": ["equals", "equalsStringSensitive", "weakEquals"], + "sortFns": ["text"], + "aggregationFns": ["extent", "sum"] + }, + "actual": 11216 + }, + { + "selection": { + "features": [ + "globalFilteringFeature", + "rowAggregationFeature", + "rowPinningFeature", + "columnFilteringFeature" + ], + "filterFns": [ + "empty", + "equalsString", + "notEmpty", + "startsWith", + "weakEquals" + ], + "aggregationFns": ["count", "max", "median"], + "rowModels": ["filteredRowModel"] + }, + "actual": 9537 + }, + { + "selection": { + "features": [ + "cellSpanningFeature", + "columnFilteringFeature", + "columnOrderingFeature", + "columnPinningFeature", + "columnResizingFeature", + "columnVisibilityFeature", + "rowPaginationFeature", + "rowSortingFeature", + "rowExpandingFeature", + "rowAggregationFeature" + ], + "rowModels": [ + "coreRowModel", + "sortedRowModel", + "expandedRowModel", + "filteredRowModel" + ], + "filterFns": [ + "arrIncludesSome", + "betweenInclusive", + "endsWith", + "equalsStringSensitive", + "inDateRange" + ], + "sortFns": ["alphanumericCaseSensitive"], + "aggregationFns": ["first", "mean", "sum"] + }, + "actual": 15586 + }, + { + "selection": { + "features": [ + "cellSelectionFeature", + "cellSpanningFeature", + "columnFacetingFeature", + "columnFilteringFeature", + "columnSizingFeature", + "rowExpandingFeature", + "rowSelectionFeature", + "columnGroupingFeature", + "rowSortingFeature", + "rowAggregationFeature" + ], + "rowModels": [ + "coreRowModel", + "groupedRowModel", + "sortedRowModel", + "facetedUniqueValues", + "filteredRowModel" + ], + "filterFns": [ + "arrHas", + "betweenInclusive", + "equalsString", + "equalsStringSensitive", + "includesString", + "includesStringSensitive", + "notEmpty", + "weakEquals" + ], + "sortFns": ["alphanumericCaseSensitive", "basic", "datetime"], + "aggregationFns": ["extent", "first", "last", "mean", "median", "sum"] + }, + "actual": 19192 + }, + { + "selection": { + "features": [ + "cellSpanningFeature", + "columnFilteringFeature", + "columnPinningFeature", + "columnResizingFeature", + "columnSizingFeature", + "globalFilteringFeature", + "rowAggregationFeature", + "rowExpandingFeature", + "rowPinningFeature", + "rowSortingFeature", + "columnFacetingFeature" + ], + "rowModels": [ + "filteredRowModel", + "sortedRowModel", + "expandedRowModel", + "facetedMinMaxValues" + ], + "filterFns": [ + "between", + "empty", + "equalsStringSensitive", + "includesString", + "notEmpty", + "startsWith", + "weakEquals" + ], + "sortFns": ["basic", "datetime", "text"], + "aggregationFns": ["first", "max", "mean", "sum"] + }, + "actual": 15827 + }, + { + "selection": { + "features": [ + "columnFilteringFeature", + "columnGroupingFeature", + "columnOrderingFeature", + "columnPinningFeature", + "columnSizingFeature", + "rowPaginationFeature", + "rowSelectionFeature", + "rowSortingFeature", + "columnFacetingFeature", + "rowAggregationFeature" + ], + "rowModels": [ + "filteredRowModel", + "sortedRowModel", + "facetedRowModel", + "facetedMinMaxValues", + "facetedUniqueValues" + ], + "filterFns": [ + "arrHas", + "empty", + "endsWith", + "equals", + "equalsString", + "equalsStringSensitive", + "inDateRange", + "includesString", + "notEmpty", + "startsWith", + "weakEquals" + ], + "sortFns": ["alphanumeric", "alphanumericCaseSensitive", "datetime"], + "aggregationFns": [ + "first", + "last", + "max", + "mean", + "median", + "min", + "unique" + ] + }, + "actual": 15789 + }, + { + "selection": { + "features": [ + "cellSelectionFeature", + "cellSpanningFeature", + "columnFacetingFeature", + "columnFilteringFeature", + "columnPinningFeature", + "columnResizingFeature", + "columnSizingFeature", + "columnVisibilityFeature", + "rowPaginationFeature", + "rowPinningFeature", + "columnGroupingFeature", + "rowSortingFeature", + "rowAggregationFeature" + ], + "rowModels": [ + "groupedRowModel", + "sortedRowModel", + "paginatedRowModel", + "facetedUniqueValues", + "filteredRowModel" + ], + "filterFns": [ + "arrHas", + "arrIncludes", + "arrIncludesAll", + "betweenInclusive", + "endsWith", + "equals", + "equalsString", + "inNumberRange", + "notEmpty" + ], + "sortFns": [ + "alphanumeric", + "basic", + "datetime", + "text", + "textCaseSensitive" + ], + "aggregationFns": [ + "count", + "extent", + "first", + "last", + "max", + "mean", + "min" + ] + }, + "actual": 20663 + }, + { + "selection": { + "features": [ + "cellSelectionFeature", + "columnFacetingFeature", + "columnFilteringFeature", + "columnOrderingFeature", + "columnPinningFeature", + "columnResizingFeature", + "columnSizingFeature", + "columnVisibilityFeature", + "rowAggregationFeature", + "rowExpandingFeature", + "rowPinningFeature", + "rowSelectionFeature", + "rowSortingFeature", + "rowPaginationFeature" + ], + "rowModels": [ + "coreRowModel", + "filteredRowModel", + "sortedRowModel", + "expandedRowModel", + "paginatedRowModel", + "facetedRowModel", + "facetedMinMaxValues" + ], + "filterFns": [ + "arrHas", + "arrIncludes", + "arrIncludesAll", + "arrIncludesSome", + "betweenInclusive", + "empty", + "equals", + "equalsString", + "inDateRange", + "includesString", + "startsWith" + ], + "sortFns": ["alphanumeric", "text", "textCaseSensitive"], + "aggregationFns": [ + "count", + "extent", + "max", + "mean", + "min", + "sum", + "unique", + "uniqueCount" + ] + }, + "actual": 21215 + }, + { + "selection": { + "features": [ + "cellSelectionFeature", + "cellSpanningFeature", + "columnFacetingFeature", + "columnFilteringFeature", + "columnGroupingFeature", + "columnPinningFeature", + "columnResizingFeature", + "columnSizingFeature", + "columnVisibilityFeature", + "globalFilteringFeature", + "rowAggregationFeature", + "rowExpandingFeature", + "rowPaginationFeature", + "rowPinningFeature", + "rowSelectionFeature", + "rowSortingFeature" + ], + "rowModels": [ + "coreRowModel", + "filteredRowModel", + "groupedRowModel", + "sortedRowModel", + "expandedRowModel", + "paginatedRowModel", + "facetedRowModel", + "facetedUniqueValues" + ], + "filterFns": [ + "arrHas", + "arrIncludes", + "arrIncludesAll", + "arrIncludesSome", + "between", + "betweenInclusive", + "empty", + "endsWith", + "equals", + "equalsString", + "equalsStringSensitive", + "inDateRange", + "includesString", + "includesStringSensitive", + "startsWith", + "weakEquals" + ], + "sortFns": [ + "alphanumericCaseSensitive", + "basic", + "datetime", + "text", + "textCaseSensitive" + ], + "aggregationFns": [ + "extent", + "first", + "max", + "mean", + "median", + "min", + "unique", + "uniqueCount" + ] + }, + "actual": 23038 + }, + { + "selection": { + "features": [ + "cellSelectionFeature", + "cellSpanningFeature", + "columnFacetingFeature", + "columnFilteringFeature", + "columnPinningFeature", + "columnResizingFeature", + "columnSizingFeature", + "globalFilteringFeature", + "rowAggregationFeature", + "rowExpandingFeature", + "rowPinningFeature", + "rowSelectionFeature", + "columnGroupingFeature", + "rowSortingFeature", + "rowPaginationFeature" + ], + "rowModels": [ + "coreRowModel", + "filteredRowModel", + "groupedRowModel", + "sortedRowModel", + "expandedRowModel", + "paginatedRowModel", + "facetedRowModel", + "facetedMinMaxValues", + "facetedUniqueValues" + ], + "filterFns": [ + "arrHas", + "arrIncludes", + "arrIncludesAll", + "arrIncludesSome", + "betweenInclusive", + "empty", + "endsWith", + "equals", + "equalsString", + "equalsStringSensitive", + "inDateRange", + "inNumberRange", + "includesString", + "includesStringSensitive", + "notEmpty", + "startsWith", + "weakEquals" + ], + "sortFns": [ + "alphanumeric", + "alphanumericCaseSensitive", + "basic", + "datetime", + "text", + "textCaseSensitive" + ], + "aggregationFns": [ + "first", + "last", + "max", + "mean", + "median", + "min", + "sum", + "unique" + ] + }, + "actual": 22794 + }, + { + "selection": { + "features": [ + "cellSelectionFeature", + "columnFacetingFeature", + "columnFilteringFeature", + "columnGroupingFeature", + "columnOrderingFeature", + "columnPinningFeature", + "columnResizingFeature", + "columnSizingFeature", + "columnVisibilityFeature", + "globalFilteringFeature", + "rowAggregationFeature", + "rowExpandingFeature", + "rowPaginationFeature", + "rowPinningFeature", + "rowSelectionFeature", + "rowSortingFeature" + ], + "rowModels": [ + "coreRowModel", + "filteredRowModel", + "groupedRowModel", + "sortedRowModel", + "expandedRowModel", + "paginatedRowModel", + "facetedRowModel", + "facetedUniqueValues" + ], + "filterFns": [ + "arrHas", + "arrIncludes", + "arrIncludesAll", + "arrIncludesSome", + "between", + "betweenInclusive", + "empty", + "endsWith", + "equals", + "equalsString", + "equalsStringSensitive", + "inDateRange", + "inNumberRange", + "includesString", + "includesStringSensitive", + "notEmpty", + "startsWith", + "weakEquals" + ], + "sortFns": [ + "alphanumeric", + "alphanumericCaseSensitive", + "basic", + "datetime", + "text", + "textCaseSensitive" + ], + "aggregationFns": [ + "extent", + "last", + "max", + "mean", + "median", + "min", + "sum", + "unique", + "uniqueCount" + ] + }, + "actual": 22443 + } +] diff --git a/scripts/generate-bundle-sizes.mjs b/scripts/generate-bundle-sizes.mjs new file mode 100644 index 0000000000..80379c4139 --- /dev/null +++ b/scripts/generate-bundle-sizes.mjs @@ -0,0 +1,560 @@ +// @ts-check + +/** + * Measures what each table-core feature, row model, and built-in row model + * function adds to a bundle, then writes the size table the devtools Features + * panel displays. + * + * Sizes come from the same pipeline as `pnpm size` (size-limit's small-lib + * preset: rolldown with tree-shaking and minification, then brotli), so the + * panel and the size-limit report agree. + * + * - `core`: `constructTable` with the core features it always installs. + * - `coreFeatures`: `core` split across the core features in proportion to + * each one's standalone size. Core features always ship together, so this + * is an allocation, not a removable cost. + * - `features`: the cost of registering a stock feature on top of `core`. + * - `rowModels`: the cost of a row model factory on top of `core` and the + * feature that owns it. + * - `filterFns` / `sortFns` / `aggregationFns`: the cost of one built-in + * function on top of `core`, its feature, and its row model. + * + * Features share code (several column features use the column pinning utils, + * for example), so these costs overlap and do not add up to a bundle size. + * The `estimate` section lets the devtools combine them without double + * counting; see `packages/table-devtools/src/estimateBundleSize.ts`. The + * script also measures random feature combinations: some fit the estimate's + * minified-to-brotli curve, and the rest are written as a test fixture that + * checks the estimate against real bundles. + * + * Usage: build table-core first, then run `pnpm size:devtools`. + */ + +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import sizeLimit from 'size-limit' +import presetSmallLib from '@size-limit/preset-small-lib' +import * as prettier from 'prettier' + +import { rootDir } from './config.js' + +const coreDir = resolve(rootDir, 'packages/table-core') +const coreEntry = resolve(coreDir, 'dist/index.js') +const devtoolsDir = resolve(rootDir, 'packages/table-devtools') +const outputPath = resolve(devtoolsDir, 'src/bundleSizes.ts') +const validationPath = resolve( + devtoolsDir, + 'tests/fixtures/bundleSizeValidation.json', +) + +if (!existsSync(coreEntry)) { + console.error( + `Missing ${coreEntry}. Build table-core first: pnpm nx build @tanstack/table-core`, + ) + process.exit(1) +} + +// Use the rolldown that size-limit bundles with, so declaration splitting +// sees exactly the tree-shaking result that size-limit measures +const require = createRequire(import.meta.url) +const presetRequire = createRequire( + require.resolve('@size-limit/preset-small-lib/package.json'), +) +const rolldownRequire = createRequire( + presetRequire.resolve('@size-limit/rolldown/package.json'), +) +const { build } = await import(rolldownRequire.resolve('rolldown')) +const { minifySync, parseSync } = await import( + rolldownRequire.resolve('rolldown/utils') +) + +const tableCore = await import(coreEntry) +const { version } = JSON.parse( + await readFile(resolve(coreDir, 'package.json'), 'utf8'), +) + +/** Row model slot -> [factory export, features it needs] */ +/** @type {Record]>} */ +const ROW_MODELS = { + coreRowModel: ['createCoreRowModel', []], + filteredRowModel: ['createFilteredRowModel', ['columnFilteringFeature']], + groupedRowModel: ['createGroupedRowModel', ['columnGroupingFeature']], + sortedRowModel: ['createSortedRowModel', ['rowSortingFeature']], + expandedRowModel: ['createExpandedRowModel', ['rowExpandingFeature']], + paginatedRowModel: ['createPaginatedRowModel', ['rowPaginationFeature']], + facetedRowModel: ['createFacetedRowModel', ['columnFacetingFeature']], + facetedMinMaxValues: ['createFacetedMinMaxValues', ['columnFacetingFeature']], + facetedUniqueValues: ['createFacetedUniqueValues', ['columnFacetingFeature']], +} + +/** Fn registry slot -> [export prefix, exports a function needs to run, built-in registry] */ +/** @type {Record, Record]>} */ +const FN_KINDS = { + filterFns: [ + 'filterFn_', + ['columnFilteringFeature', 'createFilteredRowModel'], + tableCore.filterFns, + ], + sortFns: [ + 'sortFn_', + ['rowSortingFeature', 'createSortedRowModel'], + tableCore.sortFns, + ], + aggregationFns: [ + 'aggregationFn_', + ['rowAggregationFeature'], + tableCore.aggregationFns, + ], +} + +const coreFeatureNames = Object.keys(tableCore.coreFeatures) +const stockFeatureNames = Object.keys(tableCore.stockFeatures).sort() + +/** + * Everything the devtools can find on a table. `exports` is what the item + * adds and `base` is what it needs to be useful, so each item is measured + * on top of its base: a row model on top of its feature, a fn on top of its + * feature and row model. + * + * @typedef {{ group: string, name: string, exports: Array, base: Array }} Item + * @type {Array} + */ +const items = [ + ...stockFeatureNames.map((name) => ({ + group: 'features', + name, + exports: [name], + base: [], + })), + ...Object.entries(ROW_MODELS).map(([name, [factory, owners]]) => ({ + group: 'rowModels', + name, + exports: [factory], + base: owners, + })), + ...Object.entries(FN_KINDS).flatMap(([group, [prefix, base, registry]]) => + Object.keys(registry) + .sort() + .map((name) => ({ group, name, exports: [`${prefix}${name}`], base })), + ), +] + +/** Items that provide an export, for pulling in an item's base */ +const itemsByExport = new Map( + items + .filter((item) => item.group === 'features' || item.group === 'rowModels') + .map((item) => [item.exports[0], item]), +) + +/** @param {Iterable} values */ +function sum(values) { + let total = 0 + for (const value of values) total += Number(value) + return total +} + +/** @param {Array} exports */ +function importKey(exports) { + return [...new Set(['constructTable', ...exports])].sort().join(', ') +} + +// Brotli sizes, measured by size-limit itself + +/** @type {Map} */ +const brotliSizes = new Map() + +/** @param {Array} keys import lists, or a bare file path for the whole package */ +async function measureBrotli(keys) { + const pending = [...new Set(keys)].filter((key) => !brotliSizes.has(key)) + const BATCH_SIZE = 8 + for (let i = 0; i < pending.length; i += BATCH_SIZE) { + const batch = pending.slice(i, i + BATCH_SIZE) + const results = await sizeLimit([presetSmallLib], { + checks: batch.map((key) => + key === coreEntry + ? { files: [coreEntry] } + : { files: [], import: { [coreEntry]: `{ ${key} }` } }, + ), + }) + batch.forEach((key, index) => { + const size = results[index]?.size + if (typeof size !== 'number') { + throw new Error(`size-limit returned no size for ${key}`) + } + brotliSizes.set(key, size) + }) + } +} + +/** @param {Array} exports */ +function brotliOf(exports) { + const size = brotliSizes.get(importKey(exports)) + if (size === undefined) throw new Error(`Not measured: ${importKey(exports)}`) + return size +} + +/** + * Brotli output is not additive, so code that is already in the base can + * measure a few bytes negative. Clamp those to zero. + * + * @param {Array} withExports + * @param {Array} baseExports + */ +function marginal(withExports, baseExports) { + return Math.max(0, brotliOf(withExports) - brotliOf(baseExports)) +} + +// Declarations, for combining items without double counting shared code + +const tempDir = await mkdtemp(join(tmpdir(), 'table-bundle-sizes-')) +let tempFileCount = 0 + +/** + * Bundles `exports` like size-limit does, minus minification, and returns + * the minified size of every top-level declaration keyed by module + name. + * + * @param {Array} exports + * @returns {Promise>} + */ +async function measureDeclarations(exports) { + const imports = importKey(exports) + const input = join(tempDir, `entry-${tempFileCount++}.js`) + await writeFile( + input, + `import { ${imports} } from ${JSON.stringify(coreEntry)}\nconsole.log(${imports})\n`, + ) + const { output } = await build({ + input, + write: false, + treeshake: true, + transform: { define: { 'process.env.NODE_ENV': '"production"' } }, + output: { comments: false }, + logLevel: 'silent', + }) + const code = output[0].code + const regions = [...code.matchAll(/^\/\/#region (.*)$/gm)].map( + (match) => /** @type {const} */ ([match.index, match[1]]), + ) + + /** @type {Map} */ + const declarations = new Map() + for (const node of parseSync('bundle.js', code).program.body) { + if ( + node.type === 'ImportDeclaration' || + node.type === 'ExportNamedDeclaration' + ) { + continue + } + const source = code.slice(node.start, node.end) + if (source.startsWith('console.log(')) continue + + const region = regions.findLast(([index]) => index <= node.start)?.[1] ?? '' + const moduleId = region.replace(/^.*?(?:packages|node_modules)\//, '') + // Rolldown suffixes names that collide in one bundle (`toString$1`), and + // collisions differ between bundles, so drop the suffix from the key + const name = ( + node.type === 'FunctionDeclaration' || node.type === 'ClassDeclaration' + ? node.id?.name + : node.type === 'VariableDeclaration' + ? node.declarations + .map((d) => (d.id.type === 'Identifier' ? d.id.name : '?')) + .join(',') + : source.slice(0, 60) + )?.replace(/\$\d+\b/g, '') + declarations.set( + `${moduleId}#${name}`, + minifySync('declaration.js', source).code.length, + ) + } + return declarations +} + +/** + * Deterministic pseudo-random combinations of items, so regenerating with an + * unchanged table-core produces an unchanged file. + * + * @param {number} count + * @param {number} seed + * @returns {Array>} + */ +function sampleCombinations(count, seed) { + let state = seed + function random() { + // mulberry32 + state = (state + 0x6d2b79f5) | 0 + let t = Math.imul(state ^ (state >>> 15), 1 | state) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } + return Array.from({ length: count }, (_, i) => { + const probability = 0.05 + (0.9 * i) / Math.max(1, count - 1) + return items.filter(() => random() < probability) + }) +} + +/** + * Real tables register an item's base along with it, so a combination + * includes the features and row models its fns and row models need. + * + * @param {Array} combination + * @returns {Array} + */ +function withBases(combination) { + const result = new Set(combination) + for (const item of combination) { + for (const name of item.base) { + const baseItem = itemsByExport.get(name) + if (baseItem) result.add(baseItem) + } + } + return [...result] +} + +/** @param {Array} combination */ +function combinationExports(combination) { + return combination.flatMap((item) => item.exports) +} + +const fitCombinations = sampleCombinations(16, 1).map(withBases) +const validationCombinations = sampleCombinations(12, 2).map(withBases) + +await measureBrotli([ + coreEntry, + importKey([]), + ...coreFeatureNames, + ...items.flatMap((item) => [ + importKey(item.base), + importKey([...item.base, ...item.exports]), + ]), + ...[...fitCombinations, ...validationCombinations].map((combination) => + importKey(combinationExports(combination)), + ), +]) + +const core = brotliOf([]) + +/** @type {Map>} */ +const declarationCache = new Map() + +/** @param {Array} exports */ +async function declarationsOf(exports) { + const key = importKey(exports) + let declarations = declarationCache.get(key) + if (!declarations) { + declarations = await measureDeclarations(exports) + declarationCache.set(key, declarations) + } + return declarations +} + +const coreDeclarations = await declarationsOf([]) +const coreMinified = sum(coreDeclarations.values()) + +/** @type {Map} declaration key -> chunk index */ +const chunkIndexes = new Map() +/** @type {Array} */ +const chunks = [] +/** @type {Array<[number, number]>} [total minified, brotli] per bundle */ +const curvePoints = [[coreMinified, core]] +/** + * @type {Array<{ item: Item, size: number, baseMinified: number, minified: number, chunks: Array }>} + */ +const itemMeasurements = [] + +for (const item of items) { + const all = [...item.base, ...item.exports] + const baseDeclarations = await declarationsOf(item.base) + const declarations = await declarationsOf(all) + /** @type {Array} */ + const itemChunks = [] + for (const [key, minified] of declarations) { + if (coreDeclarations.has(key) || baseDeclarations.has(key)) continue + let index = chunkIndexes.get(key) + if (index === undefined) { + index = chunks.length + chunkIndexes.set(key, index) + chunks.push(minified) + } + itemChunks.push(index) + } + curvePoints.push([sum(declarations.values()), brotliOf(all)]) + itemMeasurements.push({ + item, + size: marginal(all, item.base), + baseMinified: sum(baseDeclarations.values()), + minified: sum(declarations.values()), + chunks: itemChunks.sort((a, b) => a - b), + }) +} + +for (const combination of fitCombinations) { + const exports = combinationExports(combination) + const declarations = await declarationsOf(exports) + curvePoints.push([sum(declarations.values()), brotliOf(exports)]) +} +await rm(tempDir, { recursive: true, force: true }) + +// The estimate converts minified bytes to brotli bytes with a power curve, +// a * minified^b. Pick b to best estimate real combinations of items, then a +// by least squares in log space so curve sizes read as brotli bytes. + +/** + * Mirrors `estimateBundleSize` in the devtools. + * + * @param {Array} combination + * @param {(minified: number) => number} curveSize + */ +function estimateCombination(combination, curveSize) { + /** @type {Set} */ + const union = new Set() + let measured = 0 + let predicted = 0 + for (const item of combination) { + const measurement = itemMeasurements.find((m) => m.item === item) + if (!measurement) continue + measured += measurement.size + predicted += + curveSize(measurement.minified) - curveSize(measurement.baseMinified) + for (const chunk of measurement.chunks) union.add(chunk) + } + if (!measured || !predicted) return core + measured + const unionMinified = sum([...union].map((chunk) => chunks[chunk] ?? 0)) + return ( + core + + (curveSize(coreMinified + unionMinified) - curveSize(coreMinified)) * + (measured / predicted) + ) +} + +/** @param {Array>} combinations @param {(minified: number) => number} curveSize */ +function relativeErrors(combinations, curveSize) { + return combinations.map((combination) => { + const actual = brotliOf(combinationExports(combination)) + return estimateCombination(combination, curveSize) / actual - 1 + }) +} + +const logMinified = curvePoints.map(([minified]) => Math.log(minified)) +const logBrotli = curvePoints.map(([, brotli]) => Math.log(brotli)) +let exponent = 1 +let bestError = Infinity +for (let candidate = 0.5; candidate <= 1.2; candidate += 0.005) { + const error = sum( + relativeErrors(fitCombinations, (minified) => minified ** candidate).map( + (e) => e * e, + ), + ) + if (error < bestError) { + bestError = error + exponent = Number(candidate.toFixed(3)) + } +} +const coefficient = Math.exp( + sum(logBrotli.map((y, i) => y - exponent * (logMinified[i] ?? 0))) / + logBrotli.length, +) + +/** @param {number} minified */ +function curveSize(minified) { + return coefficient * minified ** exponent +} + +const validationErrors = relativeErrors(validationCombinations, curveSize) + +// Split `core` across the core features by standalone size +const standaloneCoreSizes = coreFeatureNames.map( + (name) => brotliSizes.get(name) ?? 0, +) +const standaloneCoreTotal = sum(standaloneCoreSizes) +const coreFeatures = Object.fromEntries( + coreFeatureNames.map((name, index) => [ + name, + Math.round( + (core * (standaloneCoreSizes[index] ?? 0)) / standaloneCoreTotal, + ), + ]), +) + +/** @type {Record>} */ +const sizesByGroup = {} +/** @type {Record>>} */ +const estimate = {} +for (const { + item, + size, + baseMinified, + minified, + chunks: itemChunks, +} of itemMeasurements) { + ;(sizesByGroup[item.group] ??= {})[item.name] = size + // What the curve predicts this item costs on top of its base. The ratio + // of measured to predicted sizes calibrates the curve for a selection. + const predicted = Math.round(curveSize(minified) - curveSize(baseMinified)) + ;(estimate[item.group] ??= {})[item.name] = [size, predicted, ...itemChunks] +} + +const data = { + tableCoreVersion: version, + package: brotliSizes.get(coreEntry), + core, + coreFeatures, + ...sizesByGroup, + estimate: { + coreMinified, + curve: [Number(coefficient.toFixed(4)), Number(exponent.toFixed(4))], + chunks, + ...estimate, + }, +} + +const source = `// Generated by scripts/generate-bundle-sizes.mjs. Do not edit by hand; +// rebuild table-core and run \`pnpm size:devtools\` to refresh. +// +// Sizes are bytes after minification and brotli compression, the metric +// \`pnpm size\` (size-limit) reports. \`coreFeatures\` splits \`core\` by each +// core feature's standalone size. Other entries are the cost of adding that +// item: features on top of \`core\`, row models on top of their feature, and +// fns on top of their feature and row model. +// +// \`estimate\` combines items without double counting shared code. Each item +// is [measured size, size the curve predicts, ...indexes into chunks], where +// a chunk is the minified size of one top-level declaration the item adds. + +export const bundleSizes = ${JSON.stringify(data)} +` + +const validation = validationCombinations.map((combination) => { + /** @type {Record>} */ + const selection = {} + for (const item of combination) { + ;(selection[item.group] ??= []).push(item.name) + } + return { selection, actual: brotliOf(combinationExports(combination)) } +}) + +const prettierConfig = await prettier.resolveConfig(outputPath) +await writeFile( + outputPath, + await prettier.format(source, { ...prettierConfig, filepath: outputPath }), +) +await writeFile( + validationPath, + await prettier.format(JSON.stringify(validation), { + ...prettierConfig, + filepath: validationPath, + }), +) + +console.log( + `table-core ${version}: package ${data.package} B, core ${core} B, ${chunks.length} chunks, curve ${data.estimate.curve.join(' ')}`, +) +console.log( + `Estimate error on ${validationErrors.length} held-out combinations: worst ${( + Math.max(...validationErrors.map(Math.abs)) * 100 + ).toFixed(1)}%`, +) +console.log(`Wrote ${outputPath}`) +console.log(`Wrote ${validationPath}`)