fix: add react-hooks eslint to catch pitfalls (#1661)

Had a couple of files with large fixes, I tested AutocompleteInput.tsx and SessionSubpanel.tsx quite a bit. Tested most others as well. I didn't act on some due to either a) correct usage or b) they are stable and I don't want to alter it. In both those cases, I added `eslint-disable-next-line` before each relevant line

References HDX-2955
This commit is contained in:
Aaron Knudtson 2026-02-04 17:04:01 -05:00 committed by GitHub
parent b6c34b13d9
commit e11b313807
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 232 additions and 225 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
fix: add react-hooks-eslint-plugin and fix issues across app

View file

@ -50,6 +50,8 @@ export default [
rules: {
...nextPlugin.configs.recommended.rules,
...nextPlugin.configs['core-web-vitals'].rules,
...reactHooksPlugin.configs.recommended.rules,
'react-hooks/set-state-in-effect': 'warn',
'@typescript-eslint/ban-ts-comment': 'warn',
'@typescript-eslint/no-empty-function': 'warn',
'@typescript-eslint/no-explicit-any': 'off',

View file

@ -137,6 +137,7 @@
"eslint-config-next": "^16.0.10",
"eslint-plugin-playwright": "^2.4.0",
"eslint-plugin-react-hook-form": "^0.3.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-storybook": "10.1.4",
"identity-obj-proxy": "^3.0.0",
"jest": "^30.2.0",

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import Fuse from 'fuse.js';
import { Popover, Textarea, UnstyledButton } from '@mantine/core';
@ -46,9 +46,19 @@ export default function AutocompleteInput({
}) {
const suggestionsLimit = 10;
const [isSearchInputFocused, setIsSearchInputFocused] = useState(false);
const [isSearchInputFocused, _setIsSearchInputFocused] = useState(false);
const [isInputDropdownOpen, setIsInputDropdownOpen] = useState(false);
const [showSearchHistory, setShowSearchHistory] = useState(false);
const setIsSearchInputFocused = useCallback(
(state: boolean) => {
_setIsSearchInputFocused(state);
setIsInputDropdownOpen(state);
},
[_setIsSearchInputFocused],
);
const [rightSectionWidth, setRightSectionWidth] = useState<number | 'auto'>(
'auto',
);
const [inputWidth, setInputWidth] = useState<number>(720);
const [selectedAutocompleteIndex, setSelectedAutocompleteIndex] =
useState(-1);
@ -67,25 +77,11 @@ export default function AutocompleteInput({
});
}, [queryHistory, queryHistoryType]);
useEffect(() => {
if (isSearchInputFocused) {
setIsInputDropdownOpen(true);
}
}, [isSearchInputFocused]);
useEffect(() => {
// only show search history when: 1.no input, 2.has search type, 3.has history list
if (
value != null &&
value.length === 0 &&
queryHistoryList.length > 0 &&
queryHistoryType
) {
setShowSearchHistory(true);
} else {
setShowSearchHistory(false);
}
}, [value, queryHistoryType, queryHistoryList]);
const showSearchHistory =
value != null &&
value.length === 0 &&
queryHistoryList.length > 0 &&
queryHistoryType;
const fuse = useMemo(
() =>
@ -128,6 +124,14 @@ export default function AutocompleteInput({
inputRef.current?.focus();
};
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (ref.current) {
setRightSectionWidth(ref.current.clientWidth);
}
if (inputRef.current) {
setInputWidth(inputRef.current.clientWidth);
}
}, [language, onLanguageChange, inputRef]);
return (
<Popover
@ -141,10 +145,7 @@ export default function AutocompleteInput({
closeOnEscape
styles={{
dropdown: {
maxWidth:
(inputRef.current?.clientWidth || 0) > 300
? inputRef.current?.clientWidth
: 720,
maxWidth: inputWidth > 300 ? inputWidth : 720,
width: '100%',
zIndex,
},
@ -242,7 +243,7 @@ export default function AutocompleteInput({
}
}
}}
rightSectionWidth={ref.current?.clientWidth ?? 'auto'}
rightSectionWidth={rightSectionWidth}
rightSection={
language != null && onLanguageChange != null ? (
<div ref={ref}>

View file

@ -164,6 +164,7 @@ function AIAssistant({
</Group>
<Collapse in={opened}>
{opened && (
// eslint-disable-next-line react-hooks/refs
<form onSubmit={handleSubmit(onSubmit)}>
<Group mb="md">
<SourceSelectControlled

View file

@ -764,19 +764,16 @@ export function useDefaultOrderBy(sourceID: string | undefined | null) {
const { data: source } = useSource({ id: sourceID });
const { data: tableMetadata } = useTableMetadata(tcFromSource(source));
// If no source, return undefined so that the orderBy is not set incorrectly
if (!source) return undefined;
// When source changes, make sure select and orderby fields are set to default
return useMemo(
() =>
optimizeDefaultOrderBy(
source?.timestampValueExpression ?? '',
source?.displayedTimestampValueExpression,
tableMetadata?.sorting_key,
),
[source, tableMetadata],
);
return useMemo(() => {
// If no source, return undefined so that the orderBy is not set incorrectly
if (!source) return undefined;
return optimizeDefaultOrderBy(
source?.timestampValueExpression ?? '',
source?.displayedTimestampValueExpression,
tableMetadata?.sorting_key,
);
}, [source, tableMetadata]);
}
// This is outside as it needs to be a stable reference

View file

@ -264,6 +264,7 @@ export default function DOMPlayer({
);
}
// eslint-disable-next-line react-hooks/immutability
updatePlayerTimeRafRef.current = requestAnimationFrame(updatePlayerTime);
}, []);

View file

@ -12,7 +12,7 @@ export default function LandingHeader({
activeKey: string;
fixed?: boolean;
}) {
const Wordmark = useWordmark();
const wordmark = useWordmark();
const { data: me } = api.useMe();
const isLoggedIn = Boolean(me);
@ -36,7 +36,7 @@ export default function LandingHeader({
<Container fluid px="xl" py="md">
<Group justify="space-between" align="center">
<Link href="/" style={{ textDecoration: 'none' }}>
<Wordmark />
{wordmark}
</Link>
<Burger

View file

@ -20,7 +20,6 @@ import {
} from '@tabler/icons-react';
import { useQueriedChartConfig } from './hooks/useChartConfig';
import { useLogomark } from './theme/ThemeProvider';
import api from './api';
import { useConnections } from './connection';
import { useSources } from './source';
@ -36,12 +35,12 @@ interface OnboardingStep {
onClick?: () => void;
}
const NOW = Date.now();
const OnboardingChecklist = ({
onAddDataClick,
}: {
onAddDataClick?: () => void;
}) => {
const Logomark = useLogomark();
const [isCollapsed, setIsCollapsed] = useLocalStorage(
'onboardingChecklistCollapsed',
false,
@ -55,7 +54,7 @@ const OnboardingChecklist = ({
// Check if team is new (less than 3 days old)
const isNewTeam = useMemo(() => {
if (!team?.createdAt) return false;
const threeDaysAgo = new Date(Date.now() - 1000 * 60 * 60 * 24 * 3);
const threeDaysAgo = new Date(NOW - 1000 * 60 * 60 * 24 * 3);
return new Date(team.createdAt) > threeDaysAgo;
}, [team]);

View file

@ -17,6 +17,7 @@ import {
Button,
Divider,
Group,
Portal,
SegmentedControl,
Tooltip,
} from '@mantine/core';
@ -66,16 +67,10 @@ function useSessionChartConfigs({
const { getFieldExpression: getTraceSourceFieldExpression } =
useFieldExpressionGenerator(traceSource);
if (!getTraceSourceFieldExpression) {
return {
eventsConfig: undefined,
aliasMap: undefined,
};
}
// Should produce rows that match the `sessionRowSchema` in packages/app/src/utils/sessions.ts
const select = useMemo(
() => [
const select = useMemo(() => {
if (!getTraceSourceFieldExpression) return [];
return [
{
valueExpression: `${getTraceSourceFieldExpression(traceSource.eventAttributesExpression ?? 'SpanAttributes', 'message')}`,
alias: 'body',
@ -145,9 +140,8 @@ function useSessionChartConfigs({
valueExpression: `CAST('span', 'String')`,
alias: 'type',
},
],
[traceSource, getTraceSourceFieldExpression],
);
];
}, [traceSource, getTraceSourceFieldExpression]);
// Events shown in the highlighted tab
const highlightedEventsFilter = useMemo(
@ -166,8 +160,9 @@ function useSessionChartConfigs({
[traceSource, rumSessionId],
);
const allEventsFilter = useMemo(
() => ({
const allEventsFilter = useMemo(() => {
if (!getTraceSourceFieldExpression) return undefined;
return {
type: 'lucene' as const,
condition: `${traceSource.resourceAttributesExpression}.rum.sessionId:"${rumSessionId}"
AND (
@ -179,12 +174,13 @@ function useSessionChartConfigs({
OR ${traceSource.spanNameExpression}:"intercom.onShow"
OR ScopeName:"custom-action"
)`,
}),
[traceSource, rumSessionId],
);
};
}, [traceSource, rumSessionId, getTraceSourceFieldExpression]);
const eventsConfig = useMemo<ChartConfigWithOptDateRange>(
() => ({
const eventsConfig = useMemo<ChartConfigWithOptDateRange | undefined>(() => {
if (!getTraceSourceFieldExpression || !select || !allEventsFilter)
return undefined;
return {
select: select,
from: traceSource.from,
dateRange: [start, end],
@ -201,21 +197,22 @@ function useSessionChartConfigs({
filters: [
tab === 'highlighted' ? highlightedEventsFilter : allEventsFilter,
],
}),
[
select,
traceSource,
start,
end,
where,
whereLanguage,
tab,
highlightedEventsFilter,
allEventsFilter,
],
);
};
}, [
select,
traceSource,
start,
end,
where,
whereLanguage,
tab,
highlightedEventsFilter,
allEventsFilter,
getTraceSourceFieldExpression,
]);
const aliasMap = useMemo(() => {
if (!getTraceSourceFieldExpression) return undefined;
// valueExpression: alias
return select.reduce(
(acc, { valueExpression, alias }) => {
@ -224,7 +221,7 @@ function useSessionChartConfigs({
},
{} as Record<string, string>,
);
}, [select]);
}, [select, getTraceSourceFieldExpression]);
return {
eventsConfig,
@ -269,46 +266,23 @@ export default function SessionSubpanel({
const [rowId, setRowId] = useState<string | undefined>(undefined);
const [aliasWith, setAliasWith] = useState<WithClause[]>([]);
// Without portaling the nested drawer close overlay will not render properly
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
containerRef.current = document.createElement('div');
if (containerRef.current) {
document.body.appendChild(containerRef.current);
}
return () => {
if (containerRef.current) {
document.body.removeChild(containerRef.current);
}
};
}, []);
const portaledPanel =
containerRef.current != null
? ReactDOM.createPortal(
traceSource && (
<DBRowSidePanel
source={traceSource}
rowId={rowId}
aliasWith={aliasWith}
onClose={() => {
setDrawerOpen(false);
setRowId(undefined);
}}
/>
),
containerRef.current,
)
: null;
const [tsQuery, setTsQuery] = useQueryState(
'ts',
parseAsInteger.withOptions({ history: 'replace' }),
);
const prevTsQuery = usePrevious(tsQuery);
const [focus, _setFocus] = useState<
{ ts: number; setBy: string } | undefined
>(
initialTs != null
? {
ts: initialTs,
setBy: 'parent',
}
: undefined,
);
useEffect(() => {
if (prevTsQuery == null && tsQuery != null) {
_setFocus({ ts: tsQuery, setBy: 'url' });
@ -327,17 +301,6 @@ export default function SessionSubpanel({
};
}, [setTsQuery]);
const [focus, _setFocus] = useState<
{ ts: number; setBy: string } | undefined
>(
initialTs != null
? {
ts: initialTs,
setBy: 'parent',
}
: undefined,
);
const setFocus = useCallback(
(focus: { ts: number; setBy: string }) => {
if (focus.setBy === 'player') {
@ -471,10 +434,44 @@ export default function SessionSubpanel({
},
[setSearchedQuery],
);
const onSessionEventClick = useCallback(
(rowWhere: RowWhereResult) => {
setDrawerOpen(true);
setRowId(rowWhere.where);
setAliasWith(rowWhere.aliasWith);
},
[setDrawerOpen, setRowId, setAliasWith],
);
const onSessionEventTimeClick = useCallback(
(ts: number) => {
setFocus({ ts, setBy: 'timeline' });
},
[setFocus],
);
const setPlayerTime = useCallback(
(ts: number) => {
if (focus?.setBy !== 'player' || focus?.ts !== ts) {
setFocus({ ts, setBy: 'player' });
}
},
[focus, setFocus],
);
return (
<div className={styles.wrapper}>
{rowId != null && portaledPanel}
{rowId != null && traceSource && (
<Portal>
<DBRowSidePanel
source={traceSource}
rowId={rowId}
aliasWith={aliasWith}
onClose={() => {
setDrawerOpen(false);
setRowId(undefined);
}}
/>
</Portal>
)}
<div className={cx(styles.eventList, { 'd-none': playerFullWidth })}>
<div className={styles.eventListHeader}>
<form
@ -536,21 +533,9 @@ export default function SessionSubpanel({
eventsFollowPlayerPosition={eventsFollowPlayerPosition}
aliasMap={aliasMap}
queriedConfig={eventsConfig}
onClick={useCallback(
(rowWhere: RowWhereResult) => {
setDrawerOpen(true);
setRowId(rowWhere.where);
setAliasWith(rowWhere.aliasWith);
},
[setDrawerOpen, setRowId, setAliasWith],
)}
onClick={onSessionEventClick}
focus={focus}
onTimeClick={useCallback(
ts => {
setFocus({ ts, setBy: 'timeline' });
},
[setFocus],
)}
onTimeClick={onSessionEventTimeClick}
minTs={minTs}
showRelativeTime={showRelativeTime}
/>
@ -563,14 +548,7 @@ export default function SessionSubpanel({
playerState={playerState}
setPlayerState={setPlayerState}
focus={focus}
setPlayerTime={useCallback(
ts => {
if (focus?.setBy !== 'player' || focus?.ts !== ts) {
setFocus({ ts, setBy: 'player' });
}
},
[focus, setFocus],
)}
setPlayerTime={setPlayerTime}
config={{
serviceName: session.serviceName,
sourceId: sessionSource.id,

View file

@ -22,7 +22,7 @@ import '@mantine/spotlight/styles.css';
export const useSpotlightActions = () => {
const router = useRouter();
const Logomark = useLogomark();
const logomark = useLogomark({ size: 16 });
const { data: logViewsData } = useSavedSearches();
const { data: dashboardsData } = api.useDashboards();
@ -151,7 +151,7 @@ export const useSpotlightActions = () => {
{
id: 'cloud',
group: 'Menu',
leftSection: <Logomark size={16} />,
leftSection: logomark,
label: 'HyperDX Cloud',
description: 'Ready to use HyperDX Cloud? Get started for free.',
keywords: ['account', 'profile'],
@ -162,7 +162,7 @@ export const useSpotlightActions = () => {
);
return logViewActions;
}, [Logomark, logViewsData, dashboardsData, router]);
}, [logomark, logViewsData, dashboardsData, router]);
return { actions };
};

View file

@ -391,8 +391,8 @@ function useSearchableList<T extends AppNavLinkItem>({
}
export default function AppNav({ fixed = false }: { fixed?: boolean }) {
const Wordmark = useWordmark();
const Logomark = useLogomark();
const wordmark = useWordmark();
const logomark = useLogomark({ size: 22 });
useEffect(() => {
let redirectUrl;
@ -676,12 +676,10 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
>
<Link href="/search" className={styles.logoLink}>
{isCollapsed ? (
<div className={styles.logoIconWrapper}>
<Logomark size={22} />
</div>
<div className={styles.logoIconWrapper}>{logomark}</div>
) : (
<Group gap="xs" align="center">
<Wordmark />
{wordmark}
{isUTC && (
<Badge
size="xs"

View file

@ -168,13 +168,11 @@ export function DBRowJsonViewer({
}
// remove internal aliases (keys that start with __hdx_)
Object.keys(data).forEach(key => {
if (key.startsWith('__hdx_')) {
delete data[key];
}
});
const cleanedData = Object.fromEntries(
Object.entries(data).filter(entry => !entry[0].startsWith('__hdx_')),
);
return filterObjectRecursively(data, debouncedFilter);
return filterObjectRecursively(cleanedData, debouncedFilter);
}, [data, debouncedFilter]);
const getLineActions = useCallback<GetLineActions>(

View file

@ -423,7 +423,19 @@ export const RawLogTable = memo(
const FETCH_NEXT_PAGE_PX = 500;
//we need a reference to the scrolling element for logic down below
const tableContainerRef = useRef<HTMLDivElement>(null);
const [tableContainerRef, setTableContainerRef] =
useState<HTMLDivElement | null>(null);
const tableContainerRefCallback = useCallback(
(node: HTMLDivElement): (() => void) => {
if (node) {
setTableContainerRef(node);
}
return () => {
setTableContainerRef(null);
};
},
[],
);
// Get the alias map from the config so we resolve correct column ids
const { data: aliasMap } = useAliasMapFromChartConfig(config);
@ -431,10 +443,10 @@ export const RawLogTable = memo(
// Reset scroll when live tail is enabled for the first time
const prevIsLive = usePrevious(isLive);
useEffect(() => {
if (isLive && prevIsLive === false && tableContainerRef.current != null) {
tableContainerRef.current.scrollTop = 0;
if (isLive && prevIsLive === false && tableContainerRef != null) {
tableContainerRef.scrollTop = 0;
}
}, [isLive, prevIsLive]);
}, [isLive, prevIsLive, tableContainerRef]);
const logLevelColumn = useMemo(() => {
return inferLogLevelColumn(dedupedRows);
@ -600,8 +612,8 @@ export const RawLogTable = memo(
//a check on mount and after a fetch to see if the table is already scrolled to the bottom and immediately needs to fetch more data
useEffect(() => {
fetchMoreOnBottomReached(tableContainerRef.current);
}, [fetchMoreOnBottomReached]);
fetchMoreOnBottomReached(tableContainerRef);
}, [fetchMoreOnBottomReached, tableContainerRef]);
const reactTableProps = useMemo((): TableOptions<any> => {
//TODO: fix any
@ -664,7 +676,7 @@ export const RawLogTable = memo(
count: _rows.length,
// count: hasNextPage ? allRows.length + 1 : allRows.length,
getScrollElement: useCallback(
() => tableContainerRef.current,
() => tableContainerRef,
[tableContainerRef],
),
estimateSize: useCallback(() => 23, []),
@ -853,7 +865,7 @@ export const RawLogTable = memo(
onScroll?.(scrollTop);
}
}}
ref={tableContainerRef}
ref={tableContainerRefCallback}
// Fixes flickering scroll bar: https://github.com/TanStack/virtual/issues/426#issuecomment-1403438040
// style={{ overflowAnchor: 'none' }}
>

View file

@ -64,22 +64,19 @@ export const useSessionId = ({
});
const result = useMemo(() => {
for (const row of data?.data || []) {
if (row.parentSpanId === null && row.rumSessionId) {
return {
rumServiceName: row.serviceName,
rumSessionId: row.rumSessionId,
};
}
const rowData = data?.data || [];
let row = rowData.find(
row => row.parentSpanId === null && row.rumSessionId,
);
if (!row) {
// otherwise just return the first session id
row = rowData.find(row => row.rumSessionId);
}
// otherwise just return the first session id
for (const row of data?.data || []) {
if (row.rumSessionId) {
return {
rumServiceName: row.serviceName,
rumSessionId: row.rumSessionId,
};
}
if (row) {
return {
rumServiceName: row.serviceName,
rumSessionId: row.rumSessionId,
};
}
return { rumServiceName: undefined, rumSessionId: undefined };
}, [data]);

View file

@ -15,7 +15,7 @@ export interface DBRowTableFieldWithPopoverProps {
cellValue: unknown;
wrapLinesEnabled: boolean;
columnName?: string;
tableContainerRef?: React.RefObject<HTMLDivElement | null>;
tableContainerRef: HTMLDivElement | null;
isChart?: boolean;
}
@ -148,7 +148,7 @@ export const DBRowTableFieldWithPopover = ({
position="top-start"
offset={5}
opened={opened}
portalProps={{ target: tableContainerRef?.current ?? undefined }}
portalProps={{ target: tableContainerRef ?? undefined }}
closeOnClickOutside={false}
clickOutsideEvents={[]}
>

View file

@ -93,7 +93,7 @@ interface TableSearchInputProps {
/**
* Reference to a container element to check if it's clickable (not obscured by modal/drawer)
*/
containerRef?: React.RefObject<HTMLElement | null>;
containerRef?: HTMLElement | null;
}
/**
@ -145,8 +145,7 @@ export const TableSearchInput = ({
// Detect Cmd+F (Mac) or Ctrl+F (Windows/Linux)
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
// if container exists, verify it's actually clickable
if (containerRef?.current && !isElementClickable(containerRef.current))
return;
if (containerRef && !isElementClickable(containerRef)) return;
e.preventDefault();
handleShow();

View file

@ -103,7 +103,7 @@ export default function DBTableChart({
}
return acc;
}, [] as string[]);
}, [config?.select]);
}, [config.select]);
const columns = useMemo(() => {
const rows = data?.data ?? [];
if (rows.length === 0) {

View file

@ -153,15 +153,12 @@ export const NumberFormatForm: React.FC<{
key="numberFormat.factor"
name="numberFormat.factor"
render={({ field: { value, onChange, ...field } }) => {
const options = useMemo(
() => [
{ value: '1', label: 'Seconds' },
{ value: '0.001', label: 'Milliseconds' },
{ value: '0.000001', label: 'Microseconds' },
{ value: '0.000000001', label: 'Nanoseconds' },
],
[],
);
const options = [
{ value: '1', label: 'Seconds' },
{ value: '0.001', label: 'Milliseconds' },
{ value: '0.000001', label: 'Microseconds' },
{ value: '0.000000001', label: 'Nanoseconds' },
];
const stringValue =
options.find(option => parseFloat(option.value) === value)

View file

@ -60,6 +60,7 @@ export default function SQLEditor({
minHeight={'100px'}
extensions={[
styleTheme,
// eslint-disable-next-line react-hooks/refs
compartmentRef.current.of(
sql({
upperCaseKeywords: true,

View file

@ -369,6 +369,7 @@ export default function SQLInlineEditor({
...tooltipExt,
createStyleTheme(),
...(allowMultiline ? [EditorView.lineWrapping] : []),
// eslint-disable-next-line react-hooks/refs
compartmentRef.current.of(
sql({
upperCaseKeywords: true,

View file

@ -9,7 +9,16 @@ export type SelectControlledProps = SelectProps &
};
export default function SelectControlled(props: SelectControlledProps) {
const { field, fieldState } = useController(props);
const {
field: {
value: fieldValue,
onChange: fieldOnChange,
onBlur: fieldOnBlur,
name: fieldName,
ref: fieldRef,
},
fieldState,
} = useController(props);
const { onCreate, allowDeselect = true, ...restProps } = props;
// This is needed as mantine does not clear the select
@ -17,9 +26,9 @@ export default function SelectControlled(props: SelectControlledProps) {
// if it was previously in the data (ex. data was deleted)
const selected = props.data?.find(d =>
typeof d === 'string'
? d === field.value
? d === fieldValue
: 'value' in d
? d.value === field.value
? d.value === fieldValue
: true,
);
@ -28,21 +37,21 @@ export default function SelectControlled(props: SelectControlledProps) {
if (value === '_create_new_value' && onCreate != null) {
onCreate();
} else if (value !== null || allowDeselect) {
field.onChange(value);
fieldOnChange(value);
}
},
[field, onCreate, allowDeselect],
[fieldOnChange, onCreate, allowDeselect],
);
return (
<Select
{...restProps}
error={fieldState.error?.message}
value={selected == null ? null : field.value}
value={selected == null ? null : fieldValue}
onChange={onChange}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
onBlur={fieldOnBlur}
name={fieldName}
ref={fieldRef}
/>
);
}

View file

@ -101,6 +101,7 @@ describe('DBNumberChart', () => {
const [numberFormat, setNumberFormat] = React.useState<
NumberFormat | undefined
>(undefined);
// eslint-disable-next-line react-hooks/globals
setNumberFormatFn = setNumberFormat;
return <DBNumberChart config={{ ...baseTestConfig, numberFormat }} />;
};

View file

@ -66,6 +66,7 @@ export function useAutoCompleteOptions(
}, [formatter, fields, additionalSuggestions]);
// searchField is used for the purpose of checking if a key is valid and key values should be fetched
// TODO: Come back and refactor how this works - it's not great and wouldn't catch a person copy-pasting some text
const [searchField, setSearchField] = useState<Field | null>(null);
// check if any search field matches
useEffect(() => {

View file

@ -52,6 +52,7 @@ function useResizable(
const endResize = useCallback(() => {
document.removeEventListener('mousemove', handleResize);
// eslint-disable-next-line react-hooks/immutability
document.removeEventListener('mouseup', endResize);
}, [handleResize]);

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useMemo } from 'react';
/// Interface for all suggestion engines
interface ISuggestionEngine {
@ -82,12 +82,9 @@ export function useSqlSuggestions({
input: string;
enabled: boolean;
}): Suggestion[] | null {
const [suggestions, setSuggestions] = useState<Suggestion[] | null>(null);
useEffect(() => {
return useMemo(() => {
if (!enabled) {
setSuggestions(null);
return;
return null;
}
const suggestions: Suggestion[] = [];
@ -99,8 +96,6 @@ export function useSqlSuggestions({
});
}
}
setSuggestions(suggestions.length > 0 ? suggestions : null);
return suggestions.length > 0 ? suggestions : null;
}, [input, enabled]);
return suggestions;
}

View file

@ -5,8 +5,7 @@ import { useWordmark } from './theme/ThemeProvider';
import useNextraSeoProps from './useNextraSeoProps';
function ThemedLogo() {
const Wordmark = useWordmark();
return <Wordmark />;
return useWordmark();
}
const theme = {

View file

@ -217,14 +217,21 @@ export function useAppTheme(): ThemeContextValue {
}
// Convenience hooks
// NOTE: These hooks return JSX elements, not component references, to avoid
// creating components during render (which would violate react-hooks/static-components)
export function useWordmark() {
const { theme } = useAppTheme();
return theme.Wordmark;
const WordmarkComponent = theme.Wordmark;
return useMemo(() => <WordmarkComponent />, [WordmarkComponent]);
}
export function useLogomark() {
export function useLogomark(props?: { size?: number }) {
const { theme } = useAppTheme();
return theme.Logomark;
const LogomarkComponent = theme.Logomark;
return useMemo(
() => <LogomarkComponent {...props} />,
[LogomarkComponent, props],
);
}
// Hook to get current theme name (useful for conditional rendering)

View file

@ -127,7 +127,7 @@ describe('ThemeProvider', () => {
const { result } = renderHook(() => useWordmark(), { wrapper });
expect(result.current).toBeDefined();
expect(typeof result.current).toBe('function');
expect(typeof result.current).toBe('object');
});
});
@ -140,7 +140,7 @@ describe('ThemeProvider', () => {
const { result } = renderHook(() => useLogomark(), { wrapper });
expect(result.current).toBeDefined();
expect(typeof result.current).toBe('function');
expect(typeof result.current).toBe('object');
});
});

View file

@ -242,11 +242,13 @@ export function useTimeQuery({
liveTailTimeRange == null &&
tempLiveTailTimeRange == null &&
!isInputTimeQueryLive(inputTimeQuery) &&
// eslint-disable-next-line react-hooks/refs
inputTimeQueryDerivedTimeQueryRef.current != null
) {
// Use the input time query, allows users to specify relative time ranges
// via url ex. /logs?tq=Last+30+minutes
// return inputTimeQueryDerivedTimeQuery as [Date, Date];
// eslint-disable-next-line react-hooks/refs
return inputTimeQueryDerivedTimeQueryRef.current;
} else if (
isReady &&
@ -339,6 +341,7 @@ export function useTimeQuery({
],
);
// eslint-disable-next-line react-hooks/refs
return {
isReady, // Don't search until we know what we want to do
isLive,

View file

@ -28,6 +28,7 @@ export const QueryParamProvider = ({
const setState = useCallback(
(state: Record<string, any>) => {
// eslint-disable-next-line react-hooks/immutability
setCache(oldCache => {
const newCache = {
...oldCache,

View file

@ -689,6 +689,7 @@ export const usePrevious = <T>(value: T): T | undefined => {
useEffect(() => {
ref.current = value;
});
// eslint-disable-next-line react-hooks/refs
return ref.current;
};

View file

@ -4,7 +4,7 @@ const USE_FULLSTACK = process.env.E2E_FULLSTACK === 'true';
// Extend the base test to automatically handle Tanstack devtools
export const test = base.extend({
page: async ({ page }, use) => {
page: async ({ page }, fn) => {
// Note: page.addInitScript runs in the browser context, which cannot access Node.js
// environment variables directly. We pass USE_FULLSTACK as a parameter so the browser
// script can determine whether to set up demo connections (local mode) or rely on
@ -153,7 +153,7 @@ export const test = base.extend({
);
}
}, USE_FULLSTACK);
await use(page);
await fn(page);
},
});

View file

@ -4379,6 +4379,7 @@ __metadata:
eslint-config-next: "npm:^16.0.10"
eslint-plugin-playwright: "npm:^2.4.0"
eslint-plugin-react-hook-form: "npm:^0.3.1"
eslint-plugin-react-hooks: "npm:^7.0.1"
eslint-plugin-storybook: "npm:10.1.4"
flat: "npm:^5.0.2"
fuse.js: "npm:^6.6.2"