Skip to content
Open
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
22 changes: 22 additions & 0 deletions packages/module/src/DataViewFilters/DataViewFilters.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { render, fireEvent } from '@testing-library/react';
import DataViewFilters from './DataViewFilters';
import DataViewToolbar from '../DataViewToolbar';
import DataViewTextFilter from '../DataViewTextFilter';
import { DataViewCheckboxFilter } from '../DataViewCheckboxFilter';

describe('DataViewFilters component', () => {
const mockOnChange = jest.fn();
Expand Down Expand Up @@ -37,4 +38,25 @@ describe('DataViewFilters component', () => {
fireEvent.input(input, { target: { value: 'abc' } });
expect(mockOnChange).toHaveBeenCalledWith('one', { one: 'abc' });
});

it('should not crash when a checkbox filter option label is a React element', () => {
// Regression test for #12536: using a React element (e.g. an icon) as a
// DataViewCheckboxFilter option label made childrenHash's JSON.stringify throw on the
// element's circular references. The tree is created inside a wrapper component so the
// label element has an owner (as in real usage), which is what triggers the cycle.
const FilterWithElementLabel = () => (
<DataViewToolbar
filters={
<DataViewFilters onChange={mockOnChange} values={{}}>
<DataViewCheckboxFilter
filterId="status"
title="Status"
options={[ { label: <span>Active</span>, value: 'active' } ]}
/>
</DataViewFilters>
}
/>
);
expect(() => render(<FilterWithElementLabel />)).not.toThrow();
});
});
18 changes: 17 additions & 1 deletion packages/module/src/DataViewFilters/DataViewFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ export interface DataViewFiltersProps<T extends object> extends Omit<ToolbarTogg
ouiaId?: string;
};

// A React element (for example a component passed as a filter option label) holds internal,
// circular references via its owner fiber, so JSON.stringify throws "cyclic object value" when it
// encounters one. Hashing children for change detection does not need an element's internals, so
// replace any nested React element with a stable, serializable descriptor instead (#12536).
const replaceReactElements = (_key: string, value: unknown): unknown => {
if (isValidElement(value)) {
const { type } = value;
const typeName =
typeof type === 'string'
? type
: (type as { displayName?: string; name?: string }).displayName ?? (type as { displayName?: string; name?: string }).name ?? 'element';
return { type: typeName, key: value.key };
}
return value;
};

export const DataViewFilters = <T extends object>({
children,
Expand All @@ -52,7 +67,8 @@ export const DataViewFilters = <T extends object>({
const childrenHash = useMemo(() => JSON.stringify(
Children.map(children, (child) =>
isValidElement(child) ? { type: child.type, key: child.key, props: child.props } : child
)
),
replaceReactElements
), [ children ]);

const filterItems: DataViewFilterIdentifiers[] = useMemo(() => Children.toArray(children)
Expand Down