From 7ae670f94834e92aa518885900dd03592c3e4987 Mon Sep 17 00:00:00 2001 From: GabberPL <38166011+GabberPL@users.noreply.github.com> Date: Mon, 11 May 2026 14:46:05 +0200 Subject: [PATCH 01/26] DEV-1644: Fix JS/TS picker dropdown width to match trigger button (#12523) --- docs/src/styles/interactive-example.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/styles/interactive-example.css b/docs/src/styles/interactive-example.css index d2722b699c2..9db564ad34d 100644 --- a/docs/src/styles/interactive-example.css +++ b/docs/src/styles/interactive-example.css @@ -405,7 +405,7 @@ box-shadow: none; list-style: none; margin: -1px 0 0; - min-width: 8rem; + min-width: 100%; overflow-y: auto; padding: 0; position: absolute; From 269dba270beaaa97ea8846f5c1371e1d56e89874 Mon Sep 17 00:00:00 2001 From: GabberPL <38166011+GabberPL@users.noreply.github.com> Date: Mon, 11 May 2026 14:46:33 +0200 Subject: [PATCH 02/26] DEV-1632: Fix search styling in external-search-box recipe examples (#12522) Replace inline styles with example-controls-container/controls CSS classes to match the styling used in searching-values examples. Applies to JS, TS, React (JSX/TSX), and Angular examples. https://claude.ai/code/session_01Wh3BHHNrtt6qMwEVcAuFjd Co-authored-by: Claude --- .../external-search-box/angular/example1.ts | 19 ++++++++------- .../javascript/example1.js | 17 +++++++------ .../javascript/example1.ts | 17 +++++++------ .../external-search-box/react/example1.jsx | 24 ++++++++----------- .../external-search-box/react/example1.tsx | 24 ++++++++----------- 5 files changed, 46 insertions(+), 55 deletions(-) diff --git a/docs/content/recipes/filtering-and-search/external-search-box/angular/example1.ts b/docs/content/recipes/filtering-and-search/external-search-box/angular/example1.ts index c851e9e53ed..23e21d8b283 100644 --- a/docs/content/recipes/filtering-and-search/external-search-box/angular/example1.ts +++ b/docs/content/recipes/filtering-and-search/external-search-box/angular/example1.ts @@ -17,15 +17,16 @@ const data = [ imports: [HotTableModule], selector: 'example1-external-search-box', template: ` -
- - +
+
+ + +
`, diff --git a/docs/content/recipes/filtering-and-search/external-search-box/javascript/example1.js b/docs/content/recipes/filtering-and-search/external-search-box/javascript/example1.js index 3d1f3f505e2..0c2cf6896b4 100644 --- a/docs/content/recipes/filtering-and-search/external-search-box/javascript/example1.js +++ b/docs/content/recipes/filtering-and-search/external-search-box/javascript/example1.js @@ -18,26 +18,25 @@ const data = [ const exampleContainer = document.querySelector('#example1'); const searchWrapper = document.createElement('div'); -searchWrapper.style.marginBottom = '12px'; +searchWrapper.className = 'example-controls-container'; + +const controlsDiv = document.createElement('div'); +controlsDiv.className = 'controls'; const searchLabel = document.createElement('label'); searchLabel.setAttribute('for', 'external-search-input'); searchLabel.textContent = 'Search rows'; -searchLabel.style.display = 'block'; -searchLabel.style.marginBottom = '4px'; const searchInput = document.createElement('input'); searchInput.id = 'external-search-input'; -searchInput.type = 'text'; +searchInput.type = 'search'; searchInput.placeholder = 'Type to highlight matching cells...'; -searchInput.style.width = '100%'; -searchInput.style.boxSizing = 'border-box'; -searchInput.style.padding = '8px'; const hotContainer = document.createElement('div'); -searchWrapper.appendChild(searchLabel); -searchWrapper.appendChild(searchInput); +controlsDiv.appendChild(searchLabel); +controlsDiv.appendChild(searchInput); +searchWrapper.appendChild(controlsDiv); exampleContainer.appendChild(searchWrapper); exampleContainer.appendChild(hotContainer); diff --git a/docs/content/recipes/filtering-and-search/external-search-box/javascript/example1.ts b/docs/content/recipes/filtering-and-search/external-search-box/javascript/example1.ts index aa00aa394b0..54a5dd8f1f7 100644 --- a/docs/content/recipes/filtering-and-search/external-search-box/javascript/example1.ts +++ b/docs/content/recipes/filtering-and-search/external-search-box/javascript/example1.ts @@ -22,26 +22,25 @@ if (!exampleContainer) { } const searchWrapper = document.createElement('div'); -searchWrapper.style.marginBottom = '12px'; +searchWrapper.className = 'example-controls-container'; + +const controlsDiv = document.createElement('div'); +controlsDiv.className = 'controls'; const searchLabel = document.createElement('label'); searchLabel.setAttribute('for', 'external-search-input'); searchLabel.textContent = 'Search rows'; -searchLabel.style.display = 'block'; -searchLabel.style.marginBottom = '4px'; const searchInput = document.createElement('input'); searchInput.id = 'external-search-input'; -searchInput.type = 'text'; +searchInput.type = 'search'; searchInput.placeholder = 'Type to highlight matching cells...'; -searchInput.style.width = '100%'; -searchInput.style.boxSizing = 'border-box'; -searchInput.style.padding = '8px'; const hotContainer = document.createElement('div'); -searchWrapper.appendChild(searchLabel); -searchWrapper.appendChild(searchInput); +controlsDiv.appendChild(searchLabel); +controlsDiv.appendChild(searchInput); +searchWrapper.appendChild(controlsDiv); exampleContainer.appendChild(searchWrapper); exampleContainer.appendChild(hotContainer); diff --git a/docs/content/recipes/filtering-and-search/external-search-box/react/example1.jsx b/docs/content/recipes/filtering-and-search/external-search-box/react/example1.jsx index e1477a5d55a..0eba78c60f5 100644 --- a/docs/content/recipes/filtering-and-search/external-search-box/react/example1.jsx +++ b/docs/content/recipes/filtering-and-search/external-search-box/react/example1.jsx @@ -47,20 +47,16 @@ const ExampleComponent = () => { return (
-
- - +
+
+ + +
{ return (
-
- - +
+
+ + +
Date: Mon, 11 May 2026 14:47:17 +0200 Subject: [PATCH 03/26] DEV-1643: Add missing React TypeScript examples for multiselect cell type docs (#12521) Co-authored-by: Claude --- .../multiselect-cell-type/react/example1.tsx | 58 ++++++++ .../multiselect-cell-type/react/example2.tsx | 131 ++++++++++++++++++ .../multiselect-cell-type/react/example3.tsx | 70 ++++++++++ 3 files changed, 259 insertions(+) create mode 100644 docs/content/guides/cell-types/multiselect-cell-type/react/example1.tsx create mode 100644 docs/content/guides/cell-types/multiselect-cell-type/react/example2.tsx create mode 100644 docs/content/guides/cell-types/multiselect-cell-type/react/example3.tsx diff --git a/docs/content/guides/cell-types/multiselect-cell-type/react/example1.tsx b/docs/content/guides/cell-types/multiselect-cell-type/react/example1.tsx new file mode 100644 index 00000000000..ca889a3c5b8 --- /dev/null +++ b/docs/content/guides/cell-types/multiselect-cell-type/react/example1.tsx @@ -0,0 +1,58 @@ +import { HotTable } from '@handsontable/react-wrapper'; +import { registerAllModules } from 'handsontable/registry'; + +// register Handsontable's modules +registerAllModules(); + +const shipmentCategories: string[] = [ + 'Electronics and Gadgets', + 'Medical Supplies', + 'Auto Parts', + 'Fresh Produce', + 'Textiles', + 'Industrial Equipment', + 'Pharmaceuticals', + 'Consumer Goods', + 'Machine Parts', + 'Food Products', +]; + +const data: (string | string[])[][] = [ + ['Los Angeles International Airport', ['Electronics and Gadgets', 'Medical Supplies']], + ["Chicago O'Hare International Airport", ['Auto Parts', 'Fresh Produce']], + ['Charles de Gaulle Airport', ['Textiles', 'Industrial Equipment']], + ['Tokyo Haneda Airport', ['Pharmaceuticals', 'Consumer Goods']], + ['Singapore Changi Airport', ['Machine Parts', 'Food Products']], + ['Luton Airport', ['Electronics and Gadgets', 'Pharmaceuticals']], + ['Frankfurt Airport', ['Industrial Equipment', 'Auto Parts', 'Consumer Goods']], + ['Sydney Kingsford Smith Airport', ['Fresh Produce', 'Food Products']], + ['Toronto Pearson International Airport', ['Medical Supplies', 'Textiles']], + ['Hong Kong International Airport', ['Machine Parts', 'Electronics and Gadgets', 'Industrial Equipment']], + ['Heathrow Airport', ['Textiles', 'Consumer Goods']], +]; + +const ExampleComponent = () => { + return ( + + ); +}; + +export default ExampleComponent; diff --git a/docs/content/guides/cell-types/multiselect-cell-type/react/example2.tsx b/docs/content/guides/cell-types/multiselect-cell-type/react/example2.tsx new file mode 100644 index 00000000000..42838265c37 --- /dev/null +++ b/docs/content/guides/cell-types/multiselect-cell-type/react/example2.tsx @@ -0,0 +1,131 @@ +import { HotTable } from '@handsontable/react-wrapper'; +import { registerAllModules } from 'handsontable/registry'; + +// register Handsontable's modules +registerAllModules(); + +interface ShipmentCategory { + key: string; + value: string; +} + +const shipmentCategories: ShipmentCategory[] = [ + { key: 'electronics', value: 'Electronics and Gadgets' }, + { key: 'medical', value: 'Medical Supplies' }, + { key: 'auto-parts', value: 'Auto Parts' }, + { key: 'fresh-produce', value: 'Fresh Produce' }, + { key: 'textiles', value: 'Textiles' }, + { key: 'industrial', value: 'Industrial Equipment' }, + { key: 'pharmaceuticals', value: 'Pharmaceuticals' }, + { key: 'consumer', value: 'Consumer Goods' }, + { key: 'machine-parts', value: 'Machine Parts' }, + { key: 'food', value: 'Food Products' }, +]; + +const data: (string | ShipmentCategory[])[][] = [ + [ + 'Los Angeles International Airport', + [ + { key: 'electronics', value: 'Electronics and Gadgets' }, + { key: 'medical', value: 'Medical Supplies' }, + ], + ], + [ + "Chicago O'Hare International Airport", + [ + { key: 'auto-parts', value: 'Auto Parts' }, + { key: 'fresh-produce', value: 'Fresh Produce' }, + ], + ], + [ + 'Charles de Gaulle Airport', + [ + { key: 'textiles', value: 'Textiles' }, + { key: 'industrial', value: 'Industrial Equipment' }, + ], + ], + [ + 'Tokyo Haneda Airport', + [ + { key: 'pharmaceuticals', value: 'Pharmaceuticals' }, + { key: 'consumer', value: 'Consumer Goods' }, + ], + ], + [ + 'Singapore Changi Airport', + [ + { key: 'machine-parts', value: 'Machine Parts' }, + { key: 'food', value: 'Food Products' }, + ], + ], + [ + 'Luton Airport', + [ + { key: 'electronics', value: 'Electronics and Gadgets' }, + { key: 'pharmaceuticals', value: 'Pharmaceuticals' }, + ], + ], + [ + 'Frankfurt Airport', + [ + { key: 'industrial', value: 'Industrial Equipment' }, + { key: 'auto-parts', value: 'Auto Parts' }, + { key: 'consumer', value: 'Consumer Goods' }, + ], + ], + [ + 'Sydney Kingsford Smith Airport', + [ + { key: 'fresh-produce', value: 'Fresh Produce' }, + { key: 'food', value: 'Food Products' }, + ], + ], + [ + 'Toronto Pearson International Airport', + [ + { key: 'medical', value: 'Medical Supplies' }, + { key: 'textiles', value: 'Textiles' }, + ], + ], + [ + 'Hong Kong International Airport', + [ + { key: 'machine-parts', value: 'Machine Parts' }, + { key: 'electronics', value: 'Electronics and Gadgets' }, + { key: 'industrial', value: 'Industrial Equipment' }, + ], + ], + [ + 'Heathrow Airport', + [ + { key: 'textiles', value: 'Textiles' }, + { key: 'consumer', value: 'Consumer Goods' }, + ], + ], +]; + +const ExampleComponent = () => { + return ( + + ); +}; + +export default ExampleComponent; diff --git a/docs/content/guides/cell-types/multiselect-cell-type/react/example3.tsx b/docs/content/guides/cell-types/multiselect-cell-type/react/example3.tsx new file mode 100644 index 00000000000..96447dbd0b2 --- /dev/null +++ b/docs/content/guides/cell-types/multiselect-cell-type/react/example3.tsx @@ -0,0 +1,70 @@ +import { HotTable } from '@handsontable/react-wrapper'; +import { registerAllModules } from 'handsontable/registry'; + +// register Handsontable's modules +registerAllModules(); + +const requiredItems: string[] = ['Passport', 'Tickets', 'Wallet', 'Phone', 'Keys']; +const optionalExtras: string[] = ['Snacks', 'Book', 'Camera', 'Umbrella', 'First aid kit']; +const interests: string[] = ['Art', 'History', 'Nature', 'Food', 'Shopping']; + +const sortAlphabetically = (entries: (string | object)[]) => + [...entries].sort((a, b) => String(a).localeCompare(String(b))); + +const data: string[][][] = [ + [['Passport', 'Phone'], ['Snacks', 'Book'], ['Nature']], + [['Tickets', 'Wallet'], ['Camera'], []], + [['Phone', 'Keys'], ['First aid kit', 'Snacks', 'Umbrella'], ['Nature']], + [['Wallet', 'Phone'], [], ['Food', 'Shopping']], + [['Passport', 'Tickets'], ['Book'], ['Art', 'History']], + [['Phone', 'Keys'], ['First aid kit', 'Snacks', 'Umbrella'], ['Nature']], + [['Wallet', 'Phone'], [], ['Food', 'Shopping']], + [['Passport', 'Tickets'], ['Book'], ['Art', 'History']], + [['Phone', 'Keys'], ['First aid kit', 'Snacks', 'Umbrella'], ['Nature']], + [['Wallet', 'Phone'], [], ['Food', 'Shopping']], + [['Passport', 'Tickets'], ['Book'], ['Art', 'History']], + [['Phone', 'Keys'], ['First aid kit', 'Snacks', 'Umbrella'], ['Nature']], + [['Wallet', 'Phone'], [], ['Food', 'Shopping']], + [['Passport', 'Tickets'], ['Book'], ['Art', 'History']], +]; + +const ExampleComponent = () => { + return ( + + ); +}; + +export default ExampleComponent; From 8682ca9845c07634d67b4f67006900ac93c12db5 Mon Sep 17 00:00:00 2001 From: KrzysztofZie Date: Tue, 12 May 2026 08:50:42 +0200 Subject: [PATCH 04/26] =?UTF-8?q?DEV-1061=20-=20Angular=20example=20throws?= =?UTF-8?q?=20an=20Uncaught=20TypeError=20when=20`@if/@fo=E2=80=A6=20(#125?= =?UTF-8?q?33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * DEV-1061 - Angular example throws an Uncaught TypeError when `@if/@for` are used and validation is executed via `ngAfterViewInit` hook" * DEV-1061 - Angular example throws an Uncaught TypeError when `@if/@for` are used and validation is executed via `ngAfterViewInit` hook * DEV-1061 - Angular example throws an Uncaught TypeError when `@if/@for` are used and validation is executed via `ngAfterViewInit` hook --- .changelogs/12533.json | 8 ++++ .../3rdparty/walkontable/src/core/_base.js | 12 +++--- .../src/3rdparty/walkontable/src/viewport.js | 4 +- .../spec/table/getFirstRenderedColumn.spec.js | 38 ++++++++++++++++++ .../spec/table/getFirstRenderedRow.spec.js | 40 +++++++++++++++++++ .../table/getRenderedColumnsCount.spec.js | 38 ++++++++++++++++++ .../spec/table/getRenderedRowsCount.spec.js | 38 ++++++++++++++++++ .../walkontable/test/spec/viewport.spec.js | 31 ++++++++++++++ 8 files changed, 201 insertions(+), 8 deletions(-) create mode 100644 .changelogs/12533.json diff --git a/.changelogs/12533.json b/.changelogs/12533.json new file mode 100644 index 00000000000..e54dda1f420 --- /dev/null +++ b/.changelogs/12533.json @@ -0,0 +1,8 @@ +{ + "issuesOrigin": "public", + "title": "Fixed Prevent crash when Handsontable is initialized inside a hidden container, rowsRenderCalculator and columnsRenderCalculator on Viewport are never assigned and remain undefined.", + "type": "fixed", + "issueOrPR": 12533, + "breaking": false, + "framework": "none" +} diff --git a/handsontable/src/3rdparty/walkontable/src/core/_base.js b/handsontable/src/3rdparty/walkontable/src/core/_base.js index a3cc5181383..56da2eb0989 100644 --- a/handsontable/src/3rdparty/walkontable/src/core/_base.js +++ b/handsontable/src/3rdparty/walkontable/src/core/_base.js @@ -333,7 +333,7 @@ export default class CoreAbstract { return wot.wtTable; // TODO refactoring: it provides itself }, get startColumnRendered() { - return wot.wtViewport.columnsRenderCalculator.startColumn; + return wot.wtViewport.columnsRenderCalculator?.startColumn ?? null; }, get startColumnVisible() { return wot.wtViewport.columnsVisibleCalculator.startColumn; @@ -342,7 +342,7 @@ export default class CoreAbstract { return wot.wtViewport.columnsPartiallyVisibleCalculator.startColumn; }, get endColumnRendered() { - return wot.wtViewport.columnsRenderCalculator.endColumn; + return wot.wtViewport.columnsRenderCalculator?.endColumn ?? null; }, get endColumnVisible() { return wot.wtViewport.columnsVisibleCalculator.endColumn; @@ -351,13 +351,13 @@ export default class CoreAbstract { return wot.wtViewport.columnsPartiallyVisibleCalculator.endColumn; }, get countColumnsRendered() { - return wot.wtViewport.columnsRenderCalculator.count; + return wot.wtViewport.columnsRenderCalculator?.count ?? 0; }, get countColumnsVisible() { return wot.wtViewport.columnsVisibleCalculator.count; }, get startRowRendered() { - return wot.wtViewport.rowsRenderCalculator.startRow; + return wot.wtViewport.rowsRenderCalculator?.startRow ?? null; }, get startRowVisible() { return wot.wtViewport.rowsVisibleCalculator.startRow; @@ -366,7 +366,7 @@ export default class CoreAbstract { return wot.wtViewport.rowsPartiallyVisibleCalculator.startRow; }, get endRowRendered() { - return wot.wtViewport.rowsRenderCalculator.endRow; + return wot.wtViewport.rowsRenderCalculator?.endRow ?? null; }, get endRowVisible() { return wot.wtViewport.rowsVisibleCalculator.endRow; @@ -375,7 +375,7 @@ export default class CoreAbstract { return wot.wtViewport.rowsPartiallyVisibleCalculator.endRow; }, get countRowsRendered() { - return wot.wtViewport.rowsRenderCalculator.count; + return wot.wtViewport.rowsRenderCalculator?.count ?? 0; }, get countRowsVisible() { return wot.wtViewport.rowsVisibleCalculator.count; diff --git a/handsontable/src/3rdparty/walkontable/src/viewport.js b/handsontable/src/3rdparty/walkontable/src/viewport.js index bc7b12560e9..e71df380b9f 100644 --- a/handsontable/src/3rdparty/walkontable/src/viewport.js +++ b/handsontable/src/3rdparty/walkontable/src/viewport.js @@ -487,7 +487,7 @@ class Viewport { * Returns `false` if at least one proposed visible row is not already rendered (meaning: redraw is needed). */ areAllProposedVisibleRowsAlreadyRendered(proposedFullyVisibleRowsCalculator, proposedPartiallyVisibleRowsCalculator) { - if (!this.rowsVisibleCalculator) { + if (!this.rowsVisibleCalculator || !this.rowsRenderCalculator) { return false; } @@ -553,7 +553,7 @@ class Viewport { proposedFullyVisibleColumnsCalculator, proposedPartiallyVisibleColumnsCalculator ) { - if (!this.columnsVisibleCalculator) { + if (!this.columnsVisibleCalculator || !this.columnsRenderCalculator) { return false; } diff --git a/handsontable/src/3rdparty/walkontable/test/spec/table/getFirstRenderedColumn.spec.js b/handsontable/src/3rdparty/walkontable/test/spec/table/getFirstRenderedColumn.spec.js index a4902b35ef7..09541d53565 100644 --- a/handsontable/src/3rdparty/walkontable/test/spec/table/getFirstRenderedColumn.spec.js +++ b/handsontable/src/3rdparty/walkontable/test/spec/table/getFirstRenderedColumn.spec.js @@ -156,5 +156,43 @@ describe('WalkontableTable', () => { expect(wt.wtTable.getFirstRenderedColumn()).toBe(4); }); + + it('should return -1 without throwing when the table is initialized inside a hidden container', async() => { + createDataArray(18, 18); + spec().$wrapper.width(250).height(170).css('display', 'none'); + + const wt = walkontable({ + data: getData, + totalRows: getTotalRows, + totalColumns: getTotalColumns, + }); + + // draw() is interrupted because the container is hidden — columnsRenderCalculator stays undefined + wt.draw(); + + // should return -1 instead of throwing "can't access property 'startColumn', columnsRenderCalculator is undefined" + expect(wt.wtTable.getFirstRenderedColumn()).toBe(-1); + }); + + it('should not throw when draw(fastDraw=true) is called after the container becomes visible for the first time', async() => { + createDataArray(18, 18); + spec().$wrapper.width(250).height(170).css('display', 'none'); + + const wt = walkontable({ + data: getData, + totalRows: getTotalRows, + totalColumns: getTotalColumns, + }); + + // Initial draw is interrupted — columnsRenderCalculator is never set + wt.draw(); + + // Container becomes visible (e.g. accordion opens) + spec().$wrapper.css('display', ''); + + // draw(true) must not throw even though columnsRenderCalculator was undefined + expect(() => wt.draw(true)).not.toThrow(); + expect(wt.wtTable.getFirstRenderedColumn()).toBe(0); + }); }); }); diff --git a/handsontable/src/3rdparty/walkontable/test/spec/table/getFirstRenderedRow.spec.js b/handsontable/src/3rdparty/walkontable/test/spec/table/getFirstRenderedRow.spec.js index 70ccbbad649..bf80f6054ae 100644 --- a/handsontable/src/3rdparty/walkontable/test/spec/table/getFirstRenderedRow.spec.js +++ b/handsontable/src/3rdparty/walkontable/test/spec/table/getFirstRenderedRow.spec.js @@ -178,5 +178,45 @@ describe('WalkontableTable', () => { expectWtTable(wt, wtTable => wtTable.getFirstRenderedRow(), 'master').toBe(0); expectWtTable(wt, wtTable => wtTable.getFirstRenderedRow(), 'bottom').toBe(0); }); + + it('should return -1 without throwing when the table is initialized inside a hidden container', async() => { + createDataArray(18, 4); + spec().$wrapper.width(250).height(170).css('display', 'none'); + + const wt = walkontable({ + data: getData, + totalRows: getTotalRows, + totalColumns: getTotalColumns, + }); + + // draw() is interrupted because the container is hidden — rowsRenderCalculator stays undefined + wt.draw(); + + // should return -1 instead of throwing "can't access property 'startRow', rowsRenderCalculator is undefined" + expect(wt.wtTable.getFirstRenderedRow()).toBe(-1); + }); + + it('should not throw when draw(fastDraw=true) is called after the container becomes visible for the first time', async() => { + createDataArray(18, 4); + spec().$wrapper.width(250).height(170).css('display', 'none'); + + const wt = walkontable({ + data: getData, + totalRows: getTotalRows, + totalColumns: getTotalColumns, + }); + + // Initial draw is interrupted — rowsRenderCalculator is never set + wt.draw(); + + // Container becomes visible (e.g. accordion opens) + spec().$wrapper.css('display', ''); + + // draw(true) must not throw even though rowsRenderCalculator was undefined; + // the guard in areAllProposedVisibleRowsAlreadyRendered forces a full redraw, + // which sets rowsRenderCalculator and recovers correctly + expect(() => wt.draw(true)).not.toThrow(); + expect(wt.wtTable.getFirstRenderedRow()).toBe(0); + }); }); }); diff --git a/handsontable/src/3rdparty/walkontable/test/spec/table/getRenderedColumnsCount.spec.js b/handsontable/src/3rdparty/walkontable/test/spec/table/getRenderedColumnsCount.spec.js index a2af4e7afb5..c73b91e2ae5 100644 --- a/handsontable/src/3rdparty/walkontable/test/spec/table/getRenderedColumnsCount.spec.js +++ b/handsontable/src/3rdparty/walkontable/test/spec/table/getRenderedColumnsCount.spec.js @@ -92,6 +92,44 @@ describe('WalkontableTable', () => { expectWtTable(wt, wtTable => wtTable.getRenderedColumnsCount(), 'top').toBe(3); }); + it('should return 0 without throwing when the table is initialized inside a hidden container', async() => { + createDataArray(18, 18); + spec().$wrapper.width(250).height(170).css('display', 'none'); + + const wt = walkontable({ + data: getData, + totalRows: getTotalRows, + totalColumns: getTotalColumns, + }); + + // draw() is interrupted because the container is hidden — columnsRenderCalculator stays undefined + wt.draw(); + + // should return 0 instead of throwing "can't access property 'count', columnsRenderCalculator is undefined" + expect(wt.wtTable.getRenderedColumnsCount()).toBe(0); + }); + + it('should return correct count after draw(fastDraw=true) is called once the container becomes visible', async() => { + createDataArray(18, 18); + spec().$wrapper.width(250).height(170).css('display', 'none'); + + const wt = walkontable({ + data: getData, + totalRows: getTotalRows, + totalColumns: getTotalColumns, + }); + + // Initial draw is interrupted — columnsRenderCalculator is never set + wt.draw(); + + // Container becomes visible (e.g. accordion opens) + spec().$wrapper.css('display', ''); + + // draw(true) must not throw and must set columnsRenderCalculator + expect(() => wt.draw(true)).not.toThrow(); + expect(wt.wtTable.getRenderedColumnsCount()).toBeGreaterThan(0); + }); + it('should return columns count only for fully visible columns', async() => { createDataArray(18, 18); spec().$wrapper.width(209).height(185); diff --git a/handsontable/src/3rdparty/walkontable/test/spec/table/getRenderedRowsCount.spec.js b/handsontable/src/3rdparty/walkontable/test/spec/table/getRenderedRowsCount.spec.js index bd465584a7b..70a23dd8c03 100644 --- a/handsontable/src/3rdparty/walkontable/test/spec/table/getRenderedRowsCount.spec.js +++ b/handsontable/src/3rdparty/walkontable/test/spec/table/getRenderedRowsCount.spec.js @@ -92,6 +92,44 @@ describe('WalkontableTable', () => { expectWtTable(wt, wtTable => wtTable.getRenderedRowsCount(), 'top').toBe(2); }); + it('should return 0 without throwing when the table is initialized inside a hidden container', async() => { + createDataArray(18, 4); + spec().$wrapper.width(250).height(170).css('display', 'none'); + + const wt = walkontable({ + data: getData, + totalRows: getTotalRows, + totalColumns: getTotalColumns, + }); + + // draw() is interrupted because the container is hidden — rowsRenderCalculator stays undefined + wt.draw(); + + // should return 0 instead of throwing "can't access property 'count', rowsRenderCalculator is undefined" + expect(wt.wtTable.getRenderedRowsCount()).toBe(0); + }); + + it('should return correct count after draw(fastDraw=true) is called once the container becomes visible', async() => { + createDataArray(18, 4); + spec().$wrapper.width(250).height(170).css('display', 'none'); + + const wt = walkontable({ + data: getData, + totalRows: getTotalRows, + totalColumns: getTotalColumns, + }); + + // Initial draw is interrupted — rowsRenderCalculator is never set + wt.draw(); + + // Container becomes visible (e.g. accordion opens) + spec().$wrapper.css('display', ''); + + // draw(true) must not throw and must set rowsRenderCalculator + expect(() => wt.draw(true)).not.toThrow(); + expect(wt.wtTable.getRenderedRowsCount()).toBeGreaterThan(0); + }); + it('should return rows count only for fully visible rows', async() => { createDataArray(18, 18); spec().$wrapper.width(209).height(185); diff --git a/handsontable/src/3rdparty/walkontable/test/spec/viewport.spec.js b/handsontable/src/3rdparty/walkontable/test/spec/viewport.spec.js index b69eb2aa91d..c09569f5e05 100644 --- a/handsontable/src/3rdparty/walkontable/test/spec/viewport.spec.js +++ b/handsontable/src/3rdparty/walkontable/test/spec/viewport.spec.js @@ -417,4 +417,35 @@ describe('WalkontableViewport', () => { expect(wt.wtViewport.hasHorizontalScroll()).toBe(true); }); }); + + describe('hidden container initialization', () => { + it('should not throw when draw(fastDraw=true) is called after container transitions from hidden to visible', async() => { + createDataArray(18, 18); + spec().$wrapper.width(250).height(170).css('display', 'none'); + + const wt = walkontable({ + data: getData, + totalRows: getTotalRows, + totalColumns: getTotalColumns, + }); + + // draw() is interrupted — rowsRenderCalculator and columnsRenderCalculator stay undefined + wt.draw(); + + expect(wt.wtViewport.rowsRenderCalculator).toBeUndefined(); + expect(wt.wtViewport.columnsRenderCalculator).toBeUndefined(); + + // Simulate accordion/tab opening: container becomes visible + spec().$wrapper.css('display', ''); + + // draw(true) triggers areAllProposedVisibleRowsAlreadyRendered() and + // areAllProposedVisibleColumnsAlreadyRendered() — both must not throw despite + // rowsRenderCalculator / columnsRenderCalculator being undefined; + // the guards force a full redraw which sets both calculators + expect(() => wt.draw(true)).not.toThrow(); + + expect(wt.wtViewport.rowsRenderCalculator).toBeDefined(); + expect(wt.wtViewport.columnsRenderCalculator).toBeDefined(); + }); + }); }); From f1ffe8ea6d08e0d75c22cfc312bdd4e2335388dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20M=C4=99dryga=C5=82?= Date: Tue, 12 May 2026 09:32:51 +0200 Subject: [PATCH 05/26] DEV-1556: Use CSS variable for cell function demo background (#12528) Replaces hardcoded background color with `--ht-background-secondary-color` for consistent theming in cell function documentation examples. --- .../guides/cell-functions/cell-function/angular/example1.ts | 2 +- .../guides/cell-functions/cell-function/javascript/example1.css | 2 +- .../guides/cell-functions/cell-function/react/example1.css | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/content/guides/cell-functions/cell-function/angular/example1.ts b/docs/content/guides/cell-functions/cell-function/angular/example1.ts index d81183a82f1..3758aa52fb5 100644 --- a/docs/content/guides/cell-functions/cell-function/angular/example1.ts +++ b/docs/content/guides/cell-functions/cell-function/angular/example1.ts @@ -73,7 +73,7 @@ const stockValidator = (value: Handsontable.CellValue, callback: (valid: boolean .htStockBarTrack { flex: 1; height: 8px; - background: #e5e7eb; + background: var(--ht-background-secondary-color); border-radius: 4px; overflow: hidden; } diff --git a/docs/content/guides/cell-functions/cell-function/javascript/example1.css b/docs/content/guides/cell-functions/cell-function/javascript/example1.css index ebb5c3d65e9..2c25609056f 100644 --- a/docs/content/guides/cell-functions/cell-function/javascript/example1.css +++ b/docs/content/guides/cell-functions/cell-function/javascript/example1.css @@ -10,7 +10,7 @@ .htStockBarTrack { flex: 1; height: 8px; - background: #e5e7eb; + background: var(--ht-background-secondary-color); border-radius: 4px; overflow: hidden; } diff --git a/docs/content/guides/cell-functions/cell-function/react/example1.css b/docs/content/guides/cell-functions/cell-function/react/example1.css index ebb5c3d65e9..2c25609056f 100644 --- a/docs/content/guides/cell-functions/cell-function/react/example1.css +++ b/docs/content/guides/cell-functions/cell-function/react/example1.css @@ -10,7 +10,7 @@ .htStockBarTrack { flex: 1; height: 8px; - background: #e5e7eb; + background: var(--ht-background-secondary-color); border-radius: 4px; overflow: hidden; } From c01ac075752871a143d325dfa958c63e48b12976 Mon Sep 17 00:00:00 2001 From: Adrian Dusinkiewicz <155736789+adrianspdev@users.noreply.github.com> Date: Tue, 12 May 2026 09:59:22 +0200 Subject: [PATCH 06/26] DEV-1646: Docs - Vue 3 component as a custom cell renderer (#10257) (#12525) --- .../vue/example2.html | 3 + .../vue/example2.js | 62 +++++++++++++++++++ .../vue3-custom-renderer-example.md | 18 +++++- 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue/example2.html create mode 100644 docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue/example2.js diff --git a/docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue/example2.html b/docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue/example2.html new file mode 100644 index 00000000000..0e3dc4668aa --- /dev/null +++ b/docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue/example2.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue/example2.js b/docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue/example2.js new file mode 100644 index 00000000000..489c383be9f --- /dev/null +++ b/docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue/example2.js @@ -0,0 +1,62 @@ +import { defineComponent, h, render } from 'vue'; +import { HotTable } from '@handsontable/vue3'; +import { registerAllModules } from 'handsontable/registry'; + +// register Handsontable's modules +registerAllModules(); + +// A reusable Vue 3 component that renders a cell value as an image. +const ImageCell = defineComponent({ + props: { + src: { type: String, required: true }, + }, + render() { + return h('img', { + src: this.src, + onMousedown: (event) => event.preventDefault(), + }); + }, +}); + +// Bridge function that mounts the Vue 3 component into the cell's TD element. +// Vue's `render()` patches the existing tree on subsequent calls, so the +// component instance is reused across re-renders. +function imageComponentRenderer(instance, td, row, col, prop, value) { + const vnode = h(ImageCell, { src: value }); + + render(vnode, td); + + return td; +} + +const ExampleComponent = defineComponent({ + data() { + return { + hotSettings: { + data: [ + ['Professional JavaScript for Web Developers', + '/docs/img/examples/professional-javascript-developers-nicholas-zakas.jpg'], + ['JavaScript: The Good Parts', + '/docs/img/examples/javascript-the-good-parts.jpg'], + ], + columns: [ + {}, + { + renderer: imageComponentRenderer, + }, + ], + colHeaders: true, + rowHeights: 55, + height: 'auto', + autoWrapRow: true, + autoWrapCol: true, + licenseKey: 'non-commercial-and-evaluation', + }, + }; + }, + components: { + HotTable, + }, +}); + +export default ExampleComponent; diff --git a/docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue3-custom-renderer-example.md b/docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue3-custom-renderer-example.md index 771e3bb75aa..54ae999d658 100644 --- a/docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue3-custom-renderer-example.md +++ b/docs/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue3-custom-renderer-example.md @@ -3,7 +3,7 @@ type: tutorial id: uu0rzeo6 title: Custom renderer in Vue 3 metaTitle: Custom cell renderer - Vue 3 Data Grid | Handsontable -description: Create a custom cell renderer, and use it in your Vue 3 data grid by declaring it as a function. +description: Create a custom cell renderer, and use it in your Vue 3 data grid by declaring it as a function or as a Vue 3 component. permalink: /vue3-custom-renderer-example canonicalUrl: /vue3-custom-renderer-example react: @@ -36,6 +36,21 @@ The following example is an implementation of `@handsontable/vue3` with a custom ::: +## Declare a renderer as a Vue 3 component + +You can use a Vue 3 component as a custom cell renderer by mounting it into the cell's TD element from inside the renderer function. Use Vue 3's `render(vnode, container)` API to mount the component imperatively and reuse the same component instance across re-renders -- Vue patches the existing tree instead of remounting. + +The renderer function receives the same arguments as a regular function-based renderer. You build a VNode from your component with `h(Component, props)` and pass it to `render()` together with the TD element. To pass static props alongside cell data, merge them into the second argument of `h()`. + +::: example #example2 :vue3 --html 1 --js 2 + +@[code](@/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue/example2.html) +@[code](@/content/guides/integrate-with-vue3/vue3-custom-renderer-example/vue/example2.js) + +::: + +If your component needs access to a Vue application context -- for example, global components, plugins, or `provide` / `inject` -- create a dedicated app per cell with `createApp(Component, props).mount(td)` instead of `render()`. Track the returned app instances so you can call `app.unmount()` when the grid is destroyed. + ## Related articles **Related guides** @@ -94,6 +109,7 @@ The following example is an implementation of `@handsontable/vue3` with a custom - How to declare a custom renderer function in a Vue 3 application. - How to read cell values and render HTML elements -- such as images -- inside cells. - How to assign the renderer to a specific column using the `renderer` option. +- How to mount a Vue 3 component as a custom cell renderer with `render(vnode, td)`. ## Next steps From 9a440fd3e014f078f9ecc88ac66c1a4f9294d46f Mon Sep 17 00:00:00 2001 From: Adrian Dusinkiewicz <155736789+adrianspdev@users.noreply.github.com> Date: Tue, 12 May 2026 10:18:29 +0200 Subject: [PATCH 07/26] DEV-1629: Fix column/row resize handle drift after scroll with preventOverflow (#12515) * DEV-1629: Fix column/row resize handle drift after scroll with preventOverflow `getRelativeCellPositionWithinWindow` ignored the master holder's internal scroll. When `preventOverflow` forced an overlay onto the window while the master kept scrolling its holder, the resize handle landed offset by the scroll amount, making the column/row impossible to resize. Compensate the spreader-based offsets with the master scroll position only when the master scrolls a holder. When the master itself scrolls the window, the existing math is correct and is preserved unchanged. Closes #10403 * DEV-1629: Add changelog entry for PR #12515 --- .changelogs/12515.json | 8 +++ .../3rdparty/walkontable/src/overlay/_base.js | 12 +++- .../__tests__/manualColumnResize.spec.js | 53 ++++++++++++++++++ .../__tests__/rtl/manualColumnResize.spec.js | 24 ++++++++ .../__tests__/manualRowResize.spec.js | 55 +++++++++++++++++++ 5 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 .changelogs/12515.json diff --git a/.changelogs/12515.json b/.changelogs/12515.json new file mode 100644 index 00000000000..b12eaa36d69 --- /dev/null +++ b/.changelogs/12515.json @@ -0,0 +1,8 @@ +{ + "issuesOrigin": "public", + "title": "Fixed manual column and row resize handle position after scrolling when `preventOverflow` is set.", + "type": "fixed", + "issueOrPR": 12515, + "breaking": false, + "framework": "none" +} diff --git a/handsontable/src/3rdparty/walkontable/src/overlay/_base.js b/handsontable/src/3rdparty/walkontable/src/overlay/_base.js index 40da4a540a5..3600bb267e2 100644 --- a/handsontable/src/3rdparty/walkontable/src/overlay/_base.js +++ b/handsontable/src/3rdparty/walkontable/src/overlay/_base.js @@ -202,11 +202,19 @@ export class Overlay { */ getRelativeCellPositionWithinWindow(onFixedRowTop, onFixedColumn, elementOffset, spreaderOffset) { const absoluteRootElementPosition = this.wot.wtTable.wtRootElement.getBoundingClientRect(); // todo refactoring: DEMETER + // `preventOverflow` can force this overlay onto the window (see `makeClone()`) while the + // master still scrolls its holder. `wtRootElement` does not move with that scroll, so + // subtract the master scroll from spreader-based offsets to align with the visible cell (#10403). + const masterScrollsHolder = this.wot.wtOverlays.scrollableElement !== this.domBindings.rootWindow; + const tableScrollPosition = { + horizontal: masterScrollsHolder ? this.wot.wtOverlays.inlineStartOverlay.getScrollPosition() : 0, + vertical: masterScrollsHolder ? this.wot.wtOverlays.topOverlay.getScrollPosition() : 0, + }; let horizontalOffset = 0; let verticalOffset = 0; if (!onFixedColumn) { - horizontalOffset = spreaderOffset.start; + horizontalOffset = spreaderOffset.start - tableScrollPosition.horizontal; } else { let absoluteRootElementStartPosition = absoluteRootElementPosition.left; @@ -225,7 +233,7 @@ export class Overlay { verticalOffset = absoluteOverlayPosition.top - absoluteRootElementPosition.top; } else { - verticalOffset = spreaderOffset.top; + verticalOffset = spreaderOffset.top - tableScrollPosition.vertical; } return { diff --git a/handsontable/src/plugins/manualColumnResize/__tests__/manualColumnResize.spec.js b/handsontable/src/plugins/manualColumnResize/__tests__/manualColumnResize.spec.js index 609526497b5..3ba8838a952 100644 --- a/handsontable/src/plugins/manualColumnResize/__tests__/manualColumnResize.spec.js +++ b/handsontable/src/plugins/manualColumnResize/__tests__/manualColumnResize.spec.js @@ -1400,4 +1400,57 @@ describe('manualColumnResize', () => { expect(getTopClone().find('table').width()).toBe(getMaster().find('table').width()); }); }); + + describe('with `preventOverflow: \'horizontal\'`', () => { + it('should position the resize handle at the visible column header right edge after horizontal scroll (#10403)', async() => { + handsontable({ + data: createSpreadsheetData(5, 20), + colHeaders: true, + manualColumnResize: true, + preventOverflow: 'horizontal', + width: 400, + height: 200, + }); + + await scrollViewportHorizontally(300); + await waitForNextAnimationFrames(2); + + const $headerTH = getTopClone().find('thead tr:eq(0) th:eq(8)'); + + $headerTH.simulate('mouseover'); + + const $handle = $('.manualColumnResizer'); + + expect($handle.offset().left) + .toEqual($headerTH.offset().left + $headerTH.outerWidth() - ($handle.outerWidth() / 2) - 1); + }); + + it('should resize a column by dragging the handle after horizontal scroll (#10403)', async() => { + handsontable({ + data: createSpreadsheetData(5, 20), + colHeaders: true, + manualColumnResize: true, + preventOverflow: 'horizontal', + width: 400, + height: 200, + }); + + await scrollViewportHorizontally(300); + await waitForNextAnimationFrames(2); + + const $headerTH = getTopClone().find('thead tr:eq(0) th:eq(8)'); + const initialWidth = $headerTH.outerWidth(); + + $headerTH.simulate('mouseover'); + + const $handle = $('.manualColumnResizer'); + const handleOffset = $handle.offset(); + + $handle.simulate('mousedown', { clientX: handleOffset.left }); + $handle.simulate('mousemove', { clientX: handleOffset.left + 30 }); + $handle.simulate('mouseup'); + + expect(colWidth(spec().$container, 8)).toBe(initialWidth + 30); + }); + }); }); diff --git a/handsontable/src/plugins/manualColumnResize/__tests__/rtl/manualColumnResize.spec.js b/handsontable/src/plugins/manualColumnResize/__tests__/rtl/manualColumnResize.spec.js index 2d91f50cd28..d5b0cc1cdcd 100644 --- a/handsontable/src/plugins/manualColumnResize/__tests__/rtl/manualColumnResize.spec.js +++ b/handsontable/src/plugins/manualColumnResize/__tests__/rtl/manualColumnResize.spec.js @@ -150,6 +150,30 @@ describe('manualColumnResize (RTL)', () => { expect($handle.css('z-index')).toBeGreaterThan(getTopClone().css('z-index')); }); + + it('should position the resize handle at the visible column header start edge after horizontal scroll with `preventOverflow: \'horizontal\'` (#10403)', async() => { + handsontable({ + layoutDirection, + data: createSpreadsheetData(5, 20), + colHeaders: true, + manualColumnResize: true, + preventOverflow: 'horizontal', + width: 400, + height: 200, + }); + + await scrollViewportHorizontally(300); + await waitForNextAnimationFrames(2); + + const $headerTH = getTopClone().find('thead tr:eq(0) th:eq(8)'); + + $headerTH.simulate('mouseover'); + + const $handle = $('.manualColumnResizer'); + + expect($handle.offset().left) + .toEqual($headerTH.offset().left - ($handle.outerWidth() / 2) + 1); + }); }); }); }); diff --git a/handsontable/src/plugins/manualRowResize/__tests__/manualRowResize.spec.js b/handsontable/src/plugins/manualRowResize/__tests__/manualRowResize.spec.js index 34122c2126a..1c1b8aeadae 100644 --- a/handsontable/src/plugins/manualRowResize/__tests__/manualRowResize.spec.js +++ b/handsontable/src/plugins/manualRowResize/__tests__/manualRowResize.spec.js @@ -1273,4 +1273,59 @@ describe('manualRowResize', () => { expect(getInlineStartClone().find('table').height()).toBe(getMaster().find('table').height()); }); }); + + describe('with `preventOverflow: \'vertical\'`', () => { + it('should position the resize handle at the visible row header bottom edge after vertical scroll (#10403)', async() => { + handsontable({ + data: createSpreadsheetData(50, 5), + rowHeaders: true, + manualRowResize: true, + preventOverflow: 'vertical', + width: 200, + height: 300, + }); + + await scrollViewportVertically(150); + await waitForNextAnimationFrames(2); + + const $headerTH = getInlineStartClone().find('tbody tr:eq(8) th:eq(0)'); + + $headerTH.simulate('mouseover'); + + const $handle = spec().$container.find('.manualRowResizer'); + + expect($headerTH.offset().top + $headerTH.height() - 5).toBeCloseTo($handle.offset().top, 0); + expect($headerTH.offset().left).toBeCloseTo($handle.offset().left, 0); + }); + + it('should resize a row by dragging the handle after vertical scroll (#10403)', async() => { + handsontable({ + data: createSpreadsheetData(50, 5), + rowHeaders: true, + manualRowResize: true, + preventOverflow: 'vertical', + width: 200, + height: 300, + }); + + await scrollViewportVertically(150); + await waitForNextAnimationFrames(2); + + const $headerTH = getInlineStartClone().find('tbody tr:eq(8) th:eq(0)'); + const initialHeight = $headerTH.outerHeight(); + + $headerTH.simulate('mouseover'); + + const $handle = spec().$container.find('.manualRowResizer'); + const handleOffset = $handle.offset(); + + $handle.simulate('mousedown', { clientY: handleOffset.top }); + $handle.simulate('mousemove', { clientY: handleOffset.top + 20 }); + $handle.simulate('mouseup'); + + const cellCoords = hot().view._wt.wtTable.getCoords($headerTH[0]); + + expect(hot().getRowHeight(cellCoords.row)).toBe(initialHeight + 20); + }); + }); }); From 6476f2db8c154491897b4ccb1fa570060aa2d569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20=E2=80=98Budzio=E2=80=99=20Budnik?= <571316+budnix@users.noreply.github.com> Date: Tue, 12 May 2026 10:45:52 +0200 Subject: [PATCH 08/26] DEV-1630: Unify handsontable/scripts into a single dispatcher with tasks.json (#12511) --- .ai/STACK.md | 3 +- .ai/TESTING.md | 12 +- .../skills/handsontable-e2e-testing/SKILL.md | 12 +- .../skills/handsontable-unit-testing/SKILL.md | 6 +- .claude/skills/node-scripts-dev/SKILL.md | 53 ++- .claude/skills/pr-creation/SKILL.md | 4 +- handsontable/.config/styles-development.js | 2 +- handsontable/.config/walkontable.js | 2 +- handsontable/AGENTS.md | 18 +- handsontable/package.json | 107 +++-- handsontable/scripts/parallel-build.mjs | 266 ------------- handsontable/scripts/run.mjs | 373 ++++++++++++++++++ handsontable/scripts/tasks.json | 245 ++++++++++++ handsontable/scripts/utils/run-step.mjs | 250 ++++++++++++ handsontable/scripts/utils/scheduler.mjs | 113 ++++++ handsontable/test/__mocks__/cssPolyfill.js | 150 ++++--- .../run-puppeteer-on-watchers-change.mjs | 42 +- handsontable/test/scripts/run-puppeteer.mjs | 11 +- wrappers/react-wrapper/package.json | 1 + wrappers/react-wrapper/test/bootstrap.js | 6 +- 20 files changed, 1259 insertions(+), 417 deletions(-) delete mode 100644 handsontable/scripts/parallel-build.mjs create mode 100644 handsontable/scripts/run.mjs create mode 100644 handsontable/scripts/tasks.json create mode 100644 handsontable/scripts/utils/run-step.mjs create mode 100644 handsontable/scripts/utils/scheduler.mjs diff --git a/.ai/STACK.md b/.ai/STACK.md index 0016d238856..b971cca8232 100644 --- a/.ai/STACK.md +++ b/.ai/STACK.md @@ -106,7 +106,8 @@ **SWC pipeline (replaces Babel for everything except Jest):** - Rspack bundles (`rspack.config.js` + `.config/*`) - JS transpiled by `builtin:swc-loader` using targets from `browser-targets.js` - `handsontable/scripts/swc-transpile.mjs` - File-per-file transpiler driven by `@swc/core`; produces the `tmp/` output consumed by wrappers. Handles CJS (`build:commonjs`), ESM (`build:es`, `.mjs` output), and i18n language packs with auto-registration (`build:languages.es --lang-registration`) -- `handsontable/scripts/parallel-build.mjs` - Build orchestrator invoked by `npm run build`; runs the Rspack and SWC tasks above concurrently where the dependency graph allows +- `handsontable/scripts/run.mjs` - Unified dispatcher for all `npm run` scripts; reads task definitions and pipeline graphs from `handsontable/scripts/tasks.json`; runs tasks quietly with spinner on TTY, supports `--parallel` (DAG scheduler) and `--sequential` modes +- `handsontable/scripts/tasks.json` - Single source of truth for all build/lint/test task commands, their `deps`, `mode`, and `cwd`; also defines named `pipelines` (ordered step sequences). Edit this file to add, remove, or change any build/lint/test step — do not add raw shell commands to `package.json` scripts for the core package **Linting:** - `.eslintrc.js` (root) - Monorepo-level ESLint config diff --git a/.ai/TESTING.md b/.ai/TESTING.md index 97377a5ea1c..9ab82a50746 100644 --- a/.ai/TESTING.md +++ b/.ai/TESTING.md @@ -28,18 +28,18 @@ pnpm --filter handsontable run test:unit pnpm --filter handsontable run test:e2e # Run specific unit test pattern (must be run from handsontable/ directory): -npm run test:unit --testPathPattern=cellMeta +npm run test:unit -- --testPathPattern=cellMeta # Run specific E2E test pattern (must be run from handsontable/ directory): +# Pass --testPathPattern and --theme after `--` so they go to the wrapper script, +# not to npm's config system (avoids npm deprecation warning). # The pattern is baked into the Rspack bundle at dump time via __ENV_ARGS__.testPathPattern. -# rspack.config.js copies the lowercase npm_config_testpathpattern to npm_config_testPathPattern -# so the standard npm --key=value syntax works. # Step 1: rebuild the test bundle with the pattern (skips full UMD build): -npm run test:e2e.dump --testPathPattern=filters +npm run test:e2e.dump -- --testPathPattern=filters # Step 2: run puppeteer against the filtered bundle: npm run test:e2e.puppeteer # Or, to do both in one command (also rebuilds UMD bundles): -npm run test:e2e --testPathPattern=filters +npm run test:e2e -- --testPathPattern=filters # NOTE: changing the pattern requires re-running test:e2e.dump — the pattern is compiled in. # NOTE: when invoking the two steps separately, pass --testPathPattern AND --theme to BOTH # commands. Each `npm run` is a separate npm process with its own env; puppeteer computes @@ -56,7 +56,7 @@ npm run test:unit -- --coverage - Per-run Puppeteer runner: `handsontable/test/E2ERunner-.html` - Generic dev runner (always regenerated alongside): `handsontable/test/E2ERunner.html` -`run-puppeteer.mjs` computes the same hash from `npm_config_testpathpattern` / `npm_config_theme` and opens the matching HTML. It also binds the local HTTP server to the first free port starting at `8086` (retries on `EADDRINUSE`, up to 100 ports), so each concurrent run gets its own port. Any number of `npm run test:e2e --testPathPattern=` invocations with distinct patterns (or themes) can run in parallel without further configuration -- the practical limit is machine resources, not the tooling. +`run-puppeteer.mjs` computes the same hash from `npm_config_testpathpattern` / `npm_config_theme` (env vars set by the `test-e2e.mjs` wrapper) and opens the matching HTML. It also binds the local HTTP server to the first free port starting at `8086` (retries on `EADDRINUSE`, up to 100 ports), so each concurrent run gets its own port. Any number of `npm run test:e2e -- --testPathPattern=` invocations with distinct patterns (or themes) can run in parallel without further configuration -- the practical limit is machine resources, not the tooling. The helper that derives the hash lives in `handsontable/.config/helper/run-id.js` -- used by both the Rspack config and the Puppeteer script so they stay in lockstep. diff --git a/.claude/skills/handsontable-e2e-testing/SKILL.md b/.claude/skills/handsontable-e2e-testing/SKILL.md index cb585125020..3bd9f6d6c0e 100644 --- a/.claude/skills/handsontable-e2e-testing/SKILL.md +++ b/.claude/skills/handsontable-e2e-testing/SKILL.md @@ -131,23 +131,23 @@ Use `it.flaky()` for timing-sensitive tests (auto-retries up to 3 times). ## Run commands - **All:** `npm run test:e2e --prefix handsontable` -- **Targeted:** `npm run test:e2e --testPathPattern= --prefix handsontable` -- the pattern is matched against test file paths during the Rspack `.dump` step (e.g. `collapsibleColumns`, `ghostTable`, `textEditor`, `nestedHeaders/__tests__/hidingColumns`) -- **With theme:** `npm run test:e2e --testPathPattern= --theme=horizon --prefix handsontable` (available themes: `classic`, `main`, `horizon`; default when `--theme` is omitted: `main`) +- **Targeted:** `npm run test:e2e --prefix handsontable -- --testPathPattern=` -- the pattern is matched against test file paths during the Rspack `.dump` step (e.g. `collapsibleColumns`, `ghostTable`, `textEditor`, `nestedHeaders/__tests__/hidingColumns`) +- **With theme:** `npm run test:e2e --prefix handsontable -- --testPathPattern= --theme=horizon` (available themes: `classic`, `main`, `horizon`; default when `--theme` is omitted: `main`) - **Rebuild first:** The E2E runner loads `dist/handsontable.js`. After changing `src/**`, run `npm run build --prefix handsontable` before running E2E tests. -**Important:** Do NOT use `--` before `--testPathPattern`. The flag is consumed by npm during the `.dump` step (Rspack build), not by Puppeteer. Using `npm run test:e2e -- --testPathPattern=...` passes it only to the Puppeteer runner, which doesn't support it. +**Always use `--` before `--testPathPattern` and `--theme`.** The `test:e2e` script is now a Node.js wrapper (`scripts/test-e2e.mjs`) that reads these flags from `process.argv` and propagates them as env vars to both the dump step and Puppeteer. Passing them without `--` triggers an npm deprecation warning and will stop working in a future npm major. -**Parallel runs:** Multiple `npm run test:e2e --testPathPattern=` invocations with different patterns (or themes) can run simultaneously. The dump step hashes `testPathPattern + theme` into a short run ID and writes per-run artifacts (`test/dist/main.entry..js` and `test/E2ERunner-.html`), and the Puppeteer runner picks its own free port starting at `8086` (retries up to 100 ports). Nothing special needs to be passed -- just launch the commands; the practical limit is machine resources, not the tooling. +**Parallel runs:** Multiple `npm run test:e2e --prefix handsontable -- --testPathPattern=` invocations with different patterns (or themes) can run simultaneously. The dump step hashes `testPathPattern + theme` into a short run ID and writes per-run artifacts (`test/dist/main.entry..js` and `test/E2ERunner-.html`), and the Puppeteer runner picks its own free port starting at `8086` (retries up to 100 ports). Nothing special needs to be passed -- just launch the commands; the practical limit is machine resources, not the tooling. **Iterating on a single area:** Prefer `test:e2e.watch` -- it leaves the dev server running and re-bundles + re-runs on every source change, so you don't have to stop and restart between edits: ```bash -npm run test:e2e.watch --testPathPattern=filters --theme=horizon +npm run test:e2e.watch --prefix handsontable -- --testPathPattern=filters --theme=horizon ``` Under the hood it spawns the regular Rspack dump in `--watch` mode and reopens the browser page, reusing the generic `test/E2ERunner.html` (no run ID needed -- the dump and puppeteer halves share one npm process, so the flags propagate automatically). -**One-shot run:** Use the combined `npm run test:e2e --testPathPattern= --theme=` -- a single npm invocation passes the flags to both dump and puppeteer via env, so there's no risk of a mismatch. +**One-shot run:** Use `npm run test:e2e --prefix handsontable -- --testPathPattern= --theme=` -- the wrapper script passes the flags to both dump and puppeteer via env, so there's no risk of a mismatch. **Split dump + puppeteer** (what CI does): if you invoke the two steps in separate `npm run` commands, pass `--testPathPattern` AND `--theme` to **both**. Each `npm run` is its own npm process with its own env, and the Puppeteer script recomputes the same hash as dump to find the runner HTML -- a mismatch fails with "Runner HTML not found at ...". `.github/workflows/test.yml` is the canonical example; the same rule applies to `test:production.dump` + `test:e2e.puppeteer`. diff --git a/.claude/skills/handsontable-unit-testing/SKILL.md b/.claude/skills/handsontable-unit-testing/SKILL.md index e566600c4d4..32e7495172e 100644 --- a/.claude/skills/handsontable-unit-testing/SKILL.md +++ b/.claude/skills/handsontable-unit-testing/SKILL.md @@ -40,10 +40,8 @@ For custom mocking, use `jest.fn()` for stubs and `jest.spyOn(object, 'method')` ## Run Commands - **All unit tests:** `npm run test:unit --prefix handsontable` -- **Targeted:** `npm run test:unit --testPathPattern= --prefix handsontable` -- the pattern is matched against test file paths (e.g. `filters`, `ghostTable.unit`, `metaManager`) -- **Example:** `npm run test:unit --testPathPattern=filters --prefix handsontable` - -**Important:** Do NOT use `--` before `--testPathPattern`. The flag is consumed by npm's script runner, not by Jest directly. +- **Targeted:** `npm run test:unit --prefix handsontable -- --testPathPattern=` -- the pattern is matched against test file paths (e.g. `filters`, `ghostTable.unit`, `metaManager`) +- **Example:** `npm run test:unit --prefix handsontable -- --testPathPattern=filters` ## Large Dataset Testing diff --git a/.claude/skills/node-scripts-dev/SKILL.md b/.claude/skills/node-scripts-dev/SKILL.md index a8e2ded8c25..0f6145ffdfe 100644 --- a/.claude/skills/node-scripts-dev/SKILL.md +++ b/.claude/skills/node-scripts-dev/SKILL.md @@ -11,9 +11,60 @@ All Node.js-side code in the monorepo -- scripts, utilities, and library modules - **Extension:** Always `.mjs` (never `.js` or `.cjs` for Node.js-side code). - **Location:** `scripts/` for CLI-invoked scripts. `lib/` for shared utilities and library modules. Package-specific paths are fine (e.g., `performance-tests/lib/`, `wrappers/react-wrapper/scripts/`). -- **Invocation:** `node scripts/your-script.mjs` from `package.json` scripts. +- **Invocation:** `node scripts/your-script.mjs` from `package.json` scripts, or as a `cmd` value in `handsontable/scripts/tasks.json` (see below). - **Scope:** These conventions apply to all `.mjs` files -- standalone scripts, library modules, Playwright helpers, build tooling, etc. +## Adding npm scripts to the handsontable core package + +The `handsontable/` package uses a unified dispatcher. **Do not add raw shell commands directly to `handsontable/package.json` scripts.** Instead: + +1. Add the task to `handsontable/scripts/tasks.json`: + +```json +"my-task": { + "cmd": "node scripts/my-script.mjs", + "deps": ["build:styles"], + "mode": "inherit" +} +``` + +2. Add a thin shim to `package.json` that delegates to the dispatcher: + +```json +"my-task": "node scripts/run.mjs my-task" +``` + +### tasks.json schema + +| Field | Required | Values | Purpose | +|-------|----------|--------|---------| +| `cmd` | yes | shell string | Command run via `spawn(..., { shell: true })` | +| `deps` | no | task name array | Tasks that must complete first (resolved by DAG scheduler in parallel mode; resolved sequentially in direct invocation mode) | +| `mode` | no | `quiet` (default) \| `inherit` \| `interactive` | `quiet` = suppress output with spinner; `inherit` = stream output (linters); `interactive` = full TTY pass-through (Jest) | +| `cwd` | no | path relative to `handsontable/` | Working directory override | +| `passthrough` | no | boolean | Append extra CLI flags (after `--`) to the cmd | +| `note` | no | string | Human annotation only, ignored at runtime | + +### Pipeline definitions + +To group tasks into an ordered pipeline (e.g., a build or test sequence), add to the `pipelines` block: + +```json +"pipelines": { + "my-pipeline": { + "before": ["clean"], + "tasks": ["my-task", "other-task"], + "after": ["prepare-package-for-publish"] + } +} +``` + +`before` and `after` steps run sequentially. `tasks` run sequentially with `--sequential` or via DAG with `--parallel`. + +### Other packages + +For wrapper packages (`wrappers/react-wrapper/`, `wrappers/angular-wrapper/`, `wrappers/vue3/`) and other monorepo packages (`performance-tests/`, `visual-tests/`), add directly to that package's `package.json` scripts as usual — those packages do not use the `run.mjs` dispatcher. + ## Native module imports Always use the `node:` protocol prefix for built-in modules. This makes it explicit that the import is a Node.js built-in, not a third-party package. diff --git a/.claude/skills/pr-creation/SKILL.md b/.claude/skills/pr-creation/SKILL.md index b5c7b85b3af..1f0aacb2fe5 100644 --- a/.claude/skills/pr-creation/SKILL.md +++ b/.claude/skills/pr-creation/SKILL.md @@ -37,10 +37,10 @@ npm run stylelint --prefix handsontable npm run build --prefix handsontable # Unit tests for the area you changed -npm run test:unit --testPathPattern= --prefix handsontable +npm run test:unit --prefix handsontable -- --testPathPattern= # E2E tests for the area you changed -npm run test:e2e --testPathPattern= --prefix handsontable +npm run test:e2e --prefix handsontable -- --testPathPattern= # If you touched a wrapper, test it too npm run test --prefix wrappers/react-wrapper diff --git a/handsontable/.config/styles-development.js b/handsontable/.config/styles-development.js index b3cc14f4ec8..f74a9aabf4b 100644 --- a/handsontable/.config/styles-development.js +++ b/handsontable/.config/styles-development.js @@ -24,7 +24,7 @@ module.exports.create = function create(envArgs) { use: [ { loader: rspack.CssExtractRspackPlugin.loader }, { loader: 'css-loader' }, - { loader: 'sass-loader' }, + { loader: 'sass-loader', options: { sassOptions: { silenceDeprecations: ['legacy-js-api'] } } }, ] } ], diff --git a/handsontable/.config/walkontable.js b/handsontable/.config/walkontable.js index b792158de10..1b9c72e9f16 100644 --- a/handsontable/.config/walkontable.js +++ b/handsontable/.config/walkontable.js @@ -44,7 +44,7 @@ module.exports.create = function create() { use: [ { loader: rspack.CssExtractRspackPlugin.loader }, { loader: 'css-loader' }, - { loader: 'sass-loader'}, + { loader: 'sass-loader', options: { sassOptions: { silenceDeprecations: ['legacy-js-api'] } } }, { loader: path.resolve(__dirname, 'loader/sass-rtl-loader.js')} ] }, diff --git a/handsontable/AGENTS.md b/handsontable/AGENTS.md index 4c648513a84..7a8d4f3fc6d 100644 --- a/handsontable/AGENTS.md +++ b/handsontable/AGENTS.md @@ -52,9 +52,9 @@ For server-backed grids (`dataProvider` with `fetchRows` and CRUD callbacks), en - ALL `it()` callbacks in spec files MUST be `async` - HOT API calls MUST be `await`-ed - E2E helpers are globals (no imports): `handsontable()`, `selectCell()`, `getDataAtCell()`, `createSpreadsheetData()` -- Targeted unit: `npm run test:unit --testPathPattern=` (regex matched against file paths, e.g. `filters`, `ghostTable.unit`) -- Targeted e2e: `npm run test:e2e --testPathPattern=` (e.g. `collapsibleColumns`, `textEditor`, `nestedHeaders/__tests__/hidingColumns`) -- E2E with theme: `npm run test:e2e --testPathPattern= --theme=horizon` (themes: `classic`, `main`, `horizon`; default: `main`) +- Targeted unit: `npm run test:unit -- --testPathPattern=` (regex matched against file paths, e.g. `filters`, `ghostTable.unit`) +- Targeted e2e: `npm run test:e2e -- --testPathPattern=` (e.g. `collapsibleColumns`, `textEditor`, `nestedHeaders/__tests__/hidingColumns`) +- E2E with theme: `npm run test:e2e -- --testPathPattern= --theme=horizon` (themes: `classic`, `main`, `horizon`; default: `main`) - **Rebuild before E2E:** E2E runner loads `dist/handsontable.js` - rebuild after changing `src/` ## Common Pitfalls @@ -112,6 +112,18 @@ npm run lint Two build variants: `handsontable.js` (base, external deps) and `handsontable.full.js` (includes HyperFormula). The E2E runner loads `dist/handsontable.js` - rebuild after changing `src/`. +## Build and test scripts + +All `npm run` entries are thin shims that delegate to `scripts/run.mjs`. Task commands and pipeline dependency graphs live in `scripts/tasks.json` - edit that file to add, remove, or modify any build/lint/test step. The dispatcher supports three modes: + +``` +node scripts/run.mjs # run one task +node scripts/run.mjs --sequential # run pipeline steps in order +node scripts/run.mjs --parallel # run pipeline with DAG scheduler (used by build) +``` + +Extra args after `--` flow through to tasks with `"passthrough": true` in `tasks.json`. `--testPathPattern=` and `--theme=` are also propagated as env vars to all pipeline tasks so the dump step and Puppeteer compute the same run-ID filename. + ## For Deeper Guidance Use these skills for detailed workflow instructions: diff --git a/handsontable/package.json b/handsontable/package.json index dbc5132fa7b..8bb2fbcf837 100644 --- a/handsontable/package.json +++ b/handsontable/package.json @@ -17,61 +17,60 @@ "jsdelivr": "dist/handsontable.full.min.js", "unpkg": "dist/handsontable.full.min.js", "scripts": { - "clean": "rimraf commonjs es coverage tmp tmp_styles", - "lint": "npm run build:styles && npm run eslint && npm run type-lint && npm run stylelint", - "lint:fix": "npm run build:styles && npm run eslint:fix && npm run stylelint:fix && npm run type-lint:fix", - "stylelint": "stylelint --cache \"src/**/*.{css,scss}\" \"test/**/*.{css,scss}\"", - "stylelint:fix": "stylelint --fix \"src/**/*.{css,scss}\" \"test/**/*.{css,scss}\"", - "eslint": "eslint --cache src/ test/ .config/plugin scripts/", - "eslint:fix": "eslint --fix src/ test/ .config/plugin scripts/", - "type-lint": "cd types && eslint \"./**/*.d.ts\" --config \"./.eslintrc.js\"", - "type-lint:fix": "cd types && eslint --fix \"./**/*.d.ts\" --config \"./.eslintrc.js\"", - "measure:module-sizes": "node scripts/measure-module-sizes.mjs", - "test": "npm run lint && npm run test:unit && npm run test:types && npm run test:walkontable && npm run test:e2e && npm run test:production", - "test.random": "npm run lint && npm run test:unit && npm run test:types && npm run test:walkontable.random && npm run test:e2e.random && npm run test:production.random", - "test:walkontable": "npm run build:walkontable && npm run test:walkontable.dump && npm run test:walkontable.puppeteer", - "test:walkontable.random": "npm run build:walkontable && npm run test:walkontable.dump && npm run test:walkontable.puppeteer -- --random", - "test:walkontable.watch": "node ./test/scripts/run-puppeteer-on-watchers-change.mjs --cmdToListen \"npm run build:walkontable -- --watch\" --cmdToListen \"npm run test:walkontable.dump -- --watch\" --runnerFile \"src/3rdparty/walkontable/test/SpecRunner.html\"", - "test:walkontable.dump": "cross-env-shell BABEL_ENV=commonjs_e2e NODE_ENV=test-walkontable env-cmd -f ../hot.config.js rspack ./src/3rdparty/walkontable/test/helpers/index.js ./src/3rdparty/walkontable/test/spec/index.js", - "test:walkontable.puppeteer": "node test/scripts/run-puppeteer.mjs src/3rdparty/walkontable/test/SpecRunner.html", - "test:production": "npm run build:umd.min && npm run build:languages.min && npm run build:themes-umd.min && npm run test:production.dump && npm run test:e2e.puppeteer", - "test:production.random": "npm run build:umd.min && npm run build:languages.min && npm run build:themes-umd.min && npm run test:production.dump && npm run test:e2e.puppeteer -- --random", - "test:production.dump": "npm run build:styles && cross-env-shell BABEL_ENV=commonjs_e2e NODE_ENV=test-production env-cmd -f ../hot.config.js rspack ./test/helpers/index.js ./test/e2e/index.js", - "test:e2e": "npm run build:umd && npm run build:languages && npm run build:themes-umd && npm run test:e2e.dump && npm run test:e2e.puppeteer", - "test:e2e.random": "npm run build:umd && npm run build:languages && npm run build:themes-umd && npm run test:e2e.dump && npm run test:e2e.puppeteer -- --random", - "test:e2e.verbose": "npm run build:umd && npm run build:languages && npm run build:themes-umd && npm run test:e2e.dump && npm run test:e2e.puppeteer -- --verbose", - "test:e2e.watch": "node ./test/scripts/run-puppeteer-on-watchers-change.mjs --cmdToListen \"npm run watch\" --cmdToListen \"npm run test:e2e.dump -- --watch\"", - "test:e2e.dump": "npm run build:styles && cross-env-shell BABEL_ENV=commonjs_e2e NODE_ENV=test-e2e env-cmd -f ../hot.config.js rspack ./test/helpers/index.js ./test/e2e/index.js", - "test:e2e.dump.esm-cjs": "cross-env-shell BABEL_ENV=commonjs_e2e NODE_ENV=test-e2e-esm-cjs env-cmd -f ../hot.config.js rspack ./test/helpers/index.js ./test/e2e/index.js", - "test:e2e.puppeteer": "node test/scripts/run-puppeteer.mjs", - "test:unit": "npm run build:styles && cross-env-shell BABEL_ENV=commonjs env-cmd -f ../hot.config.js jest --testPathPattern=${npm_config_testpathpattern:-.}", - "test:unit.watch": "npm run build:styles && cross-env-shell BABEL_ENV=commonjs env-cmd -f ../hot.config.js jest --testPathPattern=${npm_config_testpathpattern:-.} --watch", - "test:mobile.dump": "npm run build:styles && cross-env-shell BABEL_ENV=commonjs_e2e NODE_ENV=test-mobile env-cmd --no-override -f ../hot.config.js rspack ./test/helpers/index.js ./test/e2e/mobile/index.js", - "test:types": "tsc -p ./test/types -t es2015", - "watch": "npm run build:styles && cross-env-shell BABEL_ENV=commonjs NODE_ENV=watch env-cmd -f ../hot.config.js rspack --watch ./src/index.js", - "build": "npm run clean && node scripts/parallel-build.mjs", - "build:sequential": "npm run clean && npm run build:umd && npm run build:umd.min && npm run build:commonjs && npm run build:es && npm run build:languages && npm run build:languages.es && npm run build:languages.min && npm run build:themes-umd && npm run build:themes-umd.min", - "build:commonjs": "env-cmd -f ../hot.config.js node scripts/swc-transpile.mjs --format commonjs --out-dir tmp", - "build:es": "npm run build:styles && env-cmd -f ../hot.config.js node scripts/swc-transpile.mjs --format esm --out-dir tmp --out-ext .mjs", - "build:umd": "npm run build:all.styles && cross-env-shell BABEL_ENV=commonjs NODE_ENV=development env-cmd -f ../hot.config.js rspack ./src/index.js", - "build:umd-from-esm": "npm run build:all.styles && cross-env-shell BABEL_ENV=commonjs NODE_ENV=development env-cmd -f ../hot.config.js rspack ./tmp/index.mjs", - "build:umd-from-cjs": "npm run build:all.styles && cross-env-shell BABEL_ENV=commonjs NODE_ENV=development env-cmd -f ../hot.config.js rspack ./tmp/index.js", - "build:umd.min": "npm run build:all.styles.min && cross-env-shell BABEL_ENV=commonjs NODE_ENV=production env-cmd -f ../hot.config.js rspack ./src/index.js", - "build:all.styles": "npm run build:styles && npm run build:themes-css", - "build:all.styles.min": "npm run build:styles.min && npm run build:themes-css.min", - "build:styles": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=styles-development env-cmd -f ../hot.config.js rspack", - "build:styles.min": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=styles-production env-cmd -f ../hot.config.js rspack", - "build:themes-css": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=themes-css-development env-cmd -f ../hot.config.js rspack", - "build:themes-css.min": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=themes-css-production env-cmd -f ../hot.config.js rspack", - "build:themes-umd": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=themes-umd-development env-cmd -f ../hot.config.js rspack", - "build:themes-umd.min": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=themes-umd-production env-cmd -f ../hot.config.js rspack", - "build:walkontable": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=walkontable env-cmd -f ../hot.config.js rspack ./src/3rdparty/walkontable/css/walkontable.scss ./src/3rdparty/walkontable/src/index.js", - "build:languages": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=languages-development env-cmd -f ../hot.config.js rspack", - "build:languages.es": "env-cmd -f ../hot.config.js node scripts/swc-transpile.mjs --format esm --src-dir src/i18n/languages --out-dir languages --out-ext .mjs --lang-registration", - "build:languages.min": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=languages-production env-cmd -f ../hot.config.js rspack", + "clean": "node scripts/run.mjs clean", + "lint": "node scripts/run.mjs --parallel lint", + "lint:fix": "node scripts/run.mjs --sequential lint:fix", + "eslint": "node scripts/run.mjs lint:eslint", + "eslint:fix": "node scripts/run.mjs lint:eslint.fix", + "stylelint": "node scripts/run.mjs lint:stylelint", + "stylelint:fix": "node scripts/run.mjs lint:stylelint.fix", + "type-lint": "node scripts/run.mjs lint:types", + "type-lint:fix": "node scripts/run.mjs lint:types.fix", + "measure:module-sizes": "node scripts/run.mjs measure:module-sizes", + "test": "node scripts/run.mjs --sequential test", + "test.random": "node scripts/run.mjs --sequential test -- --random", + "test:walkontable": "node scripts/run.mjs --sequential test:walkontable", + "test:walkontable.random": "node scripts/run.mjs --sequential test:walkontable -- --random", + "test:walkontable.watch": "node scripts/run.mjs test:walkontable.watch", + "test:walkontable.dump": "node scripts/run.mjs test:walkontable.dump", + "test:walkontable.puppeteer": "node scripts/run.mjs test:walkontable.puppeteer", + "test:production": "node scripts/run.mjs --sequential test:production", + "test:production.random": "node scripts/run.mjs --sequential test:production -- --random", + "test:production.dump": "node scripts/run.mjs test:production.dump", + "test:e2e": "node scripts/run.mjs --sequential test:e2e", + "test:e2e.random": "node scripts/run.mjs --sequential test:e2e -- --random", + "test:e2e.verbose": "node scripts/run.mjs --sequential test:e2e -- --verbose", + "test:e2e.watch": "node scripts/run.mjs test:e2e.watch", + "test:e2e.dump": "node scripts/run.mjs test:e2e.dump", + "test:e2e.dump.esm-cjs": "node scripts/run.mjs test:e2e.dump.esm-cjs", + "test:e2e.puppeteer": "node scripts/run.mjs test:e2e.puppeteer", + "test:unit": "node scripts/run.mjs --sequential test:unit", + "test:unit.jest": "node scripts/run.mjs test:unit.jest", + "test:unit.watch": "node scripts/run.mjs --sequential test:unit -- --watch", + "test:mobile.dump": "node scripts/run.mjs test:mobile.dump", + "test:types": "node scripts/run.mjs test:types", + "watch": "node scripts/run.mjs --sequential watch", + "build": "node scripts/run.mjs --parallel build", + "postbuild": "node scripts/run.mjs prepare-package-for-publish", + "build:sequential": "node scripts/run.mjs --sequential build", + "build:commonjs": "node scripts/run.mjs build:commonjs", + "build:es": "node scripts/run.mjs build:es", + "build:umd": "node scripts/run.mjs build:umd", + "build:umd-from-esm": "node scripts/run.mjs build:umd-from-esm", + "build:umd-from-cjs": "node scripts/run.mjs build:umd-from-cjs", + "build:umd.min": "node scripts/run.mjs build:umd.min", + "build:styles": "node scripts/run.mjs build:styles", + "build:styles.min": "node scripts/run.mjs build:styles.min", + "build:themes-css": "node scripts/run.mjs build:themes-css", + "build:themes-css.min": "node scripts/run.mjs build:themes-css.min", + "build:themes-umd": "node scripts/run.mjs build:themes-umd", + "build:themes-umd.min": "node scripts/run.mjs build:themes-umd.min", + "build:walkontable": "node scripts/run.mjs build:walkontable", + "build:languages": "node scripts/run.mjs build:languages", + "build:languages.es": "node scripts/run.mjs build:languages.es", + "build:languages.min": "node scripts/run.mjs build:languages.min", "publish-package": "cd tmp && npm publish", - "create-package": "cd tmp && npm pack", - "postbuild": "node ./scripts/prepare-package-for-publish.mjs" + "create-package": "cd tmp && npm pack" }, "keywords": [ "data", diff --git a/handsontable/scripts/parallel-build.mjs b/handsontable/scripts/parallel-build.mjs deleted file mode 100644 index 6b8505b9bc4..00000000000 --- a/handsontable/scripts/parallel-build.mjs +++ /dev/null @@ -1,266 +0,0 @@ -/** - * Parallel build orchestrator for Handsontable. - * Runs build steps concurrently where the dependency graph allows. - * - * Usage: node scripts/parallel-build.mjs - */ -import { spawn } from 'node:child_process'; -import { performance } from 'node:perf_hooks'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const currentDir = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(currentDir, '..'); -const BIN = resolve(ROOT, 'node_modules', '.bin'); -const PATH_SEP = process.platform === 'win32' ? ';' : ':'; -const envPATH = [BIN, resolve(ROOT, '..', 'node_modules', '.bin'), process.env.PATH].join(PATH_SEP); - -// Build task definitions: { cmd, deps } -// eslint-disable-next-line quote-props -const tasks = { - // Styles (no deps) - 'styles': { // eslint-disable-line quote-props - cmd: 'cross-env-shell BABEL_ENV=commonjs NODE_ENV=styles-development env-cmd -f ../hot.config.js rspack', - deps: [], - }, - 'styles.min': { - cmd: 'cross-env-shell BABEL_ENV=commonjs NODE_ENV=styles-production env-cmd -f ../hot.config.js rspack', - deps: ['styles'], // must run after styles to avoid race on src/styles/handsontableStyles.js - }, - 'themes-css': { - cmd: 'cross-env-shell BABEL_ENV=commonjs NODE_ENV=themes-css-development env-cmd -f ../hot.config.js rspack', - deps: [], - }, - 'themes-css.min': { - cmd: 'cross-env-shell BABEL_ENV=commonjs NODE_ENV=themes-css-production env-cmd -f ../hot.config.js rspack', - deps: [], - }, - - // Transpilation builds (need styles.min to finalize handsontableStyles.js before reading src/) - 'commonjs': { // eslint-disable-line quote-props - cmd: 'env-cmd -f ../hot.config.js node scripts/swc-transpile.mjs --format commonjs --out-dir tmp', - deps: ['styles.min'], - }, - 'languages': { // eslint-disable-line quote-props - cmd: 'cross-env-shell BABEL_ENV=commonjs NODE_ENV=languages-development env-cmd -f ../hot.config.js rspack', - deps: [], - }, - 'languages.es': { - cmd: 'env-cmd -f ../hot.config.js node scripts/swc-transpile.mjs --format esm' - + ' --src-dir src/i18n/languages --out-dir languages --out-ext .mjs --lang-registration', - deps: [], - }, - 'languages.min': { - cmd: 'cross-env-shell BABEL_ENV=commonjs NODE_ENV=languages-production env-cmd -f ../hot.config.js rspack', - deps: [], - }, - 'themes-umd': { - cmd: 'cross-env-shell BABEL_ENV=commonjs NODE_ENV=themes-umd-development env-cmd -f ../hot.config.js rspack', - deps: [], - }, - 'themes-umd.min': { - cmd: 'cross-env-shell BABEL_ENV=commonjs NODE_ENV=themes-umd-production env-cmd -f ../hot.config.js rspack', - deps: [], - }, - - // UMD builds (need styles + themes-css) - 'umd': { // eslint-disable-line quote-props - cmd: 'cross-env-shell BABEL_ENV=commonjs NODE_ENV=development env-cmd -f ../hot.config.js rspack ./src/index.js', - deps: ['styles', 'themes-css'], - }, - 'umd.min': { - cmd: 'cross-env-shell BABEL_ENV=commonjs NODE_ENV=production env-cmd -f ../hot.config.js rspack ./src/index.js', - deps: ['styles.min', 'themes-css.min'], - }, - - // ES module build (needs styles.min to finalize handsontableStyles.js) - 'es': { // eslint-disable-line quote-props - cmd: 'env-cmd -f ../hot.config.js node scripts/swc-transpile.mjs --format esm --out-dir tmp --out-ext .mjs', - deps: ['styles.min'], - }, -}; - -/** - * Spawn a build task and return a promise that resolves with timing info. - * - * @param {string} name Task name from the tasks map. - * @returns {Promise<{name: string, elapsed: number}>} Resolves on success. - */ -function runTask(name) { - return new Promise((taskResolve, taskReject) => { - const start = performance.now(); - const child = spawn(tasks[name].cmd, [], { - cwd: ROOT, - env: { ...process.env, PATH: envPATH }, - // We only read stderr for compact failure diagnostics. - // Keep stdout ignored to avoid deadlock if a child writes enough data - // to fill the pipe buffer while parent never consumes it. - stdio: ['ignore', 'ignore', 'pipe'], - shell: true, - }); - - let stderr = ''; - - child.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - child.on('close', (code) => { - const elapsed = Math.round(performance.now() - start); - - if (code !== 0) { - // eslint-disable-next-line no-console - console.log(` \x1b[31m✗\x1b[0m ${name} FAILED (${elapsed}ms)`); - - if (stderr) { - // eslint-disable-next-line no-console - console.error(` ${stderr.trim().split('\n').slice(-3).join('\n ')}`); - } - taskReject(new Error(`${name} failed with code ${code}`)); - } else { - // eslint-disable-next-line no-console - console.log(` \x1b[32m✓\x1b[0m ${name} (${elapsed}ms)`); - taskResolve({ name, elapsed }); - } - }); - }); -} - -/** - * Run all build tasks respecting the dependency graph with maximum parallelism. - */ -async function build() { - const totalStart = performance.now(); - const taskNames = Object.keys(tasks); - const completed = new Set(); - const running = new Map(); // name -> Promise - const taskTimes = {}; - - // Validate the dependency graph up front: every `deps` entry must reference - // an existing task. A missing dep (typo, stale rename) would otherwise leave - // the dependent task permanently unready, the loop would break out with - // running.size === 0, and the build would falsely report success. - const unknownDeps = []; - - taskNames.forEach((name) => { - tasks[name].deps.forEach((dep) => { - if (!Object.prototype.hasOwnProperty.call(tasks, dep)) { - unknownDeps.push(`${name} -> "${dep}"`); - } - }); - }); - - if (unknownDeps.length > 0) { - throw new Error( - `Unknown task dependency(ies): ${unknownDeps.join(', ')}. ` - + `Known tasks: ${taskNames.join(', ')}.` - ); - } - - // eslint-disable-next-line no-console - console.log('Building Handsontable (parallel)...\n'); - - while (completed.size < taskNames.length) { - // Find tasks that can start now - const ready = taskNames.filter(name => - !completed.has(name) && - !running.has(name) && - tasks[name].deps.every(dep => completed.has(dep)) - ); - - // Launch all ready tasks - ready.forEach((name) => { - const promise = runTask(name).then( - (result) => { - taskTimes[name] = result.elapsed; - completed.add(name); - running.delete(name); - }, - (err) => { - running.delete(name); - throw err; - } - ); - - running.set(name, promise); - }); - - if (running.size === 0) { - // No ready task and nothing running, yet the completed set is short of - // every declared task. Upfront validation should prevent this, but guard - // against future cycles or logic bugs sneaking in -- a silent success - // here would ship an incomplete build. - const pending = taskNames.filter(n => !completed.has(n)); - - throw new Error( - `Build stalled: ${pending.length} task(s) never ran (${pending.join(', ')}). ` - + 'Check the dependency graph for cycles or unresolved deps.' - ); - } - - // Wait for at least one running task to complete - try { - await Promise.race(running.values()); - } catch (err) { - // Kill remaining tasks and exit - const totalElapsed = Math.round(performance.now() - totalStart); - - // eslint-disable-next-line no-console - console.error(`\n\x1b[31mBuild failed after ${totalElapsed}ms\x1b[0m`); - process.exit(1); - } - } - - const totalElapsed = Math.round(performance.now() - totalStart); - - // eslint-disable-next-line no-console - console.log(`\n\x1b[32mBuild complete in ${totalElapsed}ms (${(totalElapsed / 1000).toFixed(1)}s)\x1b[0m`); - - // Show critical path - /** - * Calculate the total time for a task including its dependency chain. - * - * @param {string} name Task name. - * @returns {number} Total path time in milliseconds. - */ - function pathTime(name) { - const depMax = tasks[name].deps.length > 0 - ? Math.max(...tasks[name].deps.map(pathTime)) - : 0; - - return (taskTimes[name] || 0) + depMax; - } - - let maxTask = ''; - let maxTime = 0; - - taskNames.forEach((name) => { - const t = pathTime(name); - - if (t > maxTime) { - maxTime = t; - maxTask = name; - } - }); - - const critPath = []; - let current = maxTask; - - critPath.push(current); - - while (tasks[current].deps.length > 0) { - const deps = tasks[current].deps; - - current = deps.reduce((a, b) => (pathTime(a) > pathTime(b) ? a : b)); - critPath.unshift(current); - } - - // eslint-disable-next-line no-console - console.log(`Critical path: ${critPath.join(' -> ')} (${maxTime}ms theoretical minimum)`); -} - -build().catch((err) => { - // eslint-disable-next-line no-console - console.error(`\n\x1b[31m${err.message}\x1b[0m`); - process.exit(1); -}); diff --git a/handsontable/scripts/run.mjs b/handsontable/scripts/run.mjs new file mode 100644 index 00000000000..3b5da429b45 --- /dev/null +++ b/handsontable/scripts/run.mjs @@ -0,0 +1,373 @@ +/** + * Unified build/test/lint dispatcher for Handsontable. + * + * All npm run scripts delegate to this file; task definitions live in tasks.json. + * + * Usage: + * node scripts/run.mjs [-- ] + * node scripts/run.mjs --parallel [-- ] + * node scripts/run.mjs --sequential [-- ] + * + * Extra args after `--`: + * --testPathPattern= propagated as env to all pipeline tasks (dump + puppeteer + * compute matching run IDs from the same env var) + * --theme= same — propagated as env + * anything else appended to commands of tasks with "passthrough": true + */ + +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { runStep, ParallelSpinner, isTTY } from './utils/run-step.mjs'; +import { runScheduled } from './utils/scheduler.mjs'; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(currentDir, '..'); +const BIN = resolve(ROOT, 'node_modules', '.bin'); +const PATH_SEP = process.platform === 'win32' ? ';' : ':'; +// On Windows PATH may be stored as 'Path' — fall back to avoid injecting the literal "undefined". +const sysPath = process.env.PATH ?? process.env.Path ?? ''; +const envPATH = [BIN, resolve(ROOT, '..', 'node_modules', '.bin'), sysPath].join(PATH_SEP); + +// Load task registry. +const registry = JSON.parse(readFileSync(resolve(currentDir, 'tasks.json'), 'utf-8')); +const TASKS = registry.tasks; +const PIPELINES = registry.pipelines; + +// --- Argument parsing --- + +const rawArgs = process.argv.slice(2); +const separatorIdx = rawArgs.indexOf('--'); +const mainArgs = separatorIdx === -1 ? rawArgs : rawArgs.slice(0, separatorIdx); + +// Normalize space-separated flag values to --flag=value form so all downstream +// code sees a consistent format. Only flags that take a value are listed here; +// boolean flags (--watch, --random, --verbose) are left as-is. +const FLAGS_WITH_VALUES = ['--testPathPattern', '--theme']; + +/** + * Normalizes space-separated flag values to `--flag=value` form. + * e.g. `--testPathPattern filters` → `--testPathPattern=filters` + * + * @param {string[]} args Raw argument array to normalize. + * @returns {string[]} Normalized argument array. + */ +function normalizeArgs(args) { + const result = []; + + for (let i = 0; i < args.length; i += 1) { + if (FLAGS_WITH_VALUES.includes(args[i]) && i + 1 < args.length) { + result.push(`${args[i]}=${args[i + 1]}`); + i += 1; + } else { + result.push(args[i]); + } + } + + return result; +} + +const extraArgs = normalizeArgs(separatorIdx === -1 ? [] : rawArgs.slice(separatorIdx + 1)); + +const isParallel = mainArgs[0] === '--parallel'; +const isSequential = mainArgs[0] === '--sequential'; +const pipelineName = (isParallel || isSequential) ? mainArgs[1] : null; +const taskNames = (isParallel || isSequential) ? [] : mainArgs; + +// Extract special env-propagated flags from extra args. +const patternArg = extraArgs.find(a => a.startsWith('--testPathPattern=')); +const themeArg = extraArgs.find(a => a.startsWith('--theme=')); +// testPathPattern and theme are propagated as env to all pipeline tasks so that +// the dump step (rspack) and run-puppeteer.mjs compute the same run-ID filename. +// For test:unit.jest, --testPathPattern= is also passed as argv (passthrough task), +// which Jest accepts directly; puppeteer ignores the duplicate argv entry. +const passthroughFlags = extraArgs; + +const propagatedEnv = {}; + +if (patternArg) { propagatedEnv.npm_config_testpathpattern = patternArg.replace('--testPathPattern=', ''); } +if (themeArg) { propagatedEnv.npm_config_theme = themeArg.replace('--theme=', ''); } + +// --- Task resolution --- + +/** + * @param {string} name Task name from tasks.json. + * @returns {object} Task definition. + */ +function resolveTask(name) { + const task = TASKS[name]; + + if (!task) { + // eslint-disable-next-line no-console + console.error(`\x1b[31mUnknown task: "${name}"\x1b[0m`); + // eslint-disable-next-line no-console + console.error(`Available tasks: ${Object.keys(TASKS).join(', ')}`); + process.exit(1); + } + + return task; +} + +/** + * @param {object} task Task definition from tasks.json. + * @param {string[]} flags Extra flags passed after `--`. + * @returns {string} Mode string: 'quiet' | 'inherit' | 'interactive'. + */ +function resolveMode(task, flags) { + if (task.mode === 'interactive' || (task.mode === 'inherit' && flags.includes('--watch'))) { + return 'interactive'; + } + + if (task.mode === 'inherit') { return 'inherit'; } + + // Tasks with no explicit mode that forward --watch must use inherit so their stdout + // reaches concurrently's output stream — the watcher reads it to detect build-completion + // markers and trigger Puppeteer re-runs. Without this, output is piped and swallowed. + const forwardsWatch = Array.isArray(task.passthroughFilter) && task.passthroughFilter.includes('--watch'); + + if (flags.includes('--watch') && forwardsWatch) { + return 'inherit'; + } + + return 'quiet'; +} + +/** + * @param {object} task Task definition from tasks.json. + * @param {string[]} flags Extra flags to append when task has `passthrough: true`. + * @returns {string} Final shell command string. + */ +function buildCmd(task, flags) { + const base = task.cmd; + let effective = flags; + + // passthroughFilter restricts which flags reach this task — e.g. test:unit.jest + // should receive --testPathPattern/--watch but not --random (which Jest rejects). + if (task.passthroughFilter && flags.length) { + effective = flags.filter(f => + task.passthroughFilter.some(p => f === p || f.startsWith(`${p}=`)) + ); + } + + const extra = (task.passthrough && effective.length) ? ` ${effective.join(' ')}` : ''; + + return `${base}${extra}`; +} + +// --- Single task runner (sequential) --- + +/** + * @param {string} name Task name. + * @param {string[]} [extraFlags] Passthrough flags appended to the command. + * @param {object} [envOverride] Additional env vars for this invocation. + * @param {Set} [visited] Tasks already executed in this run; shared + * across recursive dep calls so shared deps are not re-executed. + * @returns {Promise} Resolves on success, rejects on non-zero exit. + */ +async function runOne(name, extraFlags = [], envOverride = {}, visited = new Set()) { + // Deduplication: skip tasks already executed in this run. + // Without this, shared deps (e.g. build:styles) re-execute once per downstream + // task that has them in its dep chain, roughly doubling sequential build time. + if (visited.has(name)) { + return; + } + + visited.add(name); + + const task = resolveTask(name); + const mode = resolveMode(task, extraFlags); + const cmd = buildCmd(task, extraFlags); + const cwd = task.cwd ? resolve(ROOT, task.cwd) : ROOT; + // Inject node_modules/.bin so task cmds like `eslint`, `rimraf`, `rspack` + // resolve without requiring callers to prefix with npx or npm run. + const env = { PATH: envPATH, ...propagatedEnv, ...envOverride }; + + // For direct single-task invocations, resolve deps sequentially so that + // e.g. `npm run build:umd` automatically builds styles first. + // The parallel pipeline uses runParallelTask + the DAG scheduler instead + // and never goes through runOne, so this path does not affect it. + for (const dep of (task.deps ?? [])) { + // eslint-disable-next-line no-await-in-loop + await runOne(dep, [], envOverride, visited); + } + + await runStep(cmd, { + cwd, + env, + forceInherit: mode === 'inherit', + interactive: mode === 'interactive', + }); +} + +// --- Parallel task runner (used by the scheduler for the build pipeline) --- + +/** + * @param {string} name Task name. + * @param {ParallelSpinner} spinner Spinner managing the parallel progress display. + * @returns {Promise} Resolves with elapsed ms on success, rejects on failure. + */ +function runParallelTask(name, spinner) { + return new Promise((res, rej) => { + const task = resolveTask(name); + const cmd = buildCmd(task, passthroughFlags); + const cwd = task.cwd ? resolve(ROOT, task.cwd) : ROOT; + const start = performance.now(); + + // On Windows process.env may have 'Path' instead of 'PATH'. Delete it so + // our injected PATH is the only path-like key — Windows GetEnvironmentVariable + // picks the first case-insensitive match, which would otherwise be 'Path'. + const baseEnv = { ...process.env }; + + if (process.platform === 'win32') { delete baseEnv.Path; } + + const child = spawn(cmd, [], { + cwd, + env: { ...baseEnv, PATH: envPATH, NODE_NO_WARNINGS: '1', ...propagatedEnv }, + // Pipe both stdout and stderr so lint errors (written to stdout by ESLint/Stylelint) + // are captured and shown on failure alongside any stderr diagnostics. + stdio: ['ignore', 'pipe', 'pipe'], + shell: true, + }); + + let combined = ''; + + child.stdout.on('data', (d) => { combined += d.toString(); }); + child.stderr.on('data', (d) => { combined += d.toString(); }); + + child.on('close', (code) => { + const elapsed = Math.round(performance.now() - start); + + if (code !== 0) { + spinner.finish(name, false, elapsed); + + if (combined) { + const tail = combined.trim().split('\n').slice(-10).join('\n '); + + process.stderr.write(`\n ${tail}\n`); + } + rej(new Error(`${name} failed with exit code ${code}`)); + } else { + spinner.finish(name, true, elapsed); + + // Flush any captured output (e.g. ESLint/Stylelint warnings) that was + // collected while the task ran quietly. Build tools that exit 0 with no + // warnings produce empty output, so this is a no-op for them. + if (combined.trim()) { + process.stdout.write(`${combined.trimEnd()}\n`); + } + + res(elapsed); + } + }); + + child.on('error', (err) => { + spinner.finish(name, false, 0); + rej(err); + }); + }); +} + +// --- Pipeline runner --- + +/** + * @param {string} name Pipeline name from tasks.json. + * @param {boolean} parallel When true, uses the DAG scheduler; otherwise sequential. + * @returns {Promise} Resolves when all pipeline steps complete. + */ +async function runPipeline(name, parallel) { + const pipeline = PIPELINES[name]; + + if (!pipeline) { + // eslint-disable-next-line no-console + console.error(`\x1b[31mUnknown pipeline: "${name}"\x1b[0m`); + // eslint-disable-next-line no-console + console.error(`Available pipelines: ${Object.keys(PIPELINES).join(', ')}`); + process.exit(1); + } + + // before: sequential prerequisites (e.g. clean before build, build:styles before lint) + for (const step of (pipeline.before ?? [])) { + // before steps may be pipeline names (e.g. "lint" in "test") or task names + if (PIPELINES[step]) { + await runPipeline(step, false); + } else { + await runOne(step); + } + } + + const tasks = pipeline.tasks ?? []; + + if (parallel) { + // Build a subset of TASKS containing only the pipeline's tasks. + const subTasks = {}; + + tasks.forEach((t) => { subTasks[t] = TASKS[t] ?? { cmd: t, deps: [] }; }); + + const spinner = new ParallelSpinner(); + const totalStart = performance.now(); + + try { + await runScheduled(subTasks, (taskName) => { + spinner.start(taskName); + + return runParallelTask(taskName, spinner); + }, isTTY); + } catch (err) { + const totalElapsed = Math.round(performance.now() - totalStart); + + // eslint-disable-next-line no-console + console.error(`\n\x1b[31mPipeline "${name}" failed after ${totalElapsed}ms\x1b[0m`); + process.exit(1); + } + + } else { + // Sequential: run each item in order. + // Items may be pipeline names (e.g. "test:unit" inside "test") or task names. + // A single visited Set is shared across all items so shared deps run only once. + const visited = new Set(); + + for (const item of tasks) { + if (PIPELINES[item]) { + // eslint-disable-next-line no-await-in-loop + await runPipeline(item, false); + } else { + // eslint-disable-next-line no-await-in-loop + await runOne(item, passthroughFlags, {}, visited); + } + } + } + + // after: sequential post-steps (e.g. prepare-package-for-publish after build) + for (const step of (pipeline.after ?? [])) { + if (PIPELINES[step]) { + // eslint-disable-next-line no-await-in-loop + await runPipeline(step, false); + } else { + // eslint-disable-next-line no-await-in-loop + await runOne(step); + } + } +} + +// --- Entry point --- + +try { + if (pipelineName) { + await runPipeline(pipelineName, isParallel); + } else if (taskNames.length > 0) { + for (const name of taskNames) { + await runOne(name, passthroughFlags); + } + } else { + // eslint-disable-next-line no-console + console.error('Usage: node scripts/run.mjs [-- flags]'); + process.exit(1); + } +} catch (err) { + // eslint-disable-next-line no-console + console.error(`\n\x1b[31m${err.message}\x1b[0m`); + process.exit(1); +} diff --git a/handsontable/scripts/tasks.json b/handsontable/scripts/tasks.json new file mode 100644 index 00000000000..bc1dc007d15 --- /dev/null +++ b/handsontable/scripts/tasks.json @@ -0,0 +1,245 @@ +{ + "tasks": { + "clean": { + "cmd": "rimraf commonjs es coverage tmp tmp_styles", + "deps": [] + }, + "prepare-package-for-publish": { + "cmd": "node scripts/prepare-package-for-publish.mjs", + "deps": [] + }, + + "build:styles": { + "cmd": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=styles-development env-cmd -f ../hot.config.js rspack", + "deps": [] + }, + "build:styles.min": { + "cmd": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=styles-production env-cmd -f ../hot.config.js rspack", + "deps": ["build:styles"], + "note": "must run after build:styles to avoid race on src/styles/handsontableStyles.js" + }, + "build:themes-css": { + "cmd": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=themes-css-development env-cmd -f ../hot.config.js rspack", + "deps": [] + }, + "build:themes-css.min": { + "cmd": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=themes-css-production env-cmd -f ../hot.config.js rspack", + "deps": [] + }, + "build:commonjs": { + "cmd": "env-cmd -f ../hot.config.js node scripts/swc-transpile.mjs --format commonjs --out-dir tmp", + "deps": ["build:styles.min"], + "note": "needs build:styles.min to finalize handsontableStyles.js before reading src/" + }, + "build:languages": { + "cmd": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=languages-development env-cmd -f ../hot.config.js rspack", + "deps": [] + }, + "build:languages.es": { + "cmd": "env-cmd -f ../hot.config.js node scripts/swc-transpile.mjs --format esm --src-dir src/i18n/languages --out-dir languages --out-ext .mjs --lang-registration", + "deps": [] + }, + "build:languages.min": { + "cmd": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=languages-production env-cmd -f ../hot.config.js rspack", + "deps": [] + }, + "build:themes-umd": { + "cmd": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=themes-umd-development env-cmd -f ../hot.config.js rspack", + "deps": [] + }, + "build:themes-umd.min": { + "cmd": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=themes-umd-production env-cmd -f ../hot.config.js rspack", + "deps": [] + }, + "build:umd": { + "cmd": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=development env-cmd -f ../hot.config.js rspack ./src/index.js", + "deps": ["build:styles", "build:themes-css"] + }, + "build:umd.min": { + "cmd": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=production env-cmd -f ../hot.config.js rspack ./src/index.js", + "deps": ["build:styles.min", "build:themes-css.min"] + }, + "build:es": { + "cmd": "env-cmd -f ../hot.config.js node scripts/swc-transpile.mjs --format esm --out-dir tmp --out-ext .mjs", + "deps": ["build:styles.min"], + "note": "needs build:styles.min to finalize handsontableStyles.js" + }, + "build:walkontable": { + "cmd": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=walkontable env-cmd -f ../hot.config.js rspack ./src/3rdparty/walkontable/css/walkontable.scss ./src/3rdparty/walkontable/src/index.js", + "deps": [], + "passthrough": true, + "passthroughFilter": ["--watch"] + }, + "build:umd-from-esm": { + "cmd": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=development env-cmd -f ../hot.config.js rspack ./tmp/index.mjs", + "deps": ["build:styles", "build:themes-css"] + }, + "build:umd-from-cjs": { + "cmd": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=development env-cmd -f ../hot.config.js rspack ./tmp/index.js", + "deps": ["build:styles", "build:themes-css"] + }, + + "lint:eslint": { + "cmd": "eslint --cache src/ test/ .config/plugin scripts/", + "deps": [], + "mode": "inherit" + }, + "lint:eslint.fix": { + "cmd": "eslint --fix src/ test/ .config/plugin scripts/", + "deps": [], + "mode": "inherit" + }, + "lint:types": { + "cmd": "eslint \"./**/*.d.ts\" --config \"./.eslintrc.js\"", + "deps": [], + "cwd": "types", + "mode": "inherit" + }, + "lint:types.fix": { + "cmd": "eslint --fix \"./**/*.d.ts\" --config \"./.eslintrc.js\"", + "deps": [], + "cwd": "types", + "mode": "inherit" + }, + "lint:stylelint": { + "cmd": "stylelint --cache \"src/**/*.{css,scss}\" \"test/**/*.{css,scss}\"", + "deps": [], + "mode": "inherit" + }, + "lint:stylelint.fix": { + "cmd": "stylelint --fix \"src/**/*.{css,scss}\" \"test/**/*.{css,scss}\"", + "deps": [], + "mode": "inherit" + }, + + "test:unit.jest": { + "cmd": "cross-env-shell BABEL_ENV=commonjs env-cmd -f ../hot.config.js jest", + "deps": [], + "mode": "interactive", + "passthrough": true, + "passthroughFilter": ["--testPathPattern", "--watch", "--coverage"], + "note": "interactive gives Jest a real TTY so colors/progress work; passthroughFilter limits forwarded flags to Jest-understood args — blocks --random, --verbose, etc." + }, + "test:types": { + "cmd": "tsc -p ./test/types -t es2015", + "deps": [], + "mode": "inherit" + }, + "test:e2e.dump": { + "cmd": "npm --silent run build:styles && cross-env-shell BABEL_ENV=commonjs_e2e NODE_ENV=test-e2e env-cmd -f ../hot.config.js rspack ./test/helpers/index.js ./test/e2e/index.js", + "deps": [], + "passthrough": true, + "passthroughFilter": ["--watch"] + }, + "test:e2e.dump.esm-cjs": { + "cmd": "cross-env-shell BABEL_ENV=commonjs_e2e NODE_ENV=test-e2e-esm-cjs env-cmd -f ../hot.config.js rspack ./test/helpers/index.js ./test/e2e/index.js", + "deps": [] + }, + "test:e2e.puppeteer": { + "cmd": "node test/scripts/run-puppeteer.mjs", + "deps": [], + "mode": "inherit", + "passthrough": true, + "passthroughFilter": ["--random", "--seed", "--verbose", "--hotVersion"] + }, + "test:walkontable.dump": { + "cmd": "cross-env-shell BABEL_ENV=commonjs_e2e NODE_ENV=test-walkontable env-cmd -f ../hot.config.js rspack ./src/3rdparty/walkontable/test/helpers/index.js ./src/3rdparty/walkontable/test/spec/index.js", + "deps": [], + "passthrough": true, + "passthroughFilter": ["--watch"] + }, + "test:walkontable.puppeteer": { + "cmd": "node test/scripts/run-puppeteer.mjs src/3rdparty/walkontable/test/SpecRunner.html", + "deps": [], + "mode": "inherit", + "passthrough": true, + "passthroughFilter": ["--random", "--seed", "--verbose", "--hotVersion"] + }, + "test:production.dump": { + "cmd": "npm run build:styles && cross-env-shell BABEL_ENV=commonjs_e2e NODE_ENV=test-production env-cmd -f ../hot.config.js rspack ./test/helpers/index.js ./test/e2e/index.js", + "deps": [] + }, + "test:production.puppeteer": { + "cmd": "node test/scripts/run-puppeteer.mjs", + "deps": [], + "mode": "inherit", + "passthrough": true, + "passthroughFilter": ["--random", "--seed", "--verbose", "--hotVersion"] + }, + "test:mobile.dump": { + "cmd": "npm run build:styles && cross-env-shell BABEL_ENV=commonjs_e2e NODE_ENV=test-mobile env-cmd --no-override -f ../hot.config.js rspack ./test/helpers/index.js ./test/e2e/mobile/index.js", + "deps": [] + }, + "test:e2e.watch": { + "cmd": "node ./test/scripts/run-puppeteer-on-watchers-change.mjs --cmdToListen \"npm run watch\" --cmdToListen \"npm run test:e2e.dump -- --watch\"", + "deps": [], + "mode": "inherit" + }, + "test:walkontable.watch": { + "cmd": "node ./test/scripts/run-puppeteer-on-watchers-change.mjs --cmdToListen \"npm run build:walkontable -- --watch\" --cmdToListen \"npm run test:walkontable.dump -- --watch\" --runnerFile \"src/3rdparty/walkontable/test/SpecRunner.html\"", + "deps": [], + "mode": "inherit" + }, + + "watch:rspack": { + "cmd": "cross-env-shell BABEL_ENV=commonjs NODE_ENV=watch env-cmd -f ../hot.config.js rspack --watch ./src/index.js", + "deps": [], + "mode": "inherit" + }, + "measure:module-sizes": { + "cmd": "node scripts/measure-module-sizes.mjs", + "deps": [], + "mode": "inherit" + } + }, + + "pipelines": { + "build": { + "before": ["clean"], + "tasks": [ + "build:styles", + "build:styles.min", + "build:themes-css", + "build:themes-css.min", + "build:commonjs", + "build:languages", + "build:languages.es", + "build:languages.min", + "build:themes-umd", + "build:themes-umd.min", + "build:umd", + "build:umd.min", + "build:es" + ] + }, + "lint": { + "before": ["build:styles"], + "tasks": ["lint:eslint", "lint:types", "lint:stylelint"] + }, + "lint:fix": { + "before": ["build:styles"], + "tasks": ["lint:eslint.fix", "lint:types.fix", "lint:stylelint.fix"] + }, + "test:unit": { + "before": ["build:styles"], + "tasks": ["test:unit.jest"] + }, + "test:e2e": { + "tasks": ["build:umd", "build:languages", "build:themes-umd", "test:e2e.dump", "test:e2e.puppeteer"] + }, + "test:walkontable": { + "tasks": ["build:walkontable", "test:walkontable.dump", "test:walkontable.puppeteer"] + }, + "test:production": { + "tasks": ["build:umd.min", "build:languages.min", "build:themes-umd.min", "test:production.dump", "test:production.puppeteer"] + }, + "test": { + "before": ["lint"], + "tasks": ["test:unit", "test:types", "test:walkontable", "test:e2e", "test:production"] + }, + "watch": { + "before": ["build:styles"], + "tasks": ["watch:rspack"] + } + } +} diff --git a/handsontable/scripts/utils/run-step.mjs b/handsontable/scripts/utils/run-step.mjs new file mode 100644 index 00000000000..43d01294e55 --- /dev/null +++ b/handsontable/scripts/utils/run-step.mjs @@ -0,0 +1,250 @@ +// TTY-aware spawn helper for build/test wrapper scripts. +// +// On TTY (quiet steps): suppress output, show spinner after 50ms, print ✓/✗. +// On TTY (inherit steps): pipe output through Node so spinner clears before first +// output line; show ✓ if the command exits silently. +// On CI/non-TTY: inherit all stdio — full output flows through unchanged. +// +// For parallel tasks, use ParallelSpinner to render one spinner line per running +// task using ANSI cursor movement. Falls back gracefully on non-TTY. + +import { spawn } from 'node:child_process'; +import { performance } from 'node:perf_hooks'; + +// HOT_QUIET=1 lets parent processes (e.g. the watch watcher) signal that child +// scripts should suppress output even when concurrently breaks the TTY pipe. +export const isTTY = process.stdout.isTTY || process.env.HOT_QUIET === '1'; + +const SUPPRESS_ENV = { + NODE_NO_WARNINGS: '1', +}; + +const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +const INDICATOR_DELAY_MS = 80; +const INDICATOR_INTERVAL_MS = 80; + +/** + * @param {string} cmd Shell command string. + * @returns {string} Human-readable label with npm prefix stripped. + */ +function labelOf(cmd) { + return cmd + .replace(/^npm --silent run /, '') + .replace(/^npm run /, '') + .trim(); +} + +/** + * Spawn a shell command with TTY-aware output suppression and progress indicator. + * + * @param {string} cmd Full shell command string to execute. + * @param {{ forceInherit?: boolean, interactive?: boolean, cwd?: string, env?: object, label?: string }} [opts] + * forceInherit: show command output (linters, Puppeteer) with spinner while waiting. + * interactive: full stdio pass-through for tools that need a real TTY (Jest --watch). + * No spinner — output flows directly and keyboard input is forwarded. + * cwd: working directory (defaults to process.cwd()). + * env: extra env vars merged on top of process.env and SUPPRESS_ENV. + * label: display name for the spinner/result line (defaults to cmd with npm prefix stripped). + * @returns {Promise} Rejects with an Error on non-zero exit. + */ +export function runStep(cmd, opts = {}) { + const quiet = isTTY && !opts.forceInherit && !opts.interactive; + const label = opts.label ?? labelOf(cmd); + + return new Promise((resolve, reject) => { + const start = performance.now(); + let frame = 0; + let indicatorActive = false; + let delayTimer = null; + let spinTimer = null; + let hasOutput = false; + + // --- spinner --- + + const startSpinner = () => { + if (!isTTY || opts.interactive) { return; } + delayTimer = setTimeout(() => { + indicatorActive = true; + spinTimer = setInterval(() => { + process.stdout.write(`\r \x1b[36m${SPINNER[frame % SPINNER.length]}\x1b[0m ${label}`); + frame += 1; + }, INDICATOR_INTERVAL_MS); + }, INDICATOR_DELAY_MS); + }; + + const stopIndicator = () => { + clearTimeout(delayTimer); + + if (spinTimer) { + clearInterval(spinTimer); + spinTimer = null; + } + if (indicatorActive) { + process.stdout.write('\r\x1b[K'); + indicatorActive = false; + } + }; + + // --- stdio --- + + // interactive: full pass-through so tools like Jest --watch get a real TTY. + // TTY (non-interactive): pipe so we can intercept output for the spinner. + // CI/non-TTY: inherit directly — Node buffering would break streaming output. + const stdio = opts.interactive || !isTTY + ? 'inherit' + : ['ignore', 'pipe', 'pipe']; + + startSpinner(); + + // On Windows process.env may have 'Path' instead of 'PATH'. Remove it so + // the caller-supplied PATH (in opts.env) is the only path-like key in the + // env block — Windows GetEnvironmentVariable picks the first case-insensitive + // match, which would otherwise be the original 'Path', ignoring our injection. + const baseEnv = { ...process.env }; + + if (process.platform === 'win32') { delete baseEnv.Path; } + + const child = spawn(cmd, [], { + cwd: opts.cwd ?? process.cwd(), + env: { ...baseEnv, ...SUPPRESS_ENV, ...opts.env }, + stdio, + shell: true, + }); + + // --- output handling (TTY only) --- + + let stderr = ''; + + if (isTTY && !opts.interactive) { + const onData = (destination, data) => { + if (!hasOutput) { + hasOutput = true; + stopIndicator(); // clear spinner before first line of real output + } + if (!quiet) { + destination.write(data); // forceInherit: forward to terminal + } else if (destination === process.stderr) { + stderr += data.toString(); // quiet: capture stderr + } + }; + + child.stdout.on('data', data => onData(process.stdout, data)); + child.stderr.on('data', data => onData(process.stderr, data)); + } + + // --- completion --- + + child.on('close', (code) => { + stopIndicator(); + const elapsed = Math.round(performance.now() - start); + + if (code !== 0) { + if (quiet) { + process.stdout.write(` \x1b[31m✗\x1b[0m ${label} (${elapsed}ms)\n`); + + if (stderr) { + const tail = stderr.trim().split('\n').slice(-3).join('\n '); + + process.stderr.write(`\n ${tail}\n`); + } + } + reject(new Error(`Step failed (exit ${code}): ${cmd}`)); + } else { + // Show ✓ for quiet steps, and for inherit steps that produced no output + // (e.g. eslint passing cleanly — no output means no news is good news). + if (quiet || !hasOutput) { + process.stdout.write(` \x1b[32m✓\x1b[0m ${label} (${elapsed}ms)\n`); + } + resolve(); + } + }); + + child.on('error', (err) => { + stopIndicator(); + reject(err); + }); + }); +} + +/** + * Multi-line spinner for parallel builds. + * + * Usage: + * const spinner = new ParallelSpinner(); + * spinner.start('task-a'); + * spinner.start('task-b'); + * spinner.finish('task-a', true, 1200); + * spinner.stop(); // clears any remaining lines + * + * On non-TTY each event is printed as a plain line (no ANSI cursor movement). + */ +export class ParallelSpinner { + #active = new Map(); // name → {frame, startMs} + #timer = null; + #lineCount = 0; + + start(name) { + this.#active.set(name, { frame: 0, startMs: performance.now() }); + + if (!isTTY) { + // eslint-disable-next-line no-console + console.log(` \x1b[36m…\x1b[0m ${name}`); + + return; + } + + if (!this.#timer) { + this.#timer = setInterval(() => this.#render(), INDICATOR_INTERVAL_MS); + } + + this.#render(); + } + + finish(name, ok, elapsed) { + const mark = ok ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m'; + + if (!isTTY) { + // eslint-disable-next-line no-console + console.log(` ${mark} ${name} (${elapsed}ms)`); + this.#active.delete(name); + + return; + } + + this.#active.delete(name); + this.#clear(); + process.stdout.write(` ${mark} ${name} (${elapsed}ms)\n`); + this.#render(); + + if (this.#active.size === 0) { this.stop(); } + } + + stop() { + if (this.#timer) { + clearInterval(this.#timer); + this.#timer = null; + } + + this.#clear(); + } + + #clear() { + if (!isTTY || this.#lineCount === 0) { return; } + // Move up and erase each spinner line. + process.stdout.write(('\x1b[1A\x1b[K').repeat(this.#lineCount)); + this.#lineCount = 0; + } + + #render() { + if (!isTTY || this.#active.size === 0) { return; } + this.#clear(); + + for (const [name, state] of this.#active) { + const spin = SPINNER[state.frame % SPINNER.length]; + + state.frame += 1; + process.stdout.write(` \x1b[36m${spin}\x1b[0m ${name}\n`); + this.#lineCount += 1; + } + } +} diff --git a/handsontable/scripts/utils/scheduler.mjs b/handsontable/scripts/utils/scheduler.mjs new file mode 100644 index 00000000000..058d913741c --- /dev/null +++ b/handsontable/scripts/utils/scheduler.mjs @@ -0,0 +1,113 @@ +/** + * DAG-based parallel task scheduler. + * + * Runs tasks concurrently where the dependency graph allows, serializes + * where it does not. Caller supplies a `runTask(name)` callback that + * returns a Promise; the scheduler handles ordering, progress tracking, + * and critical-path reporting. + * + * @param {object} tasks Task definitions from tasks.json (name → {cmd, deps, …}). + * @param {Function} runTask `(name: string) => Promise` — spawn and resolve. + * @param {boolean} [isTTY] When false (CI), prints critical-path summary after build. + * @returns {Promise} Resolves when all tasks complete; rejects on first failure. + */ +export async function runScheduled(tasks, runTask, isTTY = process.stdout.isTTY) { + const taskNames = Object.keys(tasks); + + // Validate: every dep must reference a known task. + const unknownDeps = []; + + taskNames.forEach((name) => { + (tasks[name].deps ?? []).forEach((dep) => { + if (!Object.prototype.hasOwnProperty.call(tasks, dep)) { + unknownDeps.push(`${name} -> "${dep}"`); + } + }); + }); + + if (unknownDeps.length > 0) { + throw new Error( + `Unknown task dependency(ies): ${unknownDeps.join(', ')}. ` + + `Known tasks: ${taskNames.join(', ')}.` + ); + } + + const completed = new Set(); + const running = new Map(); // name → Promise + const taskTimes = {}; + + while (completed.size < taskNames.length) { + const ready = taskNames.filter(name => + !completed.has(name) && + !running.has(name) && + (tasks[name].deps ?? []).every(dep => completed.has(dep)) + ); + + ready.forEach((name) => { + const promise = runTask(name).then( + (elapsed) => { + taskTimes[name] = typeof elapsed === 'number' ? elapsed : 0; + completed.add(name); + running.delete(name); + }, + (err) => { + running.delete(name); + throw err; + } + ); + + running.set(name, promise); + }); + + if (running.size === 0) { + const pending = taskNames.filter(n => !completed.has(n)); + + throw new Error( + `Build stalled: ${pending.length} task(s) never ran (${pending.join(', ')}). ` + + 'Check the dependency graph for cycles or unresolved deps.' + ); + } + + await Promise.race(running.values()); + } + + // Critical-path analysis — CI-only diagnostic. + if (!isTTY) { + /** + * Total wall-clock time for a task including its longest dependency chain. + * + * @param {string} name Task name. + * @returns {number} Accumulated path time in milliseconds. + */ + const pathTime = (name) => { + const deps = tasks[name].deps ?? []; + const depMax = deps.length > 0 ? Math.max(...deps.map(pathTime)) : 0; + + return (taskTimes[name] || 0) + depMax; + }; + + let maxTask = ''; + let maxTime = 0; + + taskNames.forEach((name) => { + const t = pathTime(name); + + if (t > maxTime) { maxTime = t; maxTask = name; } + }); + + const critPath = []; + let current = maxTask; + + critPath.push(current); + + while ((tasks[current].deps ?? []).length > 0) { + const deps = tasks[current].deps; + + current = deps.reduce((a, b) => (pathTime(a) > pathTime(b) ? a : b)); + critPath.unshift(current); + } + + // eslint-disable-next-line no-console + console.log(`Critical path: ${critPath.join(' -> ')} (${maxTime}ms theoretical minimum)`); + } +} diff --git a/handsontable/test/__mocks__/cssPolyfill.js b/handsontable/test/__mocks__/cssPolyfill.js index 41fa89a8852..d9b900d023e 100644 --- a/handsontable/test/__mocks__/cssPolyfill.js +++ b/handsontable/test/__mocks__/cssPolyfill.js @@ -6,6 +6,11 @@ * - Other modern CSS features that jsdom doesn't support. */ +// Memoize preprocessModernCSS — the same rule/string always produces the same output. +// Avoids re-running 4 regexes on the same 133 KB handsontableStyles string 644× +// (once per insertRule call when jsdom parses the style element). +const preprocessCache = new Map(); + /** * Patches CSSStyleSheet.prototype.insertRule to handle modern CSS features. */ @@ -75,6 +80,12 @@ function preprocessModernCSS(cssText) { return cssText; } + const cached = preprocessCache.get(cssText); + + if (cached !== undefined) { + return cached; + } + let processed = cssText; // Replace light-dark() with a fallback value that jsdom can parse. @@ -100,6 +111,8 @@ function preprocessModernCSS(cssText) { // :not(*) is a valid selector that matches nothing, so the rule stays valid but has no effect. processed = processed.replace(/:has\s*\([^)]+\)/g, ':not(*)'); + preprocessCache.set(cssText, processed); + return processed; } @@ -193,6 +206,19 @@ function getMinimalComputedStyle(element) { }; } +// Module-level so clearComputedStyleCache() can replace it between tests. +// WeakMap has no .clear() method — reassigning the reference is the only option. +let computedStyleCache = new WeakMap(); + +/** + * Replaces the per-element computed style cache with a fresh WeakMap. + * Call this in a beforeEach hook to prevent stale cached snapshots from leaking + * between tests when an element's class list or inline styles change mid-test. + */ +function clearComputedStyleCache() { + computedStyleCache = new WeakMap(); +} + /** * Patches CSSStyleDeclaration to improve CSS custom property support. */ @@ -204,14 +230,20 @@ function patchCSSStyleDeclaration() { const originalGetPropertyValue = CSSStyleDeclaration.prototype.getPropertyValue; const originalGetComputedStyle = window.getComputedStyle; - // Enhance getPropertyValue to better handle CSS custom properties. + // Cache for CSS custom property values resolved from :root. + // CSS custom properties are defined on :root and don't change during a test run, + // so we can safely cache them for the lifetime of the jsdom instance. + const cssVarCache = new Map(); + + // Enhance getPropertyValue to handle CSS custom properties. + // Does NOT fall back to getComputedStyle — that path is both expensive and + // recursive (the returned CSSStyleDeclaration has the same patched prototype). + // The getComputedStyle proxy below handles the CSS variable lookup with caching. CSSStyleDeclaration.prototype.getPropertyValue = function(property) { const value = originalGetPropertyValue.call(this, property); - // If it's a CSS custom property and we got an empty string, - // try to get it from the element's inline style or computed style. if (!value && property.startsWith('--')) { - // Try inline style first. + // Try inline style first (cheap, no cascade evaluation). if (this._ownerElement && this._ownerElement.style) { const inlineValue = this._ownerElement.style.getPropertyValue(property); @@ -219,52 +251,69 @@ function patchCSSStyleDeclaration() { return inlineValue; } } + } - // Try computed style from parent/root. - if (this._ownerElement && originalGetComputedStyle) { - try { - const computed = originalGetComputedStyle(this._ownerElement); - const computedValue = computed.getPropertyValue(property); + return value; + }; - if (computedValue) { - return computedValue; - } - } catch (e) { - // Ignore errors. + /** + * Returns a cached CSSStyleDeclaration for the element, calling jsdom's + * getComputedStyle at most once per unique element. + * + * @param {Element} element The element to compute styles for. + * @param {string|null} pseudoElement Optional pseudo-element string. + * @returns {CSSStyleDeclaration} The computed style object. + */ + function getCachedComputed(element, pseudoElement) { + // Only cache non-pseudo-element lookups (pseudo-elements are rare and uncacheable). + if (pseudoElement) { + try { + return originalGetComputedStyle.call(window, element, pseudoElement); + } catch (e) { + if (e instanceof SyntaxError || (e.message && /not a valid selector/i.test(e.message))) { + return getMinimalComputedStyle(element); } + throw e; } } - return value; - }; + if (computedStyleCache.has(element)) { + return computedStyleCache.get(element); + } - // Enhance getComputedStyle to better support CSS custom properties. - window.getComputedStyle = function(element, pseudoElement) { let computed; try { - computed = originalGetComputedStyle.call(this, element, pseudoElement); + computed = originalGetComputedStyle.call(window, element, pseudoElement); } catch (e) { // JSDOM's nwsapi throws SyntaxError on unsupported selectors (e.g. :has()). - // If CSS wasn't preprocessed (e.g. dynamic styles), return a minimal style object. if (e instanceof SyntaxError || (e.message && /not a valid selector/i.test(e.message))) { - return getMinimalComputedStyle(element); + computed = getMinimalComputedStyle(element); + } else { + throw e; } - - throw e; } - // Create a proxy that enhances CSS custom property retrieval. + computedStyleCache.set(element, computed); + + return computed; + } + + // Enhance getComputedStyle to support CSS custom properties. + // PERF: getCachedComputed() ensures jsdom's CSS cascade evaluation (~34ms) runs at + // most once per unique element per test, instead of once per call (195× per HOT init). + window.getComputedStyle = function(element, pseudoElement) { + const computed = getCachedComputed(element, pseudoElement); + + // Create a proxy that resolves unresolved CSS custom properties. return new Proxy(computed, { get(target, prop) { if (prop === 'getPropertyValue') { return function(property) { const value = target.getPropertyValue(property); - // If it's a CSS custom property and we got an empty string, - // try to find it in the element's style or parent styles. if (!value && property.startsWith('--')) { - // Check inline style. + // Check inline style (cheapest, no cascade). if (element.style) { const inlineValue = element.style.getPropertyValue(property); @@ -273,55 +322,31 @@ function patchCSSStyleDeclaration() { } } - // Check for default theme CSS vars if element has a theme class. + // Check theme defaults (no DOM traversal). const defaultValue = getDefaultThemeCSSVar(element, property); if (defaultValue) { return defaultValue; } - // Check parent elements for CSS custom properties. - let current = element.parentElement; - - while (current) { - try { - const parentComputed = originalGetComputedStyle.call(window, current); - const parentValue = parentComputed.getPropertyValue(property); - - if (parentValue) { - return parentValue; - } - - // Check for default theme CSS vars in parent. - const parentDefault = getDefaultThemeCSSVar(current, property); - - if (parentDefault) { - return parentDefault; - } - } catch (e) { - // Ignore errors. - } - - current = current.parentElement; + // CSS custom properties are defined on :root so the value is the + // same regardless of which element triggered the lookup. + if (cssVarCache.has(property)) { + return cssVarCache.get(property); } - // Check document root. + // Resolve from :root — single call, no parent-chain walk. try { const rootComputed = originalGetComputedStyle.call(window, document.documentElement); - const rootValue = rootComputed.getPropertyValue(property); + const rootValue = originalGetPropertyValue.call(rootComputed, property); + + cssVarCache.set(property, rootValue || ''); if (rootValue) { return rootValue; } - - // Check for default theme CSS vars in root. - const rootDefault = getDefaultThemeCSSVar(document.documentElement, property); - - if (rootDefault) { - return rootDefault; - } } catch (e) { - // Ignore errors. + cssVarCache.set(property, ''); } } @@ -471,5 +496,6 @@ function initCSSPolyfill() { module.exports = { patchCSSStyleSheet, patchConsoleErrors, - initCSSPolyfill + initCSSPolyfill, + clearComputedStyleCache }; diff --git a/handsontable/test/scripts/run-puppeteer-on-watchers-change.mjs b/handsontable/test/scripts/run-puppeteer-on-watchers-change.mjs index 3bc48e0cf49..656ba671fcd 100644 --- a/handsontable/test/scripts/run-puppeteer-on-watchers-change.mjs +++ b/handsontable/test/scripts/run-puppeteer-on-watchers-change.mjs @@ -8,8 +8,35 @@ import debounce from 'lodash.debounce'; const argv = yargs(hideBin(process.argv)) .array('cmdToListen') .string('runnerFile') + .string('testPathPattern') + .string('theme') .argv; +const IS_TTY = process.stdout.isTTY; + +// Suppress DEP0190 Node.js warning in all child processes. +// Must be set before concurrently spawns children so they inherit the env. +process.env.NODE_NO_WARNINGS = '1'; + +// concurrently pipes child stdout, breaking process.stdout.isTTY in sub-scripts. +// HOT_QUIET=1 tells run-step.mjs to use quiet mode even without a visible TTY. +if (IS_TTY) { + process.env.HOT_QUIET = '1'; +} + +// Propagate testPathPattern and theme so both the dump step and Puppeteer +// compute the same run ID and open the matching HTML file. +if (argv.testPathPattern) { + process.env.npm_config_testpathpattern = argv.testPathPattern; +} +if (argv.theme) { + process.env.npm_config_theme = argv.theme; +} + +// On TTY suppress npm lifecycle headers; on CI keep full output. +const silentize = cmd => (IS_TTY ? cmd.replace(/^npm run /, 'npm --silent run ') : cmd); +const commands = argv.cmdToListen.map(silentize); + const PUPPETEER_CMD = 'node test/scripts/run-puppeteer.mjs'; const PUPPETEER_KILL_TIMEOUT = 2000; const writableStream = new Writable(); @@ -34,6 +61,7 @@ const spawnPuppeteer = debounce(() => { writableStream._write = (chunk, encoding, next) => { // strip colorized logs that may occurs while executing commands + // eslint-disable-next-line no-control-regex const chunkRawText = chunk.toString().replace(/\x1B[[(?);]{0,2}(;?\d)*./g, ''); const isProcessFinished = /\x9d\t\x9d/.test(chunkRawText); @@ -47,11 +75,17 @@ writableStream._write = (chunk, encoding, next) => { spawnPuppeteer(); } - // forward logs to the stdout stream (bash screen) - process.stdout.write(chunk, encoding, next); -} + if (IS_TTY) { + // On TTY suppress all build/watcher output — Puppeteer pipes its own + // stdout/stderr directly to process.stdout so its lines still appear. + next(); + } else { + // On CI forward everything for full log visibility. + process.stdout.write(chunk, encoding, next); + } +}; -await concurrently(argv.cmdToListen, { +await concurrently(commands, { prefix: 'none', killOthers: ['failure', 'success'], outputStream: writableStream, diff --git a/handsontable/test/scripts/run-puppeteer.mjs b/handsontable/test/scripts/run-puppeteer.mjs index 9c7283b3add..4802529abd5 100644 --- a/handsontable/test/scripts/run-puppeteer.mjs +++ b/handsontable/test/scripts/run-puppeteer.mjs @@ -59,6 +59,7 @@ function listenOnFreePort(server, startPort) { } const IS_CI = process.env.CI; +const IS_TTY = process.stdout.isTTY; const CI_DOTS_PER_LINE = 120; // Separate positional args (runner HTML path) from flag args (--random, @@ -86,18 +87,18 @@ if (!fs.existsSync(originalPath)) { /* eslint-disable no-console */ console.log( `Runner HTML not found at ${originalPath}. Did \`test:e2e.dump\` run with the same ` - + `\`--testPathPattern\` / \`--theme\` values?` + + '`--testPathPattern` / `--theme` values?' ); process.exit(1); } if (flags) { const seed = flags.match(/(--seed=)\d{1,}/g); - const random = flags.includes('random'); + const random = flagArgs.includes('--random'); const hotVersionMatch = flags.match(/--hotVersion=([^\s,]+)/); const params = []; - verboseReporting = flags.includes('verbose'); + verboseReporting = flagArgs.includes('--verbose'); if (seed) { params.push(`seed=${seed[0].replace('--seed=', '')}`); @@ -159,7 +160,7 @@ const cleanup = cleanupFactory(browser, server); const reporter = new JasmineReporter({ colors: 1, cleanStack: 1, - verbosity: 4, + verbosity: (IS_TTY && !verboseReporting) ? 1 : 4, listStyle: 'flat', activity: true, isVerbose: verboseReporting, @@ -224,5 +225,5 @@ try { } catch (error) { /* eslint-disable no-console */ console.log(error); - cleanup(1); + await cleanup(1); } diff --git a/wrappers/react-wrapper/package.json b/wrappers/react-wrapper/package.json index c68b1b43c92..c2df2a76afe 100644 --- a/wrappers/react-wrapper/package.json +++ b/wrappers/react-wrapper/package.json @@ -107,6 +107,7 @@ "jest": { "testEnvironment": "jsdom", "testURL": "http://localhost/", + "testTimeout": 60000, "transform": { "^.+\\.tsx?$": "babel-jest", "^.+\\.js$": "babel-jest" diff --git a/wrappers/react-wrapper/test/bootstrap.js b/wrappers/react-wrapper/test/bootstrap.js index 3e73fbc7678..7488f1f39bb 100644 --- a/wrappers/react-wrapper/test/bootstrap.js +++ b/wrappers/react-wrapper/test/bootstrap.js @@ -2,7 +2,7 @@ import { IntersectionObserverMock } from './__mocks__/intersectionObserverMock'; import { ResizeObserverMock } from './__mocks__/resizeObserverMock'; import { mockDocumentClientDimensions } from './__mocks__/documentClientDimensions'; -import { initCSSPolyfill } from '../../../handsontable/test/__mocks__/cssPolyfill'; +import { initCSSPolyfill, clearComputedStyleCache } from '../../../handsontable/test/__mocks__/cssPolyfill'; beforeAll(() => { mockDocumentClientDimensions(); @@ -15,6 +15,10 @@ beforeAll(() => { initCSSPolyfill(); }); +beforeEach(() => { + clearComputedStyleCache(); +}); + afterAll(() => { delete window.IntersectionObserver; delete window.ResizeObserver; From 88302784631efd1aefd6660bb5c3c624ad863380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20M=C4=99dryga=C5=82?= Date: Tue, 12 May 2026 11:17:06 +0200 Subject: [PATCH 09/26] DEV-1554: Fix light theme highlight for deleted Expressive Code lines (#12527) Ensures deleted lines (`.ec-line.del`) have a consistent and subtle highlight in the light theme, matching the existing style for inserted lines. --- docs/src/styles/components/code.css | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/src/styles/components/code.css b/docs/src/styles/components/code.css index 972077cddb8..09d36cda115 100644 --- a/docs/src/styles/components/code.css +++ b/docs/src/styles/components/code.css @@ -68,6 +68,13 @@ --ec-frm-shdCol: transparent; --ec-frm-frameBoxShdCssVal: none; --ec-frm-trmTtbBrdBtmCol: var(--sl-color-gray-5); + /* Base .expressive-code sets dark-theme fg; block bg is light -- line numbers and code must use dark ink */ + --ec-codeFg: var(--sl-color-white); + --ec-gtrFg: var(--sl-color-gray-2); + --ec-gtrHlFg: rgba(36, 41, 47, 0.55); + --ec-codeSelBg: rgba(51, 146, 255, 0.28); + --ec-uiSelBg: var(--sl-color-gray-5); + --ec-uiSelFg: var(--sl-color-white); } :root[data-theme='light'] .expressive-code .copy button { @@ -143,6 +150,12 @@ border-inline-start-color: #41a167 !important; } +/* Expressive Code del (deleted) line highlight — light theme only; matches ins subtlety */ +:root[data-theme='light'] .expressive-code .ec-line.del { + background-color: rgba(134, 45, 39, 0.15) !important; + border-inline-start-color: #862d27 !important; +} + /* ------------------------------------------------------------------------- * FileTree — unscoped replica of Starlight's FileTree.astro CSS. * The built-in component CSS is scoped with :where(.astro-*) and won't From cd94d952f5210f3a847c6225d543c39d01de228d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20=E2=80=98Budzio=E2=80=99=20Budnik?= <571316+budnix@users.noreply.github.com> Date: Tue, 12 May 2026 12:09:59 +0200 Subject: [PATCH 10/26] DEV-1649: Split demo-page skill into dev-pr.html and dev-latest.html (#12545) --- .../skills/handsontable-demo-page/SKILL.md | 269 +++++++++--------- 1 file changed, 129 insertions(+), 140 deletions(-) diff --git a/.claude/skills/handsontable-demo-page/SKILL.md b/.claude/skills/handsontable-demo-page/SKILL.md index c4a7bcdf2d1..28848764bad 100644 --- a/.claude/skills/handsontable-demo-page/SKILL.md +++ b/.claude/skills/handsontable-demo-page/SKILL.md @@ -1,20 +1,20 @@ --- name: demo-page -description: Use when creating a demo or test page for manual testing of Handsontable. Trigger when the user asks to create a demo, test page, repro page, reproduction case, manual test, or wants to verify a bug fix or feature visually. Also trigger when the user mentions dev-generated.html, dev.html, or wants to compare behavior between a released version and a local build. Use this for any PR that needs a manual testing artifact. +description: Use when creating a demo or test page for manual testing of Handsontable. Trigger when the user asks to create a demo, test page, repro page, reproduction case, manual test, or wants to verify a bug fix or feature visually. Also trigger when the user mentions dev-generated.html, dev-pr.html, dev-latest.html, dev.html, or wants to compare behavior between a released version and a local build. Use this for any PR that needs a manual testing artifact. --- # Demo Page Generator -Generate a self-contained HTML demo page at `handsontable/dev-generated.html` for manual testing. This file is gitignored (`dev*.html` pattern), so it never pollutes the repo. +Generate two self-contained HTML demo pages for manual testing: -The demo has two tabs so a reviewer can instantly compare behavior: +| File | Loads from | Purpose | +|------|-----------|---------| +| `handsontable/dev-latest.html` | jsDelivr CDN (latest published version) | Shows the current/buggy behavior | +| `handsontable/dev-pr.html` | Local `dist/` (built from the branch) | Shows the fix or new feature | -| Tab | Loads from | Purpose | -|-----|-----------|---------| -| **Released** | jsDelivr CDN (latest published version) | Shows the current/buggy behavior | -| **PR Build** | Local `dist/` (built from the branch) | Shows the fix or new feature | +Both files are gitignored (`dev*.html` pattern), so they never pollute the repo. -This side-by-side comparison makes PR review faster — the reviewer sees the bug on the Released tab and verifies the fix on the PR Build tab without switching branches. +Each file links to the other at the top, so a reviewer can switch back and forth without losing scroll position or state context. **Two separate files means complete JS/CSS isolation** — no dual-instance loading tricks, no stylesheet toggling, no shared globals between versions. ## Step 1 — Analyze the PR context @@ -41,19 +41,21 @@ If missing or stale, build: npm run build --prefix handsontable ``` -## Step 3 — Generate the demo page +## Step 3 — Generate the two demo files -Write `handsontable/dev-generated.html` using the template structure below. Adapt the Handsontable configuration in each tab to target the specific bug or feature being tested. +Write both files using the templates below. Both are gitignored (`dev*.html`), so they never pollute the repo. -> **File already exists?** `dev-generated.html` is always throwaway — it was generated by a previous task and contains nothing worth preserving. Skip reading it. Wipe it first with a Bash command, then write the new file: +> **Files already exist?** Both files are throwaway — generated by a previous task. Skip reading them. Wipe them first, then write fresh: > > ```bash -> rm handsontable/dev-generated.html +> rm -f handsontable/dev-pr.html handsontable/dev-latest.html > ``` > -> Then use the Write tool to create the new content from scratch. Do **not** read the old file before writing. +> Then use the Write tool to create each file from scratch. -### Template structure +Each file is a standalone single-instance page. The nav bar at the top links to the other file — the current file's link is styled as active (non-clickable) so the reviewer always knows where they are. + +### Template — `handsontable/dev-latest.html` (Released / CDN) ```html @@ -61,162 +63,148 @@ Write `handsontable/dev-generated.html` using the template structure below. Adap - Manual Test — [short description] - - - [short description] — Released v__RELEASED_VERSION__ + - - - - +

[Short description of what is being tested]

-

- [Short description of what is being tested] -

- -
- - -
+
How to test: [Step-by-step reproduction instructions]
-
-
-
- -
-
-
- - - +
- + + + +``` - - - +### Template — `handsontable/dev-pr.html` (PR Build / local) - + ``` -### Filling in the template +### Filling in the templates -Replace these placeholders: +Replace these placeholders **in both files**: | Placeholder | Value | |-------------|-------| -| `__RELEASED_VERSION__` | The latest published version from `handsontable/package.json` (e.g., `17.0.1`). If the PR branch itself bumped the version, use the version from the base branch (`git show origin/develop:handsontable/package.json`). | +| `__RELEASED_VERSION__` | The latest published version from `handsontable/package.json` (e.g., `17.0.1`). If the PR branch bumped the version, use the version from the base branch (`git show origin/develop:handsontable/package.json`). | | `[Short description...]` | A one-line summary, e.g., "Filters dropdown closes on Android touch" | | `[Step-by-step reproduction...]` | Numbered steps the reviewer should follow, e.g., "1. Double-tap cell A1. 2. The editor should open and stay open." | +Keep the ` ``` -Load third-party libraries once (before both Handsontable scripts) so both tabs share them. - ## Step 4 — Serve and verify Start a local server from the `handsontable/` directory: @@ -252,20 +238,23 @@ Start a local server from the `handsontable/` directory: python3 -m http.server 8767 --directory handsontable & ``` -Verify both tabs work: +Verify both files are reachable: ```bash -curl -s -o /dev/null -w "%{http_code}" http://localhost:8767/dev-generated.html +curl -s -o /dev/null -w "dev-latest: %{http_code}\n" http://localhost:8767/dev-latest.html && \ +curl -s -o /dev/null -w "dev-pr: %{http_code}\n" http://localhost:8767/dev-pr.html ``` -The page is at: `http://localhost:8767/dev-generated.html` +Tell the user both URLs: + +- `http://localhost:8767/dev-latest.html` — Released version (CDN) +- `http://localhost:8767/dev-pr.html` — PR Build (local) -Tell the user the URL so they can open it in a browser and test. +The nav bar in each file links to the other, so the reviewer can switch back and forth without re-typing URLs. ## Important notes -- The file is gitignored — it will not appear in `git status` or get committed. -- Each tab has its own Handsontable instance. The CDN version is saved to `window.HandsontableReleased` before the local build script overwrites `window.Handsontable`. -- Stylesheet switching uses the `disabled` attribute on `` elements so only one theme is active at a time. -- If the released version used the old CSS system (pre-v17, `dist/handsontable.full.css`), adjust the CDN CSS link accordingly. -- For very old version comparisons, check that the API used in `createInstance()` exists in both versions. +- Both files are gitignored (`dev*.html`) — they will not appear in `git status` or get committed. +- Each file loads only one Handsontable build — no dual-instance tricks needed. +- If the released version used the old CSS system (pre-v17, `dist/handsontable.full.css`), adjust the CDN CSS link in `dev-latest.html` accordingly. +- For very old version comparisons, check that the API used in the config block exists in both versions. From 3e969f638c845639bc622f3f7256db7e43aa49b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20=E2=80=98Budzio=E2=80=99=20Budnik?= <571316+budnix@users.noreply.github.com> Date: Tue, 12 May 2026 12:14:06 +0200 Subject: [PATCH 11/26] DEV-1650: Fix build pipeline noise - suppress child output on TTY success (#12547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * DEV-1650: Fix build pipeline noise - suppress output on TTY success * DEV-1650: Narrow fix — strip only rspack success line, keep lint warnings * DEV-1650: Add --verbose flag to parallel build pipeline * DEV-1650: Fix --verbose stripped from puppeteer passthrough flags --verbose was unconditionally removed from passthroughFlags, breaking test:e2e.verbose — puppeteer tasks list --verbose in their passthroughFilter to control Jasmine output verbosity. buildCmd()'s passthroughFilter already gates which tasks receive which flags, so build tasks remain unaffected. --- handsontable/scripts/run.mjs | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/handsontable/scripts/run.mjs b/handsontable/scripts/run.mjs index 3b5da429b45..aaec0b722d8 100644 --- a/handsontable/scripts/run.mjs +++ b/handsontable/scripts/run.mjs @@ -84,6 +84,12 @@ const themeArg = extraArgs.find(a => a.startsWith('--theme=')); // the dump step (rspack) and run-puppeteer.mjs compute the same run-ID filename. // For test:unit.jest, --testPathPattern= is also passed as argv (passthrough task), // which Jest accepts directly; puppeteer ignores the duplicate argv entry. +// --verbose may appear anywhere in rawArgs: npm strips the '--' separator when +// forwarding extra args so it lands in mainArgs, not extraArgs. +const isVerbose = rawArgs.includes('--verbose'); +// Do NOT strip --verbose from passthroughFlags — puppeteer tasks list it in +// their passthroughFilter and need it forwarded. buildCmd()'s passthroughFilter +// already prevents it from reaching tasks that don't expect it (e.g. build tasks). const passthroughFlags = extraArgs; const propagatedEnv = {}; @@ -223,19 +229,23 @@ function runParallelTask(name, spinner) { if (process.platform === 'win32') { delete baseEnv.Path; } + // In verbose mode use inherit so tools write directly to the terminal — + // many CLI tools (including rspack) suppress output when stdout is piped. + const stdio = isVerbose ? 'inherit' : ['ignore', 'pipe', 'pipe']; + const child = spawn(cmd, [], { cwd, env: { ...baseEnv, PATH: envPATH, NODE_NO_WARNINGS: '1', ...propagatedEnv }, - // Pipe both stdout and stderr so lint errors (written to stdout by ESLint/Stylelint) - // are captured and shown on failure alongside any stderr diagnostics. - stdio: ['ignore', 'pipe', 'pipe'], + stdio, shell: true, }); let combined = ''; - child.stdout.on('data', (d) => { combined += d.toString(); }); - child.stderr.on('data', (d) => { combined += d.toString(); }); + if (!isVerbose) { + child.stdout.on('data', (d) => { combined += d.toString(); }); + child.stderr.on('data', (d) => { combined += d.toString(); }); + } child.on('close', (code) => { const elapsed = Math.round(performance.now() - start); @@ -252,10 +262,9 @@ function runParallelTask(name, spinner) { } else { spinner.finish(name, true, elapsed); - // Flush any captured output (e.g. ESLint/Stylelint warnings) that was - // collected while the task ran quietly. Build tools that exit 0 with no - // warnings produce empty output, so this is a no-op for them. - if (combined.trim()) { + // In CI (non-TTY) flush all captured output for log collectors. + // On TTY the spinner ✓ line is sufficient; use --verbose to see output. + if (!isVerbose && !isTTY && combined.trim()) { process.stdout.write(`${combined.trimEnd()}\n`); } @@ -306,7 +315,7 @@ async function runPipeline(name, parallel) { tasks.forEach((t) => { subTasks[t] = TASKS[t] ?? { cmd: t, deps: [] }; }); - const spinner = new ParallelSpinner(); + const spinner = isVerbose ? { start: () => {}, finish: () => {} } : new ParallelSpinner(); const totalStart = performance.now(); try { From 78f6753082dde1d77249cc4087094659473dcf66 Mon Sep 17 00:00:00 2001 From: GabberPL <38166011+GabberPL@users.noreply.github.com> Date: Tue, 12 May 2026 12:18:07 +0200 Subject: [PATCH 12/26] DEV-1647: Fix built-in cell types example for React and Angular (#12540) Replace Handsontable.renderers.TextRenderer (undefined when using handsontable/base) with textRenderer imported directly from handsontable/renderers/textRenderer. --- .../content/guides/cell-types/cell-type/angular/example1.ts | 6 +++--- docs/content/guides/cell-types/cell-type/react/example1.jsx | 6 +++--- docs/content/guides/cell-types/cell-type/react/example1.tsx | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/content/guides/cell-types/cell-type/angular/example1.ts b/docs/content/guides/cell-types/cell-type/angular/example1.ts index 1b0a4e58a44..bac8d1a9a2c 100644 --- a/docs/content/guides/cell-types/cell-type/angular/example1.ts +++ b/docs/content/guides/cell-types/cell-type/angular/example1.ts @@ -1,16 +1,16 @@ /* file: app.component.ts */ import { Component } from '@angular/core'; import { GridSettings, HotTableModule} from '@handsontable/angular-wrapper'; -import Handsontable from 'handsontable/base'; +import { textRenderer } from 'handsontable/renderers/textRenderer'; import { BaseRenderer } from 'handsontable/renderers'; const yellowRenderer: BaseRenderer = (instance, td, ...rest) => { - Handsontable.renderers.TextRenderer(instance, td, ...rest); + textRenderer(instance, td, ...rest); td.style.backgroundColor = 'yellow'; }; const greenRenderer: BaseRenderer = (instance, td, ...rest) => { - Handsontable.renderers.TextRenderer(instance, td, ...rest); + textRenderer(instance, td, ...rest); td.style.backgroundColor = 'green'; }; diff --git a/docs/content/guides/cell-types/cell-type/react/example1.jsx b/docs/content/guides/cell-types/cell-type/react/example1.jsx index 2bc49bc2bf6..06cd8923f13 100644 --- a/docs/content/guides/cell-types/cell-type/react/example1.jsx +++ b/docs/content/guides/cell-types/cell-type/react/example1.jsx @@ -1,6 +1,6 @@ import { HotTable } from '@handsontable/react-wrapper'; -import Handsontable from 'handsontable/base'; import { registerAllModules } from 'handsontable/registry'; +import { textRenderer } from 'handsontable/renderers/textRenderer'; // register Handsontable's modules registerAllModules(); @@ -8,12 +8,12 @@ registerAllModules(); const ExampleComponent = () => { const colors = ['yellow', 'red', 'orange', 'green', 'blue', 'gray', 'black', 'white']; const yellowRenderer = (instance, td, ...rest) => { - Handsontable.renderers.TextRenderer(instance, td, ...rest); + textRenderer(instance, td, ...rest); td.style.backgroundColor = 'yellow'; }; const greenRenderer = (instance, td, ...rest) => { - Handsontable.renderers.TextRenderer(instance, td, ...rest); + textRenderer(instance, td, ...rest); td.style.backgroundColor = 'green'; }; diff --git a/docs/content/guides/cell-types/cell-type/react/example1.tsx b/docs/content/guides/cell-types/cell-type/react/example1.tsx index 960773a32e9..176949d2952 100644 --- a/docs/content/guides/cell-types/cell-type/react/example1.tsx +++ b/docs/content/guides/cell-types/cell-type/react/example1.tsx @@ -1,6 +1,6 @@ import { HotTable } from '@handsontable/react-wrapper'; -import Handsontable from 'handsontable/base'; import { registerAllModules } from 'handsontable/registry'; +import { textRenderer } from 'handsontable/renderers/textRenderer'; import { BaseRenderer } from 'handsontable/renderers'; // register Handsontable's modules @@ -10,12 +10,12 @@ const ExampleComponent = () => { const colors: string[] = ['yellow', 'red', 'orange', 'green', 'blue', 'gray', 'black', 'white']; const yellowRenderer: BaseRenderer = (instance, td, ...rest) => { - Handsontable.renderers.TextRenderer(instance, td, ...rest); + textRenderer(instance, td, ...rest); td.style.backgroundColor = 'yellow'; }; const greenRenderer: BaseRenderer = (instance, td, ...rest) => { - Handsontable.renderers.TextRenderer(instance, td, ...rest); + textRenderer(instance, td, ...rest); td.style.backgroundColor = 'green'; }; From aed715e90c6c735877bbd440a08cfb48f36e4b48 Mon Sep 17 00:00:00 2001 From: KrzysztofZie Date: Tue, 12 May 2026 12:53:34 +0200 Subject: [PATCH 13/26] =?UTF-8?q?Revert=20"DEV-413=20-=20*Computas*=20-=20?= =?UTF-8?q?`minSpareRows`=20creates=20a=20conflict=20when=20we=20ask=20?= =?UTF-8?q?=E2=80=A6"=20(#12544)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "DEV-413 - *Computas* - `minSpareRows` creates a conflict when we ask …" This reverts commit 84e4c6d8d2ba2d24a7e90b4df3d07dabae10193a. * DEV-413 - *Computas* - minSpareRows creates a conflict when we ask for the dataset.length * DEV-413 - *Computas* - minSpareRows creates a conflict when we ask for the dataset.length --- .changelogs/12519.json | 8 -- .../projects/hot-table/jest.config.js | 17 ++--- .../src/lib/hot-table.component.spec.ts | 75 ++++++++----------- .../hot-table/src/lib/hot-table.component.ts | 34 +++------ 4 files changed, 52 insertions(+), 82 deletions(-) delete mode 100644 .changelogs/12519.json diff --git a/.changelogs/12519.json b/.changelogs/12519.json deleted file mode 100644 index 8c482d22440..00000000000 --- a/.changelogs/12519.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issuesOrigin": "public", - "title": "Fixed the `@handsontable/angular-wrapper` ERROR RuntimeError: NG0100: ExpressionChangedAfterItHasBeenCheckedError", - "type": "fixed", - "issueOrPR": 12519, - "breaking": false, - "framework": "angular" -} diff --git a/wrappers/angular-wrapper/projects/hot-table/jest.config.js b/wrappers/angular-wrapper/projects/hot-table/jest.config.js index 33cb33792bf..4d8d762d171 100644 --- a/wrappers/angular-wrapper/projects/hot-table/jest.config.js +++ b/wrappers/angular-wrapper/projects/hot-table/jest.config.js @@ -2,18 +2,17 @@ module.exports = { preset: 'jest-preset-angular', setupFilesAfterEnv: ['/setup-jest.ts'], transform: { - '^.+\\.(ts|js|html)$': ['jest-preset-angular', { - tsconfig: '/tsconfig.spec.json', - stringifyContentPathRegex: '\\.html$' - }] + '^.+\\.(ts|js|mjs|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.html$', + }, + ], }, testEnvironment: 'jsdom', - moduleNameMapper: { - // Map any path aliases from tsconfig if needed - '\\.(css|less|scss|sass)$': '/src/__mocks__/styleMock.js' - }, transformIgnorePatterns: [ - 'node_modules/(?!.*\\.mjs$|zone\\.js|@angular|rxjs)' + 'node_modules/(?!.*\\.mjs$|zone\\.js|@angular|rxjs)', ], moduleDirectories: ['node_modules', '/../../node_modules'], testEnvironmentOptions: { diff --git a/wrappers/angular-wrapper/projects/hot-table/src/lib/hot-table.component.spec.ts b/wrappers/angular-wrapper/projects/hot-table/src/lib/hot-table.component.spec.ts index 8083308a977..1c1f33d5438 100644 --- a/wrappers/angular-wrapper/projects/hot-table/src/lib/hot-table.component.spec.ts +++ b/wrappers/angular-wrapper/projects/hot-table/src/lib/hot-table.component.spec.ts @@ -25,38 +25,53 @@ describe('HotTableComponent', () => { }); - it(`should render 'hot-table'`, async () => { + it(`should render 'hot-table'`, () => { fixture = TestBed.createComponent(HotTableComponent); fixture.componentInstance.settings = { ...settings }; fixture.detectChanges(); - await fixture.whenStable(); const elem = fixture.nativeElement; expect(elem.querySelectorAll('.handsontable').length).toBeGreaterThan(0); expect(fixture.componentInstance.hotInstance).toBeDefined(); }); - it(`should render 'hot-table' even when settings are not provided`, async () => { + it(`should render 'hot-table' even when settings are not provided`, () => { fixture = TestBed.createComponent(HotTableComponent); fixture.detectChanges(); - await fixture.whenStable(); const elem = fixture.nativeElement; expect(elem.querySelectorAll('.handsontable').length).toBeGreaterThan(0); expect(fixture.componentInstance.hotInstance).toBeDefined(); }); - it(`should set data`, async () => { + it(`should set data`, () => { fixture = TestBed.createComponent(HotTableComponent); fixture.componentInstance.settings = {}; fixture.componentInstance.data = createSpreadsheetData(5, 5); fixture.detectChanges(); - await fixture.whenStable(); expect(fixture.componentInstance.hotInstance.getDataAtCell(0, 0)).toBe('A1'); }); - it(`should be possible to set some option and pass it to Handsontable`, async () => { + it(`should use data from settings when [data] input is not bound`, () => { + const settingsData = createSpreadsheetData(3, 3); + + fixture = TestBed.createComponent(HotTableComponent); + // GridSettings intentionally omits 'data' (users should use [data] binding), + // but we cast here to verify the runtime merge does not override it with null + // when [data] is unbound (e.g. JavaScript users or future API changes). + fixture.componentInstance.settings = { + ...settings, + data: settingsData, + } as unknown as GridSettings; + // [data] input is intentionally not set — remains null (the default) + fixture.detectChanges(); + + expect(fixture.componentInstance.hotInstance.getDataAtCell(0, 0)).toBe('A1'); + expect(fixture.componentInstance.hotInstance.countRows()).toBe(3); + }); + + it(`should be possible to set some option and pass it to Handsontable`, () => { fixture = TestBed.createComponent(HotTableComponent); fixture.componentInstance.settings = { ...settings, @@ -67,7 +82,6 @@ describe('HotTableComponent', () => { }; fixture.detectChanges(); - await fixture.whenStable(); const handsontableSettings = fixture.componentInstance.hotInstance.getSettings(); expect(handsontableSettings.rowHeaders).toBe(true); @@ -77,12 +91,11 @@ describe('HotTableComponent', () => { }); describe('ngOnChanges', () => { - it('should update Handsontable settings if settings change and it is not the first change', async () => { + it('should update Handsontable settings if settings change and it is not the first change', () => { const newSettings = { readOnly: true }; fixture = TestBed.createComponent(HotTableComponent); fixture.componentInstance.settings = {}; fixture.detectChanges(); - await fixture.whenStable(); const hotSettingsResolver = fixture.componentRef.injector.get(HotSettingsResolver); const component = fixture.componentInstance; const applyCustomSettingsSpy = jest.spyOn(hotSettingsResolver, 'applyCustomSettings'); @@ -98,12 +111,11 @@ describe('HotTableComponent', () => { expect(updateHotTableSpy).toHaveBeenCalledWith(newSettings, false); }); - it('should not update Handsontable settings if it is the first change', async () => { + it('should not update Handsontable settings if it is the first change', () => { const newSettings = { data: [[1, 2, 3]] }; fixture = TestBed.createComponent(HotTableComponent); fixture.componentInstance.settings = {}; fixture.detectChanges(); - await fixture.whenStable(); const hotSettingsResolver = fixture.componentRef.injector.get(HotSettingsResolver); const applyCustomSettingsSpy = jest.spyOn(hotSettingsResolver, 'applyCustomSettings'); const component = fixture.componentInstance; @@ -119,12 +131,11 @@ describe('HotTableComponent', () => { expect(updateHotTableSpy).not.toHaveBeenCalled(); }); - it('should update Handsontable data if data change and it is not the first change', async () => { + it('should update Handsontable data if data change and it is not the first change', () => { const newData = [[1, 2, 3]]; fixture = TestBed.createComponent(HotTableComponent); fixture.componentInstance.settings = {}; fixture.detectChanges(); - await fixture.whenStable(); const component = fixture.componentInstance; const updateDataSpy = jest.spyOn(component.hotInstance, 'updateData'); @@ -137,12 +148,11 @@ describe('HotTableComponent', () => { expect(updateDataSpy).toHaveBeenCalledWith(newData); }); - it('should not update Handsontable data if data change and it is the first change', async () => { + it('should not update Handsontable data if data change and it is the first change', () => { const newData = [[1, 2, 3]]; fixture = TestBed.createComponent(HotTableComponent); fixture.componentInstance.settings = {}; fixture.detectChanges(); - await fixture.whenStable(); const component = fixture.componentInstance; const updateDataSpy = jest.spyOn(component.hotInstance, 'updateData'); @@ -155,14 +165,13 @@ describe('HotTableComponent', () => { expect(updateDataSpy).not.toHaveBeenCalledWith(newData); }); - it('should not pass init-only settings to updateSettings after initialization', async () => { + it('should not pass init-only settings to updateSettings after initialization', () => { fixture = TestBed.createComponent(HotTableComponent); fixture.componentInstance.settings = { renderAllRows: false, width: 300, }; fixture.detectChanges(); - await fixture.whenStable(); const component = fixture.componentInstance; const updateSettingsSpy = jest.spyOn(component.hotInstance, 'updateSettings'); @@ -184,7 +193,7 @@ describe('HotTableComponent', () => { }); describe('ngOnDestroy', () => { - it('should destroy Handsontable instance and editor component references, when columns property is an array', async () => { + it('should destroy Handsontable instance and editor component references, when columns property is an array', () => { fixture = TestBed.createComponent(HotTableComponent); const editorRefMock = { destroy: jest.fn(), @@ -198,7 +207,6 @@ describe('HotTableComponent', () => { ], }; fixture.detectChanges(); - await fixture.whenStable(); const hotInstance = fixture.componentInstance.hotInstance; const destroySpy = jest.spyOn(hotInstance, 'destroy'); @@ -208,7 +216,7 @@ describe('HotTableComponent', () => { expect(destroySpy).toHaveBeenCalled(); }); - it('should destroy Handsontable instance, when columns property is a function', async () => { + it('should destroy Handsontable instance, when columns property is a function', () => { fixture = TestBed.createComponent(HotTableComponent); fixture.componentInstance.settings = { @@ -217,7 +225,6 @@ describe('HotTableComponent', () => { }; fixture.detectChanges(); - await fixture.whenStable(); const hotInstance = fixture.componentInstance.hotInstance; const destroySpy = jest.spyOn(hotInstance, 'destroy'); @@ -225,7 +232,7 @@ describe('HotTableComponent', () => { expect(destroySpy).toHaveBeenCalled(); }); - it('should destroy Handsontable instance, when columns property is undefined', async () => { + it('should destroy Handsontable instance, when columns property is undefined', () => { fixture = TestBed.createComponent(HotTableComponent); fixture.componentInstance.settings = { @@ -233,29 +240,16 @@ describe('HotTableComponent', () => { }; fixture.detectChanges(); - await fixture.whenStable(); const hotInstance = fixture.componentInstance.hotInstance; const destroySpy = jest.spyOn(hotInstance, 'destroy'); fixture.componentInstance.ngOnDestroy(); expect(destroySpy).toHaveBeenCalled(); }); - - it('should not create Handsontable instance if component is destroyed before microtask resolves', async () => { - fixture = TestBed.createComponent(HotTableComponent); - fixture.componentInstance.settings = { ...settings }; - fixture.detectChanges(); - - // Destroy before the pending Promise resolves - fixture.componentInstance.ngOnDestroy(); - await fixture.whenStable(); - - expect(fixture.componentInstance.hotInstance).toBeNull(); - }); }); describe('hooks', () => { - it(`should use Handsontable as a hook's context, if is defined as a component's method`, async () => { + it(`should use Handsontable as a hook's context, if is defined as a component's method`, () => { fixture = TestBed.createComponent(HotTableComponent); fixture.componentInstance.settings = { ...settings, @@ -264,7 +258,6 @@ describe('HotTableComponent', () => { }, }; fixture.detectChanges(); - await fixture.whenStable(); const instance: Handsontable = fixture.componentInstance.hotInstance; instance.runHooks('afterInit'); @@ -273,7 +266,7 @@ describe('HotTableComponent', () => { expect(instance.getPlugin('copyPaste')).toBeTruthy(); }); - it(`should use Handsontable as a hook's context, if is defined as a function in settings object`, async () => { + it(`should use Handsontable as a hook's context, if is defined as a function in settings object`, () => { fixture = TestBed.createComponent(HotTableComponent); fixture.componentInstance.settings = { ...settings, @@ -282,7 +275,6 @@ describe('HotTableComponent', () => { }, }; fixture.detectChanges(); - await fixture.whenStable(); const instance: Handsontable = fixture.componentInstance.hotInstance.runHooks('afterInit'); @@ -290,7 +282,7 @@ describe('HotTableComponent', () => { expect(instance.getPlugin('copyPaste')).toBeTruthy(); }); - it(`should allow to block 'before*' hooks`, async () => { + it(`should allow to block 'before*' hooks`, () => { fixture = TestBed.createComponent(HotTableComponent); const component = fixture.componentInstance; component.settings = { @@ -307,7 +299,6 @@ describe('HotTableComponent', () => { }; let afterChangeResult = false; fixture.detectChanges(); - await fixture.whenStable(); component.hotInstance.setDataAtCell(0, 0, 'test'); diff --git a/wrappers/angular-wrapper/projects/hot-table/src/lib/hot-table.component.ts b/wrappers/angular-wrapper/projects/hot-table/src/lib/hot-table.component.ts index 5fc3d691010..c6e1303eac2 100644 --- a/wrappers/angular-wrapper/projects/hot-table/src/lib/hot-table.component.ts +++ b/wrappers/angular-wrapper/projects/hot-table/src/lib/hot-table.component.ts @@ -48,7 +48,6 @@ export class HotTableComponent implements AfterViewInit, OnChanges, OnDestroy { /** The Handsontable instance. */ private __hotInstance: Handsontable | null = null; - private _pendingDestroy = false; constructor( private readonly _hotSettingsResolver: HotSettingsResolver, @@ -85,32 +84,23 @@ export class HotTableComponent implements AfterViewInit, OnChanges, OnDestroy { */ ngAfterViewInit(): void { let options: Handsontable.GridSettings = this._hotSettingsResolver.applyCustomSettings(this.settings); + const negotiatedSettings = this.getNegotiatedSettings(options); options = { ...options, ...negotiatedSettings, ...(this.data != null ? { data: this.data } : {}) }; - // Defer initialization to the next microtask to prevent NG0100 - // (ExpressionChangedAfterItHasBeenCheckedError). HOT mutates the input - // data array during init (e.g. minSpareRows), which Angular's dev-mode - // double-check detects as an illegal mid-cycle change. - Promise.resolve().then(() => { - if (this._pendingDestroy) { - return; - } - - this.ngZone.runOutsideAngular(() => { - this.hotInstance = new Handsontable.Core(this.container.nativeElement, options); + this.ngZone.runOutsideAngular(() => { + this.hotInstance = new Handsontable.Core(this.container.nativeElement, options); - (this.hotInstance as any)._angularEnvironmentInjector = this.environmentInjector; + (this.hotInstance as any)._angularEnvironmentInjector = this.environmentInjector; - this.hotInstance.init(); - }); + this.hotInstance.init(); + }); - this._hotConfig.config$.pipe(skip(1), takeUntilDestroyed(this.destroyRef)).subscribe(() => { - if (this.hotInstance) { - const negotiatedSettings = this.getNegotiatedSettings(this.settings); - this.updateHotTable(negotiatedSettings); - } - }); + this._hotConfig.config$.pipe(skip(1), takeUntilDestroyed(this.destroyRef)).subscribe(() => { + if (this.hotInstance) { + const negotiatedSettings = this.getNegotiatedSettings(this.settings); + this.updateHotTable(negotiatedSettings); + } }); } @@ -141,8 +131,6 @@ export class HotTableComponent implements AfterViewInit, OnChanges, OnDestroy { * Destroys the Handsontable instance and clears the columns from custom editors. */ ngOnDestroy(): void { - this._pendingDestroy = true; - if (!this.hotInstance) { return; } From 12a934f395fbd7b58726ea432cb0541adf3a4e6c Mon Sep 17 00:00:00 2001 From: GabberPL <38166011+GabberPL@users.noreply.github.com> Date: Tue, 12 May 2026 13:03:23 +0200 Subject: [PATCH 14/26] DEV-1631: Fix dialog hide() resetting scroll position when no prior selection (#12514) * DEV-1631: Fix dialog hide scrolling viewport to top when no prior selection When the loading overlay was hidden with no prior cell selection (user was just scrolling), dialog.js called selectCell(0, 0) which scrolled the viewport back to the top. Pass scrollToCell=false so the viewport stays at the user's current scroll position. * DEV-1631: Add changelog entry for PR #12514 * DEV-1631: Clear selection state in else branch of dialog.hide() Null out #selectionState consistently in both branches of the hide() method, matching the if-branch behavior. * DEV-1631: Fix repeated show/hide cycles scrolling to row 0 The previous fix (selectCell(0,0,,,false)) caused a cascade: the second hide() found #selectionState with ranges.length=1 (from the first hide selecting (0,0)), taking the if-branch which calls importSelection -> setRangeFocus -> afterSetFocus -> viewportScroller.scrollTo(0,0) unsuspended, scrolling to row 0. Fix: when no prior selection existed, call view.render() only. This preserves the exact pre-dialog state (no selection, same scroll position) across any number of show/hide cycles. Added regression test for the repeated cycle scenario. --- .changelogs/12514.json | 8 ++ handsontable/src/plugins/dialog/dialog.js | 3 +- .../loading/__tests__/options/hide.spec.js | 76 +++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 .changelogs/12514.json diff --git a/.changelogs/12514.json b/.changelogs/12514.json new file mode 100644 index 00000000000..1a038b71776 --- /dev/null +++ b/.changelogs/12514.json @@ -0,0 +1,8 @@ +{ + "issuesOrigin": "public", + "title": "Fixed the loading overlay resetting the grid scroll position to the top when no cell was selected before showing the overlay.", + "type": "fixed", + "issueOrPR": 12514, + "breaking": false, + "framework": "none" +} diff --git a/handsontable/src/plugins/dialog/dialog.js b/handsontable/src/plugins/dialog/dialog.js index 571a0f16b49..cce520b6fcb 100644 --- a/handsontable/src/plugins/dialog/dialog.js +++ b/handsontable/src/plugins/dialog/dialog.js @@ -372,7 +372,8 @@ export class Dialog extends BasePlugin { this.hot.view.render(); this.#selectionState = null; } else { - this.hot.selectCell(0, 0); + this.hot.view.render(); + this.#selectionState = null; } this.hot.runHooks('afterDialogHide'); diff --git a/handsontable/src/plugins/loading/__tests__/options/hide.spec.js b/handsontable/src/plugins/loading/__tests__/options/hide.spec.js index a4c6cef106e..5666d749254 100644 --- a/handsontable/src/plugins/loading/__tests__/options/hide.spec.js +++ b/handsontable/src/plugins/loading/__tests__/options/hide.spec.js @@ -39,4 +39,80 @@ describe('Loading - hide method', () => { expect(loadingPlugin.isVisible()).toBe(false); }); + + it('should preserve scroll position when hiding the loading overlay with no prior cell selection', async() => { + handsontable({ + data: createSpreadsheetData(100, 5), + height: 300, + width: 400, + loading: true, + }); + + await scrollViewportTo({ row: 50, verticalSnap: 'top' }); + + const firstVisibleRow = getFirstFullyVisibleRow(); + + expect(getSelected()).toBeUndefined(); + + const loadingPlugin = getPlugin('loading'); + + loadingPlugin.show(); + loadingPlugin.hide(); + + expect(getFirstFullyVisibleRow()).toBe(firstVisibleRow); + }); + + it('should preserve scroll position when hiding the loading overlay after updateData() with no prior cell selection', async() => { + const initialData = createSpreadsheetData(50, 5); + const moreData = createSpreadsheetData(100, 5); + + handsontable({ + data: initialData, + height: 300, + width: 400, + loading: true, + }); + + await scrollViewportTo({ row: 30, verticalSnap: 'top' }); + + const firstVisibleRow = getFirstFullyVisibleRow(); + + expect(getSelected()).toBeUndefined(); + + const loadingPlugin = getPlugin('loading'); + + loadingPlugin.show(); + await updateData(moreData); + loadingPlugin.hide(); + + expect(getFirstFullyVisibleRow()).toBe(firstVisibleRow); + }); + + it('should preserve scroll position across repeated show/hide cycles with no prior cell selection', async() => { + handsontable({ + data: createSpreadsheetData(100, 5), + height: 300, + width: 400, + loading: true, + }); + + await scrollViewportTo({ row: 50, verticalSnap: 'top' }); + + const firstVisibleRow = getFirstFullyVisibleRow(); + + expect(getSelected()).toBeUndefined(); + + const loadingPlugin = getPlugin('loading'); + + // First cycle (simulates first data load) + loadingPlugin.show(); + loadingPlugin.hide(); + + // Second cycle (simulates second data load - was scrolling to row 0) + loadingPlugin.show(); + loadingPlugin.hide(); + + expect(getFirstFullyVisibleRow()).toBe(firstVisibleRow); + expect(getSelected()).toBeUndefined(); + }); }); From 7f189c67f97c21331d8afc4f0428494ac644181f Mon Sep 17 00:00:00 2001 From: Marek Martuszewski Date: Tue, 12 May 2026 13:04:08 +0200 Subject: [PATCH 15/26] Simplify import-csv-excel recipe: one section + empty state, no preview (#12524) - Drop the textarea/preview pattern; Choose file and Load sample data share a single dropzone and feed loadIntoGrid() directly. - Lazy-init Handsontable behind an empty-state panel so the grid no longer shows a single blank cell before any import. - Restyle dropzone, buttons, and surrounding UI to match the spreadsheet look (edge-to-edge dividers, border-radius 0, gray-6/gray-5 tokens). - Align Step 1/2/4/5, "Try it quickly", "How it works", and "What you learned" with the simplified demo. Co-authored-by: Claude Opus 4.7 (1M context) --- .../import-csv-excel/angular/example1.css | 121 ++++++++++++-- .../import-csv-excel/angular/example1.ts | 115 +++++-------- .../import-csv-excel/import-csv-excel.md | 155 +++++++++--------- .../import-csv-excel/javascript/example1.css | 153 +++++++++-------- .../import-csv-excel/javascript/example1.html | 38 ++--- .../import-csv-excel/javascript/example1.js | 96 ++++------- .../import-csv-excel/javascript/example1.ts | 109 ++++-------- .../import-csv-excel/react/example1.css | 146 +++++++++-------- .../import-csv-excel/react/example1.jsx | 133 +++++---------- .../import-csv-excel/react/example1.tsx | 133 +++++---------- 10 files changed, 554 insertions(+), 645 deletions(-) diff --git a/docs/content/recipes/import-export/import-csv-excel/angular/example1.css b/docs/content/recipes/import-export/import-csv-excel/angular/example1.css index 51ff63673c9..f7ab699f27e 100644 --- a/docs/content/recipes/import-export/import-csv-excel/angular/example1.css +++ b/docs/content/recipes/import-export/import-csv-excel/angular/example1.css @@ -1,25 +1,118 @@ +.import-csv-excel-wrap { + display: flex; + flex-direction: column; + margin: 0 -1rem; +} + .import-dropzone { - border: 2px dashed var(--ht-border-color, #ccc); - border-radius: 8px; - padding: 16px; + padding: 1.5rem 1rem; text-align: center; - margin-bottom: 12px; + border: 0; + border-bottom: 1px solid var(--sl-color-gray-5); + border-radius: 0; + background: transparent; + color: var(--sl-color-gray-2); + font-size: var(--sl-text-xs); + transition: background 0.15s ease; cursor: pointer; } -.import-dropzone--active { - border-color: var(--ht-accent-color, #1a42e8); - background: rgba(26, 66, 232, 0.05); +.import-dropzone p { + margin: 0 0 0.75rem; +} + +.import-dropzone.import-dropzone--active { + background: var(--sl-color-gray-6); } -.import-preview { - border: 1px solid var(--ht-border-color, #e0e0e0); - border-radius: 4px; - padding: 12px; - margin-bottom: 12px; +.import-actions { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.import-file-label { + display: inline-block; + cursor: pointer; +} + +.import-file-label span, +.import-sample-btn { + display: inline-flex; + align-items: center; + padding: 0.375rem 0.75rem; + border: 1px solid var(--sl-color-gray-5); + border-radius: 0; + background: var(--sl-color-gray-6); + color: var(--sl-color-gray-2); + font-family: var(--sl-font); + font-size: var(--sl-text-xs); + font-weight: 500; + line-height: 1.4; + cursor: pointer; + transition: background-color 0.15s, color 0.15s, border-color 0.15s; +} + +.import-file-label:hover span, +.import-sample-btn:hover { + background: var(--sl-color-gray-5); + color: var(--sl-color-white); +} + +.import-file-label input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.import-msg { + margin: 0; + padding: 0.75rem 1rem; + border-radius: 0; + font-size: var(--sl-text-xs); } .import-msg--error { - color: var(--ht-cell-error-color, #c62828); - padding: 8px 0; + border-bottom: 1px solid var(--sl-color-red, #ef4444); + background: var(--sl-color-red-low, rgba(239, 68, 68, 0.12)); + color: var(--sl-color-red, #ef4444); +} + +.import-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 2.5rem 1rem; + min-height: 200px; + color: var(--sl-color-gray-2); + text-align: center; +} + +.import-empty-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + margin-bottom: 0.25rem; + border: 1px solid var(--sl-color-gray-5); + color: var(--sl-color-gray-3); +} + +.import-empty-title { + margin: 0; + color: var(--sl-color-white); + font-size: var(--sl-text-sm); + font-weight: 600; +} + +.import-empty-text { + margin: 0; + max-width: 38ch; + font-size: var(--sl-text-xs); + line-height: 1.5; } diff --git a/docs/content/recipes/import-export/import-csv-excel/angular/example1.ts b/docs/content/recipes/import-export/import-csv-excel/angular/example1.ts index 34bbd868d8c..14a9604b92c 100644 --- a/docs/content/recipes/import-export/import-csv-excel/angular/example1.ts +++ b/docs/content/recipes/import-export/import-csv-excel/angular/example1.ts @@ -147,29 +147,18 @@ async function parseFile(file: File): Promise { (dragleave)="onDragLeave()" (drop)="onDrop($event)" > -

Drop a .csv or .xlsx file here, or use the file picker.

- -
- -
- - -
- +

Drop a .csv or .xlsx file here, or pick a source.

+
+ +
@@ -177,19 +166,22 @@ async function parseFile(file: File): Promise {
{{ errorMessage }}
} - @if (showPreview) { -
-

Detected column headers (not loaded yet):

-
    - @for (h of pending?.headers ?? []; track h) { -
  • {{ h }}
  • - } -
- + @if (gridData.length === 0) { +
+ +

No data loaded yet

+

+ Drop a CSV or Excel file above, choose a file, or load the sample data to populate the table. +

+ } @else { + } - -
`, }) @@ -198,11 +190,9 @@ export class AppComponent { isDragOver = false; errorMessage = ''; - showPreview = false; - pending: ParsedPayload | null = null; gridData: Record[] = []; - sampleCsv = `Product,Category,In stock,Price + private readonly SAMPLE_CSV = `Product,Category,In stock,Price Widget A,Hardware,true,19.99 Widget B,Hardware,false,24.5 Service Pack,Services,true,0`; @@ -242,34 +232,16 @@ Service Pack,Services,true,0`; input.value = ''; } - async parseSampleCsv(): Promise { + async loadSampleData(): Promise { this.errorMessage = ''; try { - const payload = parseCsvText(this.sampleCsv); - this.setPending(payload); + const payload = parseCsvText(this.SAMPLE_CSV); + this.loadIntoGrid(payload); } catch (e) { - this.clearPendingPreview(); this.errorMessage = e instanceof Error ? e.message : String(e); } } - applyToGrid(): void { - this.errorMessage = ''; - if (!this.pending) { - this.errorMessage = 'Nothing to load. Import a file first.'; - return; - } - const { headers, rows } = this.pending; - this.gridSettings = { - ...this.gridSettings, - colHeaders: headers, - columns: this.columnsFromHeaders(headers), - }; - this.gridData = [...rows]; - this.showPreview = false; - this.pending = null; - } - private async handleFile(file: File): Promise { this.errorMessage = ''; if (file.size === 0) { @@ -278,30 +250,25 @@ Service Pack,Services,true,0`; } try { const payload = await parseFile(file); - this.setPending(payload); + this.loadIntoGrid(payload); } catch (e) { - this.clearPendingPreview(); this.errorMessage = e instanceof Error ? e.message : String(e); } } - private setPending(payload: ParsedPayload): void { - this.pending = payload; - this.errorMessage = ''; - this.showPreview = true; - } - - private clearPendingPreview(): void { - this.pending = null; - this.showPreview = false; + private loadIntoGrid(payload: ParsedPayload): void { + const { headers, rows } = payload; + this.gridSettings = { + ...this.gridSettings, + colHeaders: headers, + columns: this.columnsFromHeaders(headers, rows), + }; + this.gridData = [...rows]; } - private columnsFromHeaders(headers: string[]): GridSettings['columns'] { - if (!this.pending) { - return headers.map((data) => ({ data, type: 'text' })); - } + private columnsFromHeaders(headers: string[], rows: Record[]): GridSettings['columns'] { return headers.map((data) => { - const values = (this.pending?.rows ?? []) + const values = rows .map((row) => row[data]) .filter((v) => v !== null); if (values.length > 0 && values.every((v) => typeof v === 'number')) { diff --git a/docs/content/recipes/import-export/import-csv-excel/import-csv-excel.md b/docs/content/recipes/import-export/import-csv-excel/import-csv-excel.md index d1bcd7d0236..47be1c736d7 100644 --- a/docs/content/recipes/import-export/import-csv-excel/import-csv-excel.md +++ b/docs/content/recipes/import-export/import-csv-excel/import-csv-excel.md @@ -95,18 +95,22 @@ Use `accept` on the file input and check `file.name` to route `.csv` and `.xlsx` ```html
-

Drop a .csv or .xlsx file here, or use the file picker.

- +

Drop a .csv or .xlsx file here, or pick a source.

+
+ + +
``` **What's happening:** - The `accept` attribute restricts the system file picker to `.csv` and `.xlsx`. This is a hint to the browser only -- you must validate the extension in JavaScript as well. - The real `` is visually hidden (positioned off-screen with `opacity: 0`) and activated via the wrapping `
- -
- - -
- +

Drop a .csv or .xlsx file here, or pick a source.

+
+ +
- diff --git a/docs/content/recipes/import-export/import-csv-excel/javascript/example1.js b/docs/content/recipes/import-export/import-csv-excel/javascript/example1.js index f7a2449e658..66df0b52c5d 100644 --- a/docs/content/recipes/import-export/import-csv-excel/javascript/example1.js +++ b/docs/content/recipes/import-export/import-csv-excel/javascript/example1.js @@ -193,21 +193,13 @@ async function parseFile(file) { } throw new Error('Unsupported file type. Use a .csv or .xlsx file.'); } -let pending = null; -function renderHeaderPreview(listEl, headers) { - listEl.innerHTML = ''; - for (const h of headers) { - const li = document.createElement('li'); - li.textContent = h; - listEl.appendChild(li); - } -} -function columnsFromHeaders(headers) { - if (!pending) { - return headers.map((data) => ({ data, type: 'text' })); - } +const SAMPLE_CSV = `Product,Category,In stock,Price +Widget A,Hardware,true,19.99 +Widget B,Hardware,false,24.5 +Service Pack,Services,true,0`; +function columnsFromHeaders(headers, rows) { return headers.map((data) => { - const values = pending?.rows + const values = rows .map((row) => row[data]) .filter((v) => v !== null); if (values.length > 0 && values.every((v) => typeof v === 'number')) { @@ -220,36 +212,34 @@ function columnsFromHeaders(headers) { }); } const gridContainer = document.querySelector('#example1'); +const emptyEl = document.querySelector('#import-empty'); const errEl = document.querySelector('#import-error'); -const previewEl = document.querySelector('#import-preview'); -const headerListEl = document.querySelector('#import-header-list'); -const applyBtn = document.querySelector('#import-apply'); const fileInput = document.querySelector('#import-file'); const dropzone = document.querySelector('#import-dropzone'); -const sampleTa = document.querySelector('#import-sample-csv'); -const sampleBtn = document.querySelector('#import-parse-sample'); -const initialSettings = { - data: [], - columns: [], - colHeaders: [], - rowHeaders: true, - height: 'auto', - width: '100%', - licenseKey: 'non-commercial-and-evaluation', -}; -const hot = new Handsontable(gridContainer, initialSettings); -function clearPendingPreview() { - pending = null; - if (previewEl) { - previewEl.hidden = true; +const sampleBtn = document.querySelector('#import-load-sample'); +let hot = null; +function loadIntoGrid({ headers, rows }) { + const columns = columnsFromHeaders(headers, rows); + if (!hot) { + if (emptyEl) { + emptyEl.hidden = true; + } + if (gridContainer) { + gridContainer.hidden = false; + } + hot = new Handsontable(gridContainer, { + data: rows, + columns, + colHeaders: headers, + rowHeaders: true, + height: 'auto', + width: '100%', + licenseKey: 'non-commercial-and-evaluation', + }); } -} -function setPending(payload) { - pending = payload; - clearError(errEl); - if (headerListEl && previewEl) { - renderHeaderPreview(headerListEl, payload.headers); - previewEl.hidden = false; + else { + hot.updateSettings({ colHeaders: headers, columns }); + hot.loadData(rows); } } async function handleFile(file) { @@ -263,30 +253,12 @@ async function handleFile(file) { } try { const payload = await parseFile(file); - setPending(payload); + loadIntoGrid(payload); } catch (e) { - clearPendingPreview(); showError(errEl, e instanceof Error ? e.message : String(e)); } } -applyBtn?.addEventListener('click', () => { - clearError(errEl); - if (!pending) { - showError(errEl, 'Nothing to load. Import a file first.'); - return; - } - const { headers, rows } = pending; - hot.updateSettings({ - colHeaders: headers, - columns: columnsFromHeaders(headers), - }); - hot.loadData(rows); - if (previewEl) { - previewEl.hidden = true; - } - pending = null; -}); fileInput?.addEventListener('change', () => { const f = fileInput.files?.[0]; handleFile(f); @@ -309,12 +281,10 @@ sampleBtn?.addEventListener('click', async () => { clearError(errEl); try { const PapaRef = await ensurePapa(); - const text = sampleTa?.value ?? ''; - const payload = parseCsvText(text, PapaRef); - setPending(payload); + const payload = parseCsvText(SAMPLE_CSV, PapaRef); + loadIntoGrid(payload); } catch (e) { - clearPendingPreview(); showError(errEl, e instanceof Error ? e.message : String(e)); } }); diff --git a/docs/content/recipes/import-export/import-csv-excel/javascript/example1.ts b/docs/content/recipes/import-export/import-csv-excel/javascript/example1.ts index c1a8bc0e525..6600f63e720 100644 --- a/docs/content/recipes/import-export/import-csv-excel/javascript/example1.ts +++ b/docs/content/recipes/import-export/import-csv-excel/javascript/example1.ts @@ -282,25 +282,14 @@ async function parseFile(file: File): Promise { throw new Error('Unsupported file type. Use a .csv or .xlsx file.'); } -let pending: ParsedPayload | null = null; - -function renderHeaderPreview(listEl: HTMLElement, headers: string[]): void { - listEl.innerHTML = ''; - for (const h of headers) { - const li = document.createElement('li'); - - li.textContent = h; - listEl.appendChild(li); - } -} - -function columnsFromHeaders(headers: string[]): GridSettings['columns'] { - if (!pending) { - return headers.map((data) => ({ data, type: 'text' as const })); - } +const SAMPLE_CSV = `Product,Category,In stock,Price +Widget A,Hardware,true,19.99 +Widget B,Hardware,false,24.5 +Service Pack,Services,true,0`; +function columnsFromHeaders(headers: string[], rows: ParsedRow[]): GridSettings['columns'] { return headers.map((data) => { - const values = pending?.rows + const values = rows .map((row) => row[data]) .filter((v): v is string | number | boolean => v !== null); @@ -317,42 +306,34 @@ function columnsFromHeaders(headers: string[]): GridSettings['columns'] { } const gridContainer = document.querySelector('#example1')!; +const emptyEl = document.querySelector('#import-empty'); const errEl = document.querySelector('#import-error'); -const previewEl = document.querySelector('#import-preview'); -const headerListEl = document.querySelector('#import-header-list'); -const applyBtn = document.querySelector('#import-apply'); const fileInput = document.querySelector('#import-file'); const dropzone = document.querySelector('#import-dropzone'); -const sampleTa = document.querySelector('#import-sample-csv'); -const sampleBtn = document.querySelector('#import-parse-sample'); - -const initialSettings: GridSettings = { - data: [], - columns: [], - colHeaders: [], - rowHeaders: true, - height: 'auto', - width: '100%', - licenseKey: 'non-commercial-and-evaluation', -}; +const sampleBtn = document.querySelector('#import-load-sample'); -const hot = new Handsontable(gridContainer, initialSettings); +let hot: Handsontable | null = null; -function clearPendingPreview(): void { - pending = null; +function loadIntoGrid({ headers, rows }: ParsedPayload): void { + const columns = columnsFromHeaders(headers, rows); - if (previewEl) { - previewEl.hidden = true; - } -} - -function setPending(payload: ParsedPayload): void { - pending = payload; - clearError(errEl); - - if (headerListEl && previewEl) { - renderHeaderPreview(headerListEl, payload.headers); - previewEl.hidden = false; + if (!hot) { + if (emptyEl) { + emptyEl.hidden = true; + } + gridContainer.hidden = false; + hot = new Handsontable(gridContainer, { + data: rows, + columns, + colHeaders: headers, + rowHeaders: true, + height: 'auto', + width: '100%', + licenseKey: 'non-commercial-and-evaluation', + }); + } else { + hot.updateSettings({ colHeaders: headers, columns }); + hot.loadData(rows); } } @@ -372,38 +353,12 @@ async function handleFile(file: File | null | undefined): Promise { try { const payload = await parseFile(file); - setPending(payload); + loadIntoGrid(payload); } catch (e) { - clearPendingPreview(); - showError(errEl, e instanceof Error ? e.message : String(e)); } } -applyBtn?.addEventListener('click', () => { - clearError(errEl); - - if (!pending) { - showError(errEl, 'Nothing to load. Import a file first.'); - - return; - } - - const { headers, rows } = pending; - - hot.updateSettings({ - colHeaders: headers, - columns: columnsFromHeaders(headers), - }); - hot.loadData(rows); - - if (previewEl) { - previewEl.hidden = true; - } - - pending = null; -}); - fileInput?.addEventListener('change', () => { const f = fileInput.files?.[0]; @@ -433,12 +388,10 @@ sampleBtn?.addEventListener('click', async () => { try { const PapaRef = await ensurePapa(); - const text = sampleTa?.value ?? ''; - const payload = parseCsvText(text, PapaRef); + const payload = parseCsvText(SAMPLE_CSV, PapaRef); - setPending(payload); + loadIntoGrid(payload); } catch (e) { - clearPendingPreview(); showError(errEl, e instanceof Error ? e.message : String(e)); } }); diff --git a/docs/content/recipes/import-export/import-csv-excel/react/example1.css b/docs/content/recipes/import-export/import-csv-excel/react/example1.css index 286a715eec8..37cedaaae88 100644 --- a/docs/content/recipes/import-export/import-csv-excel/react/example1.css +++ b/docs/content/recipes/import-export/import-csv-excel/react/example1.css @@ -1,37 +1,61 @@ .import-csv-excel-wrap { display: flex; flex-direction: column; - gap: 12px; - margin-bottom: 12px; + margin: 0 -1rem; } .import-dropzone { - border: 2px dashed var(--sl-color-gray-4, #ccc); - border-radius: 8px; - padding: 16px; + padding: 1.5rem 1rem; text-align: center; - background: var(--sl-color-bg-inline-code, rgba(0, 0, 0, 0.04)); - transition: border-color 0.15s ease, background 0.15s ease; + border: 0; + border-bottom: 1px solid var(--sl-color-gray-5); + border-radius: 0; + background: transparent; + color: var(--sl-color-gray-2); + font-size: var(--sl-text-xs); + transition: background 0.15s ease; +} + +.import-dropzone p { + margin: 0 0 0.75rem; } .import-dropzone.import-dropzone--active { - border-color: var(--sl-color-accent, #3b82f6); - background: var(--sl-color-accent-low, rgba(59, 130, 246, 0.08)); + background: var(--sl-color-gray-6); +} + +.import-actions { + display: inline-flex; + align-items: center; + gap: 0.5rem; } .import-file-label { display: inline-block; - margin-top: 8px; cursor: pointer; } -.import-file-label span { - display: inline-block; - padding: 6px 14px; - border-radius: 6px; - background: var(--sl-color-accent, #3b82f6); - color: var(--sl-color-black, #fff); - font-size: 0.875rem; +.import-file-label span, +.import-sample-btn { + display: inline-flex; + align-items: center; + padding: 0.375rem 0.75rem; + border: 1px solid var(--sl-color-gray-5); + border-radius: 0; + background: var(--sl-color-gray-6); + color: var(--sl-color-gray-2); + font-family: var(--sl-font); + font-size: var(--sl-text-xs); + font-weight: 500; + line-height: 1.4; + cursor: pointer; + transition: background-color 0.15s, color 0.15s, border-color 0.15s; +} + +.import-file-label:hover span, +.import-sample-btn:hover { + background: var(--sl-color-gray-5); + color: var(--sl-color-white); } .import-file-label input { @@ -42,70 +66,52 @@ pointer-events: none; } -.import-sample-block label { - display: block; - font-size: 0.875rem; - margin-bottom: 6px; -} - -.import-sample-block textarea { - width: 100%; - box-sizing: border-box; - font-family: ui-monospace, monospace; - font-size: 0.8rem; - padding: 8px; - border-radius: 6px; - border: 1px solid var(--sl-color-gray-4, #ccc); - resize: vertical; -} - -.import-sample-actions { - margin-top: 8px; -} - -.import-sample-actions button, -.import-apply-btn { - padding: 6px 14px; - border-radius: 6px; - border: 1px solid var(--sl-color-gray-4, #ccc); - background: var(--sl-color-bg, #fff); - cursor: pointer; - font-size: 0.875rem; -} - -.import-apply-btn { - margin-top: 8px; - border-color: var(--sl-color-accent, #3b82f6); - color: var(--sl-color-accent, #3b82f6); -} - .import-msg { - padding: 10px 12px; - border-radius: 6px; - font-size: 0.875rem; + margin: 0; + padding: 0.75rem 1rem; + border-radius: 0; + font-size: var(--sl-text-xs); } .import-msg--error { + border-bottom: 1px solid var(--sl-color-red, #ef4444); background: var(--sl-color-red-low, rgba(239, 68, 68, 0.12)); - border: 1px solid var(--sl-color-red, #ef4444); - color: var(--sl-color-red-high, #b91c1c); + color: var(--sl-color-red, #ef4444); } -.import-preview { - padding: 12px; - border-radius: 8px; - border: 1px solid var(--sl-color-gray-4, #ccc); - background: var(--sl-color-bg-inline-code, rgba(0, 0, 0, 0.03)); +.import-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 2.5rem 1rem; + min-height: 200px; + color: var(--sl-color-gray-2); + text-align: center; } -.import-preview-title { - margin: 0 0 8px; - font-size: 0.875rem; +.import-empty-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + margin-bottom: 0.25rem; + border: 1px solid var(--sl-color-gray-5); + color: var(--sl-color-gray-3); +} + +.import-empty-title { + margin: 0; + color: var(--sl-color-white); + font-size: var(--sl-text-sm); font-weight: 600; } -.import-header-list { +.import-empty-text { margin: 0; - padding-left: 1.25rem; - font-size: 0.875rem; + max-width: 38ch; + font-size: var(--sl-text-xs); + line-height: 1.5; } diff --git a/docs/content/recipes/import-export/import-csv-excel/react/example1.jsx b/docs/content/recipes/import-export/import-csv-excel/react/example1.jsx index 830e6d2dc4d..d4e7a23157d 100644 --- a/docs/content/recipes/import-export/import-csv-excel/react/example1.jsx +++ b/docs/content/recipes/import-export/import-csv-excel/react/example1.jsx @@ -243,13 +243,9 @@ async function parseFile(file) { throw new Error('Unsupported file type. Use a .csv or .xlsx file.'); } -function columnsFromHeaders(headers, pendingRows) { - if (!pendingRows) { - return headers.map((data) => ({ data, type: 'text' })); - } - +function columnsFromHeaders(headers, rows) { return headers.map((data) => { - const values = pendingRows + const values = rows .map((row) => row[data]) .filter((v) => v !== null); @@ -269,33 +265,24 @@ function columnsFromHeaders(headers, pendingRows) { const SAMPLE_CSV = `Product,Category,In stock,Price Widget A,Hardware,true,19.99 Widget B,Hardware,false,24.5 -Service Pack,Services,true,0 -`; +Service Pack,Services,true,0`; /* end:skip-in-preview */ const ExampleComponent = () => { - const [pending, setPendingState] = useState(null); const [errorMessage, setErrorMessage] = useState(''); - const [showPreview, setShowPreview] = useState(false); - const [sampleCsv, setSampleCsv] = useState(SAMPLE_CSV); const [dropzoneActive, setDropzoneActive] = useState(false); const [gridData, setGridData] = useState([]); const [gridColHeaders, setGridColHeaders] = useState([]); const [gridColumns, setGridColumns] = useState([]); - const clearPendingPreview = () => { - setPendingState(null); - setShowPreview(false); - }; - - const handleParsed = (payload) => { + const loadIntoGrid = ({ headers, rows }) => { setErrorMessage(''); - setPendingState(payload); - setShowPreview(true); + setGridColHeaders(headers); + setGridColumns(columnsFromHeaders(headers, rows)); + setGridData(rows); }; const handleError = (e) => { - clearPendingPreview(); setErrorMessage(e instanceof Error ? e.message : String(e)); }; @@ -315,7 +302,7 @@ const ExampleComponent = () => { try { const payload = await parseFile(file); - handleParsed(payload); + loadIntoGrid(payload); } catch (e) { handleError(e); } @@ -345,37 +332,19 @@ const ExampleComponent = () => { handleFile(f); }; - const handleParseSample = async () => { + const handleLoadSample = async () => { setErrorMessage(''); try { const PapaRef = await ensurePapa(); - const payload = parseCsvText(sampleCsv, PapaRef); + const payload = parseCsvText(SAMPLE_CSV, PapaRef); - handleParsed(payload); + loadIntoGrid(payload); } catch (e) { handleError(e); } }; - const handleApply = () => { - setErrorMessage(''); - - if (!pending) { - setErrorMessage('Nothing to load. Import a file first.'); - - return; - } - - const { headers, rows } = pending; - - setGridColHeaders(headers); - setGridColumns(columnsFromHeaders(headers, rows)); - setGridData(rows); - setPendingState(null); - setShowPreview(false); - }; - return (
{ onDrop={handleDrop} >

- Drop a .csv or .xlsx file here, or use the file picker. + Drop a .csv or .xlsx file here, or pick a source.

- -
- -
- -