@humanspeak/svelte-headless-table 6.0.7 → 6.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bodyRows.js CHANGED
@@ -235,19 +235,23 @@ export const getColumnedBodyRows = (rows, columnIdOrder) => {
235
235
  if (rows.length === 0 || columnIdOrder.length === 0)
236
236
  return rows;
237
237
  rows.forEach((row, rowIdx) => {
238
- // Create a shallow copy of `row.cells` to reassign each `cell`'s `row`
239
- // reference.
240
- const cells = row.cells.map((cell) => {
238
+ // Build `cellForId` directly during the clone pass so we don't
239
+ // pay for an intermediate Map + Object.fromEntries detour
240
+ // (which is what the previous shape did — see plan-1A in
241
+ // .notes/performance-optimization-plan.md). The id-keyed object
242
+ // gives us O(1) lookups for `visibleCells` and is the same shape
243
+ // BodyRow.cellForId already expects.
244
+ const cellForId = {};
245
+ row.cells.forEach((cell) => {
241
246
  const clonedCell = cell.clone();
242
247
  clonedCell.row = columnedRows[rowIdx];
243
- return clonedCell;
248
+ cellForId[clonedCell.id] = clonedCell;
244
249
  });
245
- const cellById = new Map(cells.map((c) => [c.id, c]));
246
- const visibleCells = columnIdOrder.map((cid) => cellById.get(cid)).filter(nonUndefined);
250
+ const visibleCells = columnIdOrder.map((cid) => cellForId[cid]).filter(nonUndefined);
247
251
  columnedRows[rowIdx].cells = visibleCells;
248
- // Include hidden cells in `cellForId` to allow row transformations on
249
- // hidden cells.
250
- columnedRows[rowIdx].cellForId = Object.fromEntries(cellById);
252
+ // `cellForId` includes hidden cells so row transformations can
253
+ // still reach them.
254
+ columnedRows[rowIdx].cellForId = cellForId;
251
255
  });
252
256
  return columnedRows;
253
257
  };
@@ -60,10 +60,31 @@ export interface ViewModelDebug {
60
60
  injectedPageRows: number;
61
61
  headerRows: number;
62
62
  };
63
- /** Reset all derivation call counters to 0 */
63
+ /**
64
+ * Per-derivation cumulative wall-clock in milliseconds, accumulated
65
+ * via `performance.now()` deltas inside each `derived(...)` body.
66
+ * Mirrors `derivationCalls` so the perf bench can attribute a
67
+ * scenario's render budget to a specific derivation rather than the
68
+ * aggregated `firstPaintMs`. Reset by `resetCounters()`.
69
+ */
70
+ derivationTimings: {
71
+ tableAttrs: number;
72
+ tableHeadAttrs: number;
73
+ tableBodyAttrs: number;
74
+ visibleColumns: number;
75
+ columnedRows: number;
76
+ rows: number;
77
+ injectedRows: number;
78
+ pageRows: number;
79
+ injectedPageRows: number;
80
+ headerRows: number;
81
+ };
82
+ /** Reset all derivation call counters and timings to 0 */
64
83
  resetCounters: () => void;
65
84
  /** Get total derivation calls since last reset */
66
85
  getTotalCalls: () => number;
86
+ /** Get total derivation wall-clock (ms) since last reset */
87
+ getTotalMs: () => number;
67
88
  }
68
89
  /**
69
90
  * The view model for a table, containing all reactive stores and state.
@@ -30,6 +30,23 @@ export const createViewModel = (table, columns, { rowDataId } = {}) => {
30
30
  injectedPageRows: 0,
31
31
  headerRows: 0
32
32
  };
33
+ // Per-derivation cumulative ms, populated alongside derivationCalls.
34
+ // Each `derived(...)` body wraps its work in performance.now() pairs
35
+ // so the perf bench can attribute a scenario's render budget to a
36
+ // specific derivation. `rows` / `pageRows` stay at 0 — they're
37
+ // plugin-pipeline pass-throughs that don't run a body of their own.
38
+ const derivationTimings = {
39
+ tableAttrs: 0,
40
+ tableHeadAttrs: 0,
41
+ tableBodyAttrs: 0,
42
+ visibleColumns: 0,
43
+ columnedRows: 0,
44
+ rows: 0,
45
+ injectedRows: 0,
46
+ pageRows: 0,
47
+ injectedPageRows: 0,
48
+ headerRows: 0
49
+ };
33
50
  const $flatColumns = getFlatColumns(columns);
34
51
  const flatColumns = readable($flatColumns);
35
52
  const originalRows = derived([data, flatColumns], ([$data, $flatColumns]) => {
@@ -103,9 +120,11 @@ export const createViewModel = (table, columns, { rowDataId } = {}) => {
103
120
  tableAttrs = fn(tableAttrs);
104
121
  });
105
122
  const finalizedTableAttrs = derived(tableAttrs, ($tableAttrs) => {
123
+ const _t0 = performance.now();
106
124
  derivationCalls.tableAttrs++;
107
125
  const $finalizedAttrs = finalizeAttributes($tableAttrs);
108
126
  _tableAttrs.set($finalizedAttrs);
127
+ derivationTimings.tableAttrs += performance.now() - _t0;
109
128
  return $finalizedAttrs;
110
129
  });
111
130
  const deriveTableHeadAttrsFns = Object.values(pluginInstances)
@@ -116,9 +135,11 @@ export const createViewModel = (table, columns, { rowDataId } = {}) => {
116
135
  tableHeadAttrs = fn(tableHeadAttrs);
117
136
  });
118
137
  const finalizedTableHeadAttrs = derived(tableHeadAttrs, ($tableHeadAttrs) => {
138
+ const _t0 = performance.now();
119
139
  derivationCalls.tableHeadAttrs++;
120
140
  const $finalizedAttrs = finalizeAttributes($tableHeadAttrs);
121
141
  _tableHeadAttrs.set($finalizedAttrs);
142
+ derivationTimings.tableHeadAttrs += performance.now() - _t0;
122
143
  return $finalizedAttrs;
123
144
  });
124
145
  const deriveTableBodyAttrsFns = Object.values(pluginInstances)
@@ -131,9 +152,11 @@ export const createViewModel = (table, columns, { rowDataId } = {}) => {
131
152
  tableBodyAttrs = fn(tableBodyAttrs);
132
153
  });
133
154
  const finalizedTableBodyAttrs = derived(tableBodyAttrs, ($tableBodyAttrs) => {
155
+ const _t0 = performance.now();
134
156
  derivationCalls.tableBodyAttrs++;
135
157
  const $finalizedAttrs = finalizeAttributes($tableBodyAttrs);
136
158
  _tableBodyAttrs.set($finalizedAttrs);
159
+ derivationTimings.tableBodyAttrs += performance.now() - _t0;
137
160
  return $finalizedAttrs;
138
161
  });
139
162
  const deriveFlatColumnsFns = Object.values(pluginInstances)
@@ -146,13 +169,18 @@ export const createViewModel = (table, columns, { rowDataId } = {}) => {
146
169
  visibleColumns = fn(visibleColumns);
147
170
  });
148
171
  const injectedColumns = derived(visibleColumns, ($visibleColumns) => {
172
+ const _t0 = performance.now();
149
173
  derivationCalls.visibleColumns++;
150
174
  _visibleColumns.set($visibleColumns);
175
+ derivationTimings.visibleColumns += performance.now() - _t0;
151
176
  return $visibleColumns;
152
177
  });
153
178
  const columnedRows = derived([originalRows, injectedColumns], ([$originalRows, $injectedColumns]) => {
179
+ const _t0 = performance.now();
154
180
  derivationCalls.columnedRows++;
155
- return getColumnedBodyRows($originalRows, $injectedColumns.map((c) => c.id));
181
+ const result = getColumnedBodyRows($originalRows, $injectedColumns.map((c) => c.id));
182
+ derivationTimings.columnedRows += performance.now() - _t0;
183
+ return result;
156
184
  });
157
185
  const deriveRowsFns = Object.values(pluginInstances)
158
186
  .map((pluginInstance) => pluginInstance.deriveRows)
@@ -163,23 +191,39 @@ export const createViewModel = (table, columns, { rowDataId } = {}) => {
163
191
  rows = fn(rows);
164
192
  });
165
193
  const pluginEntries = Object.entries(pluginInstances);
194
+ const trHookEntries = [];
195
+ const tdHookEntries = [];
196
+ for (const [name, instance] of pluginEntries) {
197
+ const trHook = instance.hooks?.['tbody.tr'];
198
+ if (trHook !== undefined)
199
+ trHookEntries.push([name, trHook]);
200
+ const tdHook = instance.hooks?.['tbody.tr.td'];
201
+ if (tdHook !== undefined)
202
+ tdHookEntries.push([name, tdHook]);
203
+ }
204
+ // Hoisted out of the per-row loop so we don't allocate a fresh
205
+ // closure on every iteration. For rows-10k that's 10,000 fewer
206
+ // closure allocations per derivation pass.
207
+ const injectCellState = (cell) => cell.injectState(tableState);
166
208
  const injectedRows = derived(rows, ($rows) => {
209
+ const _t0 = performance.now();
167
210
  derivationCalls.injectedRows++;
168
211
  $rows.forEach((row) => {
169
212
  row.injectState(tableState);
170
- row.cells.forEach((cell) => cell.injectState(tableState));
171
- for (const [pluginName, pluginInstance] of pluginEntries) {
172
- const trHook = pluginInstance.hooks?.['tbody.tr'];
173
- if (trHook !== undefined) {
174
- row.applyHook(pluginName, trHook(row));
175
- }
176
- const tdHook = pluginInstance.hooks?.['tbody.tr.td'];
177
- if (tdHook !== undefined) {
178
- row.cells.forEach((cell) => cell.applyHook(pluginName, tdHook(cell)));
179
- }
213
+ row.cells.forEach(injectCellState);
214
+ for (const [pluginName, trHook] of trHookEntries) {
215
+ row.applyHook(pluginName, trHook(row));
216
+ }
217
+ if (tdHookEntries.length > 0) {
218
+ row.cells.forEach((cell) => {
219
+ for (const [pluginName, tdHook] of tdHookEntries) {
220
+ cell.applyHook(pluginName, tdHook(cell));
221
+ }
222
+ });
180
223
  }
181
224
  });
182
225
  _rows.set($rows);
226
+ derivationTimings.injectedRows += performance.now() - _t0;
183
227
  return $rows;
184
228
  });
185
229
  const derivePageRowsFns = Object.values(pluginInstances)
@@ -194,11 +238,14 @@ export const createViewModel = (table, columns, { rowDataId } = {}) => {
194
238
  // Page rows are a subset of the same object references already processed
195
239
  // by injectedRows — no need to re-inject state or re-apply hooks.
196
240
  const injectedPageRows = derived(pageRows, ($pageRows) => {
241
+ const _t0 = performance.now();
197
242
  derivationCalls.injectedPageRows++;
198
243
  _pageRows.set($pageRows);
244
+ derivationTimings.injectedPageRows += performance.now() - _t0;
199
245
  return $pageRows;
200
246
  });
201
247
  const headerRows = derived(injectedColumns, ($injectedColumns) => {
248
+ const _t0 = performance.now();
202
249
  derivationCalls.headerRows++;
203
250
  const $headerRows = getHeaderRows(columns, $injectedColumns.map((c) => c.id));
204
251
  $headerRows.forEach((row) => {
@@ -216,6 +263,7 @@ export const createViewModel = (table, columns, { rowDataId } = {}) => {
216
263
  }
217
264
  });
218
265
  _headerRows.set($headerRows);
266
+ derivationTimings.headerRows += performance.now() - _t0;
219
267
  return $headerRows;
220
268
  });
221
269
  const _debug = {
@@ -230,13 +278,18 @@ export const createViewModel = (table, columns, { rowDataId } = {}) => {
230
278
  pageRows: derivePageRowsFns.length + 1 // +1 for injected
231
279
  },
232
280
  derivationCalls,
281
+ derivationTimings,
233
282
  resetCounters: () => {
234
283
  Object.keys(derivationCalls).forEach((key) => {
235
284
  derivationCalls[key] = 0;
285
+ derivationTimings[key] = 0;
236
286
  });
237
287
  },
238
288
  getTotalCalls: () => {
239
289
  return Object.values(derivationCalls).reduce((sum, count) => sum + count, 0);
290
+ },
291
+ getTotalMs: () => {
292
+ return Object.values(derivationTimings).reduce((sum, ms) => sum + ms, 0);
240
293
  }
241
294
  };
242
295
  return {
@@ -215,7 +215,9 @@ export const getMergedRow = (cells) => {
215
215
  }
216
216
  const mergedCells = [];
217
217
  let startIdx = 0;
218
- let endIdx = 1;
218
+ // endIdx is initialized inside the merge branch (line ~293) before
219
+ // it's read; declared here only so the inner-while loop can see it.
220
+ let endIdx;
219
221
  while (startIdx < cells.length) {
220
222
  const cell = cells[startIdx].clone();
221
223
  if (!cell.isGroup()) {
@@ -1,3 +1,4 @@
1
+ import { MemoryCache } from '@humanspeak/memory-cache';
1
2
  import { derived, writable } from 'svelte/store';
2
3
  import { compare } from '../utils/compare.js';
3
4
  import { isShiftClick } from '../utils/event.js';
@@ -169,6 +170,16 @@ export const addSortBy = ({ initialSortKeys = [], disableMultiSort = false, isMu
169
170
  });
170
171
  };
171
172
  const pluginState = { sortKeys, preSortedRows };
173
+ // The `tbody.tr.td` hook output only depends on `cell.id` (the
174
+ // column ID — closed-over) and the reactive `sortKeys` store.
175
+ // Two body cells in the same column produce identical Readables,
176
+ // so we can share one Readable per column ID across every row.
177
+ // For rows-10k × 8 cols that collapses 80,000 Readable allocations
178
+ // per cold mount into 8. LRU eviction means we stay bounded even
179
+ // if column IDs churn over the view-model's lifetime.
180
+ const tdPropsCache = new MemoryCache({
181
+ maxSize: 256
182
+ });
172
183
  return {
173
184
  pluginState,
174
185
  deriveRows,
@@ -204,12 +215,16 @@ export const addSortBy = ({ initialSortKeys = [], disableMultiSort = false, isMu
204
215
  return { props };
205
216
  },
206
217
  'tbody.tr.td': (cell) => {
207
- const props = derived(sortKeys, ($sortKeys) => {
208
- const key = $sortKeys.find((k) => k.id === cell.id);
209
- return {
210
- order: key?.order
211
- };
212
- });
218
+ let props = tdPropsCache.get(cell.id);
219
+ if (props === undefined) {
220
+ props = derived(sortKeys, ($sortKeys) => {
221
+ const key = $sortKeys.find((k) => k.id === cell.id);
222
+ return {
223
+ order: key?.order
224
+ };
225
+ });
226
+ tdPropsCache.set(cell.id, props);
227
+ }
213
228
  return { props };
214
229
  }
215
230
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-headless-table",
3
- "version": "6.0.7",
3
+ "version": "6.0.9",
4
4
  "description": "A powerful, headless table library for Svelte that provides complete control over table UI while handling complex data operations like sorting, filtering, pagination, grouping, and row expansion. Build custom, accessible data tables with zero styling opinions.",
5
5
  "keywords": [
6
6
  "svelte",
@@ -69,15 +69,15 @@
69
69
  "@faker-js/faker": "^10.4.0",
70
70
  "@playwright/test": "^1.60.0",
71
71
  "@sveltejs/adapter-auto": "^7.0.1",
72
- "@sveltejs/kit": "^2.60.1",
72
+ "@sveltejs/kit": "^2.61.1",
73
73
  "@sveltejs/package": "^2.5.7",
74
74
  "@sveltejs/vite-plugin-svelte": "^7.1.2",
75
75
  "@testing-library/jest-dom": "^6.9.1",
76
76
  "@testing-library/svelte": "^5.3.1",
77
77
  "@types/eslint": "9.6.1",
78
78
  "@types/node": "^25.9.1",
79
- "@typescript-eslint/eslint-plugin": "^8.59.4",
80
- "@typescript-eslint/parser": "^8.59.4",
79
+ "@typescript-eslint/eslint-plugin": "^8.60.0",
80
+ "@typescript-eslint/parser": "^8.60.0",
81
81
  "@vitest/coverage-v8": "^4.1.7",
82
82
  "eslint": "^10.4.0",
83
83
  "eslint-config-prettier": "10.1.8",
@@ -97,7 +97,7 @@
97
97
  "tslib": "^2.8.1",
98
98
  "type-fest": "^5.6.0",
99
99
  "typescript": "^6.0.3",
100
- "typescript-eslint": "^8.59.4",
100
+ "typescript-eslint": "^8.60.0",
101
101
  "vite": "^8.0.14",
102
102
  "vitest": "^4.1.7"
103
103
  },
@@ -120,6 +120,7 @@
120
120
  "lint": "prettier --check . && eslint .",
121
121
  "lint:fix": "npm run format && eslint . --fix",
122
122
  "package": "svelte-kit sync && svelte-package && publint",
123
+ "perf:bench": "node scripts/perf-bench.mjs",
123
124
  "preview": "vite preview",
124
125
  "test": "vitest run --coverage",
125
126
  "test:all": "npm run test && npm run test:e2e",