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
16 changes: 13 additions & 3 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"browser": true
},
"rules": {
"typescript/no-explicit-any": "error",
"typescript/explicit-function-return-type": "error",
"typescript/explicit-module-boundary-types": "error",
"typescript/array-type": ["error", { "default": "array-simple" }],
Expand Down Expand Up @@ -44,9 +45,18 @@
"react/jsx-no-useless-fragment": "warn",
"react/no-unescaped-entities": "error",
"react/exhaustive-deps": "error",
"react/react-compiler": "warn",
"react-doctor/no-fetch-in-effect": "warn",
"react-doctor/no-derived-state": "warn",
"react/unsupported-syntax": "error",
"react/no-deriving-state-in-effects": "error",
"react/invariant": "error",
"react/rule-suppression": "error",
"react/syntax": "error",
"react/todo": "error",
"react/capitalized-calls": "error",
"react/exhaustive-effect-dependencies": "error",
"react/hooks": "error",
"react/memo-dependencies": "error",
"react-doctor/no-fetch-in-effect": "error",
"react-doctor/no-derived-state": "error",
"react/jsx-curly-brace-presence": [
"error",
{ "props": "never", "children": "never", "propElementValues": "always" }
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,10 @@ npm run lint
npm run lint:fix
```

The Oxlint configuration includes React Doctor's lint-native rules. Run `npm run doctor` for the full React Doctor
project scan; pull requests and pushes to `main` also run that scan in GitHub Actions.
The Oxlint configuration treats React Compiler rules as errors, including the recommended correctness rules and the
compiler checks in the suspicious, performance, and restriction categories. React Doctor's `no-fetch-in-effect` and
`no-derived-state` rules are also errors. Run `npm run doctor` for the full React Doctor project scan; pull requests and
pushes to `main` also run that scan in GitHub Actions.

#### Development mode

Expand Down
2 changes: 1 addition & 1 deletion STYLEGUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ touch without expanding the task into unrelated cleanup.
for dynamic values passed across memoized boundaries. Do not add memoization blindly or omit dependencies.
- Memoize React context values when they contain objects or functions whose identity would otherwise change each render.
- Avoid array-index keys when a stable domain identifier exists.
- Treat `react/react-compiler` and `react-perf` diagnostics as design feedback. Fix new warnings rather than disabling
- Treat React Compiler and `react-perf` diagnostics as design feedback. Fix new warnings rather than disabling
the rule or increasing the repository warning ceiling.

## Components, state, and accessibility
Expand Down
14 changes: 4 additions & 10 deletions client/src/context/FetchContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { render, screen, waitFor } from '@testing-library/react';
import { render, renderHook, screen, waitFor } from '@testing-library/react';

import type { FetchFn } from './FetchContext';
import { FetchProvider, useFetch } from './FetchContext';
Expand All @@ -38,15 +38,9 @@ function TestJsonConsumer({ url }: { url: string }): React.ReactElement {
describe('FetchContext', () => {
describe('useFetch without provider', () => {
it('returns the default fetch wrapper from @perses-dev/client', () => {
let hookResult: ReturnType<typeof useFetch> | undefined;
function Capture(): React.ReactNode {
hookResult = useFetch();
return null;
}
render(<Capture />);
expect(hookResult).toBeDefined();
expect(typeof hookResult!.fetch).toBe('function');
expect(typeof hookResult!.fetchJson).toBe('function');
const { result } = renderHook(() => useFetch());
expect(typeof result.current.fetch).toBe('function');
expect(typeof result.current.fetchJson).toBe('function');
});
});

Expand Down
3 changes: 1 addition & 2 deletions client/src/model/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ export interface ProjectMetadata extends Metadata {
export interface Resource {
kind: Kind;
metadata: Metadata | ProjectMetadata;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
spec?: any;
spec?: unknown;
}

export interface ProjectResource {
Expand Down
58 changes: 58 additions & 0 deletions components/src/EChart/EChart.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { act, render } from '@testing-library/react';
import { init } from 'echarts/core';

import { EChart } from './EChart';

const option = {};

it('resizes on container changes and cancels pending work when unmounted', () => {
vi.useFakeTimers();
const resize = vi.fn();
const dispose = vi.fn();
const observe = vi.fn();
const disconnect = vi.fn();
const callbacks: ResizeObserverCallback[] = [];
class TestResizeObserver {
observe = observe;
disconnect = disconnect;
unobserve = vi.fn();
constructor(callback: ResizeObserverCallback) {
callbacks.push(callback);
}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver);
vi.mocked(init).mockReturnValue({ setOption: vi.fn(), resize, dispose } as unknown as ReturnType<typeof init>);
try {
const { unmount } = render(<EChart option={option} />);
expect(observe).toHaveBeenCalledTimes(1);
act(() => vi.advanceTimersByTime(200));
expect(resize).toHaveBeenCalledTimes(1);
act(() => {
callbacks[0]?.([], {} as ResizeObserver);
vi.advanceTimersByTime(200);
});
expect(resize).toHaveBeenCalledTimes(2);
act(() => callbacks[0]?.([], {} as ResizeObserver));
unmount();
act(() => vi.runAllTimers());
expect(disconnect).toHaveBeenCalledTimes(1);
expect(dispose).toHaveBeenCalledTimes(1);
expect(resize).toHaveBeenCalledTimes(2);
} finally {
vi.unstubAllGlobals();
vi.useRealTimers();
}
});
39 changes: 12 additions & 27 deletions components/src/EChart/EChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export const EChart = memo(function EChart<T>({
sx,
style,
onEvents,
_instance,
_instance: instanceRef,
syncGroup,
onChartInitialized,
}: EChartsProps<T>) {
Expand All @@ -185,15 +185,15 @@ export const EChart = memo(function EChart<T>({
if (chartElement.current === undefined) return;
chartElement.current.setOption(initialOption.current, true);
onChartInitialized?.(chartElement.current);
if (_instance !== undefined) {
_instance.current = chartElement.current;
if (instanceRef !== undefined) {
instanceRef.current = chartElement.current;
}
return (): void => {
if (chartElement.current === null) return;
chartElement.current.dispose();
chartElement.current = null;
};
}, [_instance, onChartInitialized, theme, renderer]);
}, [instanceRef, onChartInitialized, theme, renderer]);

// When syncGroup is explicitly set, charts within same group share interactions such as crosshair
useEffect(() => {
Expand All @@ -210,16 +210,21 @@ export const EChart = memo(function EChart<T>({
prevOption.current = option;
}, [option]);

// Resize chart, cleanup listener on unmount
// Observe actual container size, including layout and style changes.
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const updateSize = debounce(() => {
if (!chartElement.current) return;
chartElement.current.resize();
chartElement.current?.resize();
}, 200);
const observer = new ResizeObserver(updateSize);
observer.observe(container);
window.addEventListener('resize', updateSize);
updateSize();
return (): void => {
observer.disconnect();
window.removeEventListener('resize', updateSize);
updateSize.cancel();
};
}, []);

Expand All @@ -237,26 +242,6 @@ export const EChart = memo(function EChart<T>({
};
}, [onEvents]);

// TODO: re-evaluate how this is triggered. It's technically working right
// now because the sx prop is an object that gets re-created, but that also
// means it runs unnecessarily some of the time and theoretically might
// not run in some other cases. Maybe it should use a resize observer?
useEffect(() => {
// TODO: fix this debouncing. This likely isn't working as intended because
// the debounced function is re-created every time this useEffect is called.
const updateSize = debounce(
() => {
if (!chartElement.current) return;
chartElement.current.resize();
},
200,
{
leading: true,
},
);
updateSize();
}, [sx, style]);

return <Box ref={containerRef} sx={sx} style={style}></Box>;
});

Expand Down
2 changes: 1 addition & 1 deletion components/src/Legend/TableLegend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const COLUMNS: Array<TableColumnConfig<LegendItem>> = [
// Starting with `title` attr instead of a tooltip because it is easier to
// implement. We should try adding a tooltip in the future, but we'll need
// to be very careful about performance when doing so with large tables.
cell: ({ getValue }) => <span title={getValue()}>{getValue()}</span>,
cell: ({ getValue }) => <span title={getValue<string>()}>{getValue<string>()}</span>,
},
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import type { AutocompleteProps, UseAutocompleteProps } from '@mui/material';
import type { AutocompleteProps, AutocompleteRenderInputParams, UseAutocompleteProps } from '@mui/material';
import { Autocomplete, TextField, Typography, createFilterOptions } from '@mui/material';
import type { ReactElement, ReactNode } from 'react';

function renderDefaultInput(params: AutocompleteRenderInputParams): ReactElement {
return <TextField {...params} />;
}

/**
* Interface to extend from for `options` when using `SettingsAutocomplete`.
*/
Expand Down Expand Up @@ -67,7 +71,7 @@ export function SettingsAutocomplete<
DisableClearable extends boolean | undefined = false,
>({
options,
renderInput = (params): ReactElement => <TextField {...params} />,
renderInput = renderDefaultInput,
id,
'aria-labelledby': ariaLabelledby,
...otherProps
Expand Down
2 changes: 1 addition & 1 deletion components/src/Table/Table.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const COLUMNS: TableProps<MockTableData>['columns'] = [
header: 'Color',
headerDescription: 'Hex codes for colors',
width: 100,
cell: ({ getValue }) => <div data-testid="wrapper">{getValue()}</div>,
cell: ({ getValue }) => <div data-testid="wrapper">{getValue<string>()}</div>,
enableSorting: true,
},
];
Expand Down
4 changes: 4 additions & 0 deletions components/src/Table/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ export function Table<TableData>({
defaultColumnConfig,
...otherProps
}: TableProps<TableData>): ReactElement {
'use no memo'; // TanStack Table v8 uses a mutable instance that automatic memoization cannot track.

const theme = useTheme();

const hasSubRows = !!getSubRows;
Expand Down Expand Up @@ -205,6 +207,8 @@ export function Table<TableData>({
return initTableColumns;
}, [columns, defaultColumnConfig, hasItemActions, checkboxSelection, actionsColumn, checkboxColumn]);

// Oxlint still checks incompatible APIs inside functions opted out of compilation above.
// oxlint-disable-next-line react/incompatible-library
const table = useReactTable({
data,
columns: tableColumns,
Expand Down
2 changes: 2 additions & 0 deletions components/src/Table/TableToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export function TableToolbar<TableData>({
isAllExpanded,
onExpandAllChange,
}: TableToolbarProps<TableData>): ReactElement | null {
'use no memo'; // TanStack Table columns retain their identity when visibility changes.

const [colMenuAnchor, setColMenuAnchor] = useState<null | HTMLElement>(null);
const colMenuOpen = Boolean(colMenuAnchor);
const [searchResetKey, setSearchResetKey] = useState(0);
Expand Down
32 changes: 13 additions & 19 deletions components/src/Table/VirtualizedTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,14 @@ export function VirtualizedTable<TableData>({
rows,
columns,
headers,
columnSizing,
columnSizingInfo,
cellConfigs,
pagination,
onPaginationChange,
rowCount,
toolbarConfig,
}: VirtualizedTableProps<TableData>): ReactElement {
'use no memo'; // TanStack Table row and column getters read mutable state behind stable identities.

const virtuosoRef = useRef<TableVirtuosoHandle>(null);
// Use a ref for these values because they are only needed for keyboard
// focus interactions and setting them on state will lead to a significant
Expand Down Expand Up @@ -133,8 +133,7 @@ export function VirtualizedTable<TableData>({
},
TableHead,
TableFoot,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
TableRow: ({ item, ...props }): ReactElement | null => {
TableRow: ({ item: _item, ...props }): ReactElement | null => {
const index = props['data-index'];
const row = rows[index];
if (!row) {
Expand Down Expand Up @@ -183,23 +182,18 @@ export function VirtualizedTable<TableData>({
/**
* Instead of calling `column.getSize()` on every render for every header
* and especially every data cell (very expensive),
* we will calculate all column sizes at once at the root table level in a useMemo
* we calculate all column sizes once per render at the root table level
* and pass the column sizes down as CSS variables to the <table> element.
*/
const columnSizeVars = useMemo(() => {
const colSizes: { [key: string]: number } = {};
headers.forEach((headerGroup) => {
headerGroup.headers
.filter((header) => header.column.getCanResize())
.forEach((header) => {
colSizes[`--header-${header.id}-size`] = header.getSize();
colSizes[`--col-${header.column.id}-size`] = header.column.getSize();
});
});
return colSizes;
// We want to recalculate column sizes whenever column sizes or column resizing info changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [columnSizingInfo, columnSizing, headers]);
const columnSizeVars: { [key: string]: number } = {};
headers.forEach((headerGroup) => {
headerGroup.headers
.filter((header) => header.column.getCanResize())
.forEach((header) => {
columnSizeVars[`--header-${header.id}-size`] = header.getSize();
columnSizeVars[`--col-${header.column.id}-size`] = header.column.getSize();
});
});

return (
<Box style={{ width, height, ...columnSizeVars }}>
Expand Down
5 changes: 2 additions & 3 deletions components/src/Table/model/table-model.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ describe('getTableCellLayout', () => {
{ name: 'first column', opts: { isFirstColumn: true } },
{ name: 'center column', opts: {} },
{ name: 'last column', opts: { isLastColumn: true } },
// eslint-disable-next-line @typescript-eslint/no-unused-vars
])(`in $name`, ({ name, opts }) => {
])(`in $name`, ({ opts }) => {
expect(getTableCellLayout(mockMuiTheme, density, opts)).toMatchSnapshot();
});
});
Expand Down Expand Up @@ -267,7 +266,7 @@ describe('persesColumnToTanstackColumn', () => {
headerDescription: 'The total number of values.',
width: 120,
dataLink: undefined,
cell: (data) => <strong>{data.getValue()}</strong>,
cell: (data) => <strong>{data.getValue<number>()}</strong>,
cellDescription: (data) => `Desc for ${data.getValue()}`,
},
];
Expand Down
Loading
Loading