Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Tests

on:
pull_request:

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Install pnpm
uses: pnpm/action-setup@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'

- name: Install dependencies
run: pnpm install

- name: Build packages
run: pnpm build

- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium

- name: Run tests
run: pnpm test:all
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,6 @@ vite.config.ts.timestamp-*
.vite/
.turbo/
*.DS_Store

# Test artifacts
__screenshots__/
14 changes: 11 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,32 @@
"private": true,
"type": "module",
"scripts": {
"check": "pnpm lint",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:all": "pnpm test && pnpm test:e2e",
"check": "pnpm lint && pnpm test",
"build": "pnpm -r --filter './packages/*' run build",
"lint": "pnpm -r --filter './packages/*' run lint",
"clean": "pnpm -r --filter './{packages,examples}/*' run clean && rimraf node_modules",
"release:preflight": "pnpm check && pnpm build",
"release:prepare": "node scripts/release.js",
"release": "pnpm release:preflight && pnpm publish -r --access public"
},
"packageManager": "pnpm@10.23.0",
"packageManager": "pnpm@10.27.0",
"devDependencies": {
"@eslint/js": "^9.39.1",
"@stylistic/eslint-plugin": "^5.6.1",
"@testing-library/react": "^16.3.1",
"@types/node": "^20.0.0",
"@vitest/browser": "^4.0.16",
"@vitest/browser-playwright": "^4.0.16",
"eslint": "^9.39.1",
"globals": "^17.0.0",
"playwright": "^1.57.0",
"rimraf": "^6.1.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.0",
"vitest": "^4.0.14"
"vitest": "^4.0.16"
}
}
23 changes: 23 additions & 0 deletions packages/grid-lite-react/src/__tests__/Grid.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createGridTests } from '@highcharts/grid-shared-react/src/test/createGridTests';
import { GridLite, GridOptions } from '../index';

createGridTests<GridOptions>(
'GridLite',
GridLite,
{
dataTable: {
columns: {
name: ['Alice', 'Bob'],
age: [30, 25]
}
}
},
{
dataTable: {
columns: {
name: ['Charlie', 'Diana'],
age: [40, 35]
}
}
}
);
23 changes: 23 additions & 0 deletions packages/grid-pro-react/src/__tests__/Grid.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createGridTests } from '@highcharts/grid-shared-react/src/test/createGridTests';
import { GridPro, GridOptions } from '../index';

createGridTests<GridOptions>(
'GridPro',
GridPro,
{
dataTable: {
columns: {
name: ['Alice', 'Bob'],
age: [30, 25]
}
}
},
{
dataTable: {
columns: {
name: ['Charlie', 'Diana'],
age: [40, 35]
}
}
}
);
71 changes: 71 additions & 0 deletions packages/grid-shared-react/src/hooks/useGrid.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { render, waitFor } from '@testing-library/react';
import { StrictMode } from 'react';
import { describe, it, expect } from 'vitest';
import { BaseGrid } from '../components/BaseGrid';
import type { GridType, GridInstance } from './useGrid';

type TestOptions = { label?: string };

interface DeferredInit {
id: number;
resolve: () => Promise<void>;
}

function createDeferredGrid(initQueue: DeferredInit[]): GridType<TestOptions> {
let nextId = 0;

return {
grid(container, _options, async) {
const id = ++nextId;
const grid: GridInstance<TestOptions> = {
destroy: () => {
container.innerHTML = '';
},
update: () => {}
};

if (!async) {
container.innerHTML = `<div data-grid-id="${id}">grid</div>`;
return grid;
}

return new Promise((resolve) => {
const resolveInit = async () => {
container.innerHTML = `<div data-grid-id="${id}">grid</div>`;
resolve(grid);
await Promise.resolve();
};

initQueue.push({
id,
resolve: resolveInit
});
});
}
};
}

describe('useGrid', () => {
it('keeps the active grid when StrictMode double-inits', async () => {
const initQueue: DeferredInit[] = [];
const Grid = createDeferredGrid(initQueue);

const { container } = render(
<StrictMode>
<BaseGrid options={{}} Grid={Grid} />
</StrictMode>
);

await waitFor(() => {
expect(initQueue).toHaveLength(1);
});

const [firstInit] = initQueue;

await firstInit.resolve();

await waitFor(() => {
expect(container.querySelector('[data-grid-id="1"]')).not.toBeNull();
});
});
});
80 changes: 59 additions & 21 deletions packages/grid-shared-react/src/hooks/useGrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,52 +39,90 @@ export function useGrid<TOptions>({
callback
}: UseGridOptions<TOptions>) {
const currGridRef = useRef<GridInstance<TOptions> | null>(null);
const isInitializingRef = useRef(false);
const callbackRef = useRef(callback);
const pendingOptionsRef = useRef<TOptions | null>(null);
const initStartedRef = useRef(false);

// StrictMode runs effects twice: mount → cleanup → mount.
// This ref tracks if cleanup ran while async init was in-flight.
// The second mount resets it to false, allowing the init to complete
// and commit. Without this, the grid would be destroyed immediately
// after creation due to the cleanup setting this flag.
const destroyOnInitRef = useRef(false);

// Keep callback ref in sync
callbackRef.current = callback;

// Effect for initialization - only depends on container and Grid
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}

// Update grid if it already exists
if (currGridRef.current) {
currGridRef.current.update(options, true);
return;
}
// StrictMode cleanup runs before re-mount; allow init to complete if re-mounted.
destroyOnInitRef.current = false;

// Prevent double initialization
if (isInitializingRef.current) {
if (initStartedRef.current || currGridRef.current) {
return;
}

isInitializingRef.current = true;
initStartedRef.current = true;

const initGrid = async () => {
try {
const grid = await Grid.grid(container, options, true);
// Use pending options if available (from rapid updates during init)
const initOptions = pendingOptionsRef.current ?? options;
pendingOptionsRef.current = null;

const grid = await Grid.grid(container, initOptions, true);

if (destroyOnInitRef.current) {
// Component unmounted while we were initializing - destroy immediately
grid.destroy();
return;
}

currGridRef.current = grid;
callback?.(grid);

// Apply any pending options that came in while we were initializing
if (pendingOptionsRef.current) {
grid.update(pendingOptionsRef.current, true);
pendingOptionsRef.current = null;
}

callbackRef.current?.(grid);
} catch (error) {
// Re-throw unless we've been cleaned up (component unmounted)
if (!destroyOnInitRef.current) {
throw error;
}
} finally {
isInitializingRef.current = false;
initStartedRef.current = false;
}
};

initGrid();

return () => {
currGridRef.current?.destroy();
isInitializingRef.current = false;
destroyOnInitRef.current = true;
if (currGridRef.current) {
currGridRef.current.destroy();
currGridRef.current = null;
}
};
}, [options, containerRef, Grid]);
}, [containerRef, Grid]);

// Cleanup on unmount
// Effect for options updates - separate from init
useEffect(() => {
return () => {
currGridRef.current?.destroy();
currGridRef.current = null;
};
}, []);
if (currGridRef.current) {
// Grid exists, update it directly
currGridRef.current.update(options, true);
} else {
// Grid still initializing, queue the update
pendingOptionsRef.current = options;
}
}, [options]);

return currGridRef;
}
Loading