diff --git a/ui/package.json b/ui/package.json index e0bb34221a8..afbf32896da 100644 --- a/ui/package.json +++ b/ui/package.json @@ -65,6 +65,7 @@ "@typescript-eslint/eslint-plugin": "5.62.0", "@typescript-eslint/parser": "5.62.0", "arg": "5.0.2", + "babel-plugin-react-compiler": "1.0.0", "chromatic": "11.29.0", "css-loader": "6.11.0", "eslint": "8.45.0", @@ -78,7 +79,7 @@ "eslint-plugin-prettier": "5.5.5", "eslint-plugin-promise": "6.6.0", "eslint-plugin-react": "7.37.5", - "eslint-plugin-react-hooks": "4.6.2", + "eslint-plugin-react-hooks": "7.0.1", "eslint-plugin-standard": "5.0.0", "eslint-plugin-storybook": "0.12.0", "eslint-plugin-typescript-enum": "2.1.0", @@ -98,6 +99,7 @@ "postcss": "8.5.8", "prettier": "3.6.2", "prettier-plugin-tailwindcss": "^0.4.0", + "react-compiler-runtime": "1.0.0", "react-is": "18.3.1", "react-test-renderer": "18.3.1", "replace-in-files": "3.0.0", diff --git a/ui/packages/app/web/package.json b/ui/packages/app/web/package.json index 3c0cf27713e..d3ca0e64b67 100644 --- a/ui/packages/app/web/package.json +++ b/ui/packages/app/web/package.json @@ -63,12 +63,14 @@ }, "devDependencies": { "@types/lodash.throttle": "4.1.9", - "@vitejs/plugin-react-swc": "3.11.0", + "@vitejs/plugin-react": "4.7.0", + "babel-plugin-react-compiler": "1.0.0", "css-loader": "6.11.0", "eslint-config-prettier": "8.10.2", "eslint-plugin-import": "2.32.0", "jest": "29.7.0", "jest-runtime": "29.7.0", + "react-compiler-runtime": "1.0.0", "tslint": "6.1.3", "tslint-config-prettier": "1.18.0", "tslint-plugin-prettier": "2.3.0", diff --git a/ui/packages/app/web/src/components/ui/Navbar.tsx b/ui/packages/app/web/src/components/ui/Navbar.tsx index d16f3935de0..0f8d0470fc3 100644 --- a/ui/packages/app/web/src/components/ui/Navbar.tsx +++ b/ui/packages/app/web/src/components/ui/Navbar.tsx @@ -61,7 +61,7 @@ const Navbar = () => { const compareA = queryParams.get('compare_a'); const compareB = queryParams.get('compare_b'); - const queryParamsURL = parseParams(window.location.search); + const queryParamsURL = parseParams(location.search); /* eslint-disable @typescript-eslint/naming-convention */ const { diff --git a/ui/packages/app/web/src/pages/index.tsx b/ui/packages/app/web/src/pages/index.tsx index 063d3c38d3d..a1af24e394a 100644 --- a/ui/packages/app/web/src/pages/index.tsx +++ b/ui/packages/app/web/src/pages/index.tsx @@ -14,7 +14,7 @@ import {useCallback} from 'react'; import {GrpcWebFetchTransport} from '@protobuf-ts/grpcweb-transport'; -import {useNavigate} from 'react-router-dom'; +import {useLocation, useNavigate} from 'react-router-dom'; import {QueryServiceClient} from '@parca/client'; import {ParcaContextProvider, Spinner, URLStateProvider} from '@parca/components'; @@ -31,7 +31,12 @@ const queryClient = new QueryServiceClient( ); const Profiles = () => { + 'use no memo'; const navigate = useNavigate(); + // useLocation() subscribes to react-router location changes so this component + // re-renders on navigate(). 'use no memo' ensures the re-render propagates to + // URLStateProvider, whose no-deps effect syncs state from window.location.search. + useLocation(); const isDarkMode = useAppSelector(selectDarkMode); const navigateTo = useCallback( diff --git a/ui/packages/app/web/vite.config.ts b/ui/packages/app/web/vite.config.ts index 72a68fca6c4..6b1411786a0 100644 --- a/ui/packages/app/web/vite.config.ts +++ b/ui/packages/app/web/vite.config.ts @@ -11,14 +11,29 @@ // See the License for the specific language governing permissions and // limitations under the License. -import react from '@vitejs/plugin-react-swc'; +import react from '@vitejs/plugin-react'; import {defineConfig} from 'vite'; import svgr from 'vite-plugin-svgr'; // https://vitejs.dev/config/ export default defineConfig({ - // @ts-expect-error - plugins: [react(), svgr()], + // cast needed: dual @types/node versions create incompatible vite Plugin types + plugins: [ + react({ + babel: { + plugins: [ + [ + 'babel-plugin-react-compiler', + { + target: '18', + compilationMode: 'infer', + }, + ], + ], + }, + }), + svgr(), + ] as any, base: './', server: { port: 3000, diff --git a/ui/packages/shared/components/src/DateTimeRangePicker/AbsoluteDatePicker/index.test.tsx b/ui/packages/shared/components/src/DateTimeRangePicker/AbsoluteDatePicker/index.test.tsx new file mode 100644 index 00000000000..cad87483ac3 --- /dev/null +++ b/ui/packages/shared/components/src/DateTimeRangePicker/AbsoluteDatePicker/index.test.tsx @@ -0,0 +1,45 @@ +// Copyright 2022 The Parca 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. + +/* eslint-disable jest-dom/prefer-to-have-value */ + +import {render} from '@testing-library/react'; +import {describe, expect, it, vi} from 'vitest'; + +import {AbsoluteDate, DateTimeRange} from '../utils'; +import AbsoluteDatePicker from './index'; + +describe('AbsoluteDatePicker', () => { + it('resyncs when an existing DateTimeRange instance is mutated', () => { + const range = new DateTimeRange( + new AbsoluteDate(new Date('2023-12-01T10:00:00Z')), + new AbsoluteDate(new Date('2023-12-01T15:30:00Z')) + ); + + const {rerender, getAllByRole} = render( + + ); + + const [startInput, endInput] = getAllByRole('textbox'); + expect((startInput as HTMLInputElement).value).toBe('2023-12-01 10:00:00'); + expect((endInput as HTMLInputElement).value).toBe('2023-12-01 15:30:00'); + + range.from = new AbsoluteDate(new Date('2023-12-02T08:15:00Z')); + range.to = new AbsoluteDate(new Date('2023-12-02T09:45:00Z')); + + rerender(); + + expect((startInput as HTMLInputElement).value).toBe('2023-12-02 08:15:00'); + expect((endInput as HTMLInputElement).value).toBe('2023-12-02 09:45:00'); + }); +}); diff --git a/ui/packages/shared/components/src/DateTimeRangePicker/AbsoluteDatePicker/index.tsx b/ui/packages/shared/components/src/DateTimeRangePicker/AbsoluteDatePicker/index.tsx index 6ad9206d586..5bdc43f3664 100644 --- a/ui/packages/shared/components/src/DateTimeRangePicker/AbsoluteDatePicker/index.tsx +++ b/ui/packages/shared/components/src/DateTimeRangePicker/AbsoluteDatePicker/index.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {useEffect, useMemo, useState} from 'react'; +import {useState} from 'react'; import {DateTimePicker} from '../../DateTimePicker'; import {AbsoluteDate, DateTimeRange, RelativeDate, getHistoricalDate} from '../utils'; @@ -22,52 +22,24 @@ interface AbsoluteDatePickerProps { } const AbsoluteDatePicker = ({range, onChange}: AbsoluteDatePickerProps): JSX.Element => { - const dateFromInRelative = useMemo(() => range.from as RelativeDate, [range.from]); - const dateToInRelative = useMemo(() => range.to as RelativeDate, [range.to]); - - const [from, setFrom] = useState( - range.from.isRelative() - ? new AbsoluteDate( - getHistoricalDate({ - unit: dateFromInRelative.unit, - value: dateFromInRelative.value, - }) - ) - : (range.from as AbsoluteDate) - ); - const [to, setTo] = useState( - range.to.isRelative() + const toAbsolute = (d: RelativeDate | AbsoluteDate): AbsoluteDate => + d.isRelative() ? new AbsoluteDate( - getHistoricalDate({ - unit: dateToInRelative.unit, - value: dateToInRelative.value, - }) + getHistoricalDate({unit: (d as RelativeDate).unit, value: (d as RelativeDate).value}) ) - : (range.to as AbsoluteDate) - ); + : (d as AbsoluteDate); + + const [from, setFrom] = useState(() => toAbsolute(range.from)); + const [to, setTo] = useState(() => toAbsolute(range.to)); + const [prevRangeFrom, setPrevRangeFrom] = useState(range.from); + const [prevRangeTo, setPrevRangeTo] = useState(range.to); - useEffect(() => { - setFrom( - range.from.isRelative() - ? new AbsoluteDate( - getHistoricalDate({ - unit: dateFromInRelative.unit, - value: dateFromInRelative.value, - }) - ) - : (range.from as AbsoluteDate) - ); - setTo( - range.to.isRelative() - ? new AbsoluteDate( - getHistoricalDate({ - unit: dateToInRelative.unit, - value: dateToInRelative.value, - }) - ) - : (range.to as AbsoluteDate) - ); - }, [dateFromInRelative, dateToInRelative, range.from, range.to]); + if (prevRangeFrom !== range.from || prevRangeTo !== range.to) { + setPrevRangeFrom(range.from); + setPrevRangeTo(range.to); + setFrom(toAbsolute(range.from)); + setTo(toAbsolute(range.to)); + } return (
diff --git a/ui/packages/shared/components/src/ResponsiveSvg/index.tsx b/ui/packages/shared/components/src/ResponsiveSvg/index.tsx index 6daec421439..f491b231822 100644 --- a/ui/packages/shared/components/src/ResponsiveSvg/index.tsx +++ b/ui/packages/shared/components/src/ResponsiveSvg/index.tsx @@ -11,6 +11,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +/* eslint-disable react-hooks/set-state-in-effect */ + import {Children, useEffect, useState} from 'react'; import {useContainerDimensions} from '@parca/hooks'; @@ -33,6 +35,7 @@ const addPropsToChildren = (children: JSX.Element, props: {[x: string]: any}): J }; const ResponsiveSvg = (props: Props): JSX.Element => { + 'use no memo'; const {children} = props; const {ref, dimensions} = useContainerDimensions(); const {width} = dimensions ?? {width: 0}; diff --git a/ui/packages/shared/components/src/Table/index.tsx b/ui/packages/shared/components/src/Table/index.tsx index f11d4aea0df..4c708675bfc 100644 --- a/ui/packages/shared/components/src/Table/index.tsx +++ b/ui/packages/shared/components/src/Table/index.tsx @@ -139,6 +139,7 @@ const Table = ({ scrollToIndex, estimatedRowHeight = 26, }: Props): JSX.Element => { + 'use no memo'; const [sorting, setSorting] = useState(initialSorting); const tableContainerRef = useRef(null); const scrollingRef = useRef(); diff --git a/ui/packages/shared/profile/src/GraphTooltipArrow/index.tsx b/ui/packages/shared/profile/src/GraphTooltipArrow/index.tsx index 969d466fc6a..1fa035cba37 100644 --- a/ui/packages/shared/profile/src/GraphTooltipArrow/index.tsx +++ b/ui/packages/shared/profile/src/GraphTooltipArrow/index.tsx @@ -11,6 +11,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +/* eslint-disable react-hooks/refs */ + import React, {useEffect, useState} from 'react'; import {flip, offset, shift, useFloating, type VirtualElement} from '@floating-ui/react'; @@ -39,6 +41,7 @@ function createPositionedVirtualElement(contextElement: Element, x = 0, y = 0): } const GraphTooltip = ({children, contextElement}: GraphTooltipProps): React.JSX.Element => { + 'use no memo'; const [isPositioned, setIsPositioned] = useState(false); const {refs, floatingStyles, update} = useFloating({ diff --git a/ui/packages/shared/profile/src/MatchersInput/SuggestionsList.test.tsx b/ui/packages/shared/profile/src/MatchersInput/SuggestionsList.test.tsx new file mode 100644 index 00000000000..50a08fcf8c7 --- /dev/null +++ b/ui/packages/shared/profile/src/MatchersInput/SuggestionsList.test.tsx @@ -0,0 +1,70 @@ +// Copyright 2022 The Parca 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 {useRef} from 'react'; + +import {act, fireEvent, render} from '@testing-library/react'; +import {beforeAll, describe, expect, it, vi} from 'vitest'; + +import SuggestionsList, {Suggestion, Suggestions} from './SuggestionsList'; + +vi.mock('@parca/components', () => ({ + RefreshButton: ({title}: {title: string}) => , + useParcaContext: () => ({ + loader:
loading
, + }), +})); + +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); +}); + +const TestHarness = ({inputKey = 'initial'}: {inputKey?: string}): JSX.Element => { + const inputRef = useRef(null); + const suggestions = new Suggestions(); + suggestions.labelNames.push(new Suggestion('labelName', 'na', 'namespace')); + + return ( +
+