Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
12 changes: 9 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"private": true,
"type": "module",
"scripts": {
"check": "pnpm lint",
"test": "vitest run",
"test:watch": "vitest",
"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",
Expand All @@ -13,13 +15,17 @@
},
"packageManager": "pnpm@10.23.0",
"devDependencies": {
"@types/node": "^20.0.0",
"@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",
"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]
}
}
}
);
74 changes: 52 additions & 22 deletions packages/grid-shared-react/src/hooks/useGrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,52 +39,82 @@ 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);

// 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;
}

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

isInitializingRef.current = true;
// Track if this effect has been cleaned up to handle race conditions
let isCleanedUp = false;

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 (isCleanedUp) {
// Component unmounted while we were initializing - destroy immediately
grid.destroy();
return;
}

currGridRef.current = grid;
callback?.(grid);
} finally {
isInitializingRef.current = false;

// 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 (!isCleanedUp) {
throw error;
}
}
};

initGrid();

return () => {
currGridRef.current?.destroy();
isInitializingRef.current = false;
isCleanedUp = true;
initStartedRef.current = false;
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;
}
139 changes: 139 additions & 0 deletions packages/grid-shared-react/src/test/createGridTests.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { render, waitFor, fireEvent } from '@testing-library/react';
import { useRef, useState, ComponentType } from 'react';
import { describe, it, expect, vi } from 'vitest';
import { GridProps, GridRefHandle } from '../components/BaseGrid';
import { GridInstance } from '../hooks/useGrid';

interface TestOptions {
dataTable?: {
columns?: Record<string, any>;
};
}

/**
* Creates a standard test suite for a Grid component.
* Use this to avoid duplicating tests between grid-lite-react and grid-pro-react.
*/
export function createGridTests<TOptions extends TestOptions>(
name: string,
GridComponent: ComponentType<GridProps<TOptions>>,
testOptions: TOptions,
updatedOptions: TOptions
) {

describe(name, () => {
it('renders a container div and initializes grid', async () => {
let gridInstance: GridInstance<TOptions> | null = null;

const onGridReady = (grid: GridInstance<TOptions>) => {
gridInstance = grid;
};

const { container } = render(
<GridComponent options={testOptions} callback={onGridReady} />
);

expect(container.firstChild).toBeInstanceOf(HTMLDivElement);

await waitFor(() => {
expect(gridInstance).not.toBeNull();
});
});

it('provides grid instance via ref', async () => {
let gridRef: React.RefObject<GridRefHandle<TOptions> | null>;
let initialized = false;

function TestComponent() {
gridRef = useRef<GridRefHandle<TOptions>>(null);
return (
<GridComponent
options={testOptions}
gridRef={gridRef}
callback={() => { initialized = true; }}
/>
);
}

render(<TestComponent />);

await waitFor(() => {
expect(initialized).toBe(true);
expect(gridRef.current?.grid).toBeDefined();
});
});

it('calls callback when grid is initialized', async () => {
const callback = vi.fn();
render(<GridComponent options={testOptions} callback={callback} />);

await waitFor(() => {
expect(callback).toHaveBeenCalled();
});
});

it('updates grid when options change', async () => {
let gridInstance: GridInstance<TOptions> | null = null;

function TestComponent() {
const [opts, setOpts] = useState(testOptions);

const onGridReady = (grid: GridInstance<TOptions>) => {
gridInstance = grid;
};

return (
<>
<GridComponent options={opts} callback={onGridReady} />
<button
data-testid="update-options"
onClick={() => setOpts(updatedOptions)}
>
Update
</button>
</>
);
}

const { getByTestId, container } = render(<TestComponent />);

// Wait for initial grid creation
await waitFor(() => {
expect(gridInstance).not.toBeNull();
});

// Trigger options change
fireEvent.click(getByTestId('update-options'));

// Wait for the grid to update with new data
await waitFor(() => {
const cells = container.querySelectorAll('td[data-value]');
const values = Array.from(cells).map(c => c.getAttribute('data-value'));
expect(values).toContain('Charlie');
});
});

it('calls destroy() on unmount', async () => {
let destroySpy: ReturnType<typeof vi.fn> | null = null;

const onGridReady = (grid: GridInstance<TOptions>) => {
destroySpy = vi.spyOn(grid, 'destroy');
};

const { unmount } = render(
<GridComponent options={testOptions} callback={onGridReady} />
);

// Wait for grid to initialize
await waitFor(() => {
expect(destroySpy).not.toBeNull();
});

// Unmount and verify destroy was called
unmount();

expect(destroySpy).toHaveBeenCalledTimes(1);
});

});
}
Loading