chore: Prevent Date.now() and new Date() via eslint (#1937)

## Summary

This PR adds lint rules disallowing Date.now() and new Date(), which can cause unnecessary re-renders.

### Screenshots or video

No behavior changes are expected.

### How to test locally or on Vercel

This can be tested in the preview environment - it is an app-only change

### References



- Linear Issue: Closes HDX-2187
- Related PRs:
This commit is contained in:
Drew Davis 2026-03-18 17:19:58 -04:00 committed by GitHub
parent 50aa44bd39
commit 2b53b8e9ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 140 additions and 75 deletions

View file

@ -11,6 +11,76 @@ import playwrightPlugin from 'eslint-plugin-playwright';
import reactHookFormPlugin from 'eslint-plugin-react-hook-form';
import { fixupPluginRules } from '@eslint/compat';
// Kept separate so test overrides can drop just the date rules while keeping
// the UI style rules (bi-icons, Button/ActionIcon variants).
const UI_SYNTAX_RESTRICTIONS = [
// Temporary rule to enforce use of @tabler/icons-react instead of bi bi-icons
// Will remove after we've updated all icons and let some PRs merge.
{
selector: 'Literal[value=/\\bbi-\\b/i]',
message: 'Please update to use @tabler/icons-react instead',
},
// Enforce custom Button/ActionIcon variants (see agent_docs/code_style.md)
// NOTE: Icon-only Buttons should use ActionIcon instead - this requires manual review
// as ESLint cannot detect children content patterns
{
selector:
'JSXElement[openingElement.name.name="Button"] JSXAttribute[name.name="variant"][value.value="light"]',
message:
'Use variant="primary", "secondary", or "danger" for Button. See agent_docs/code_style.md',
},
{
selector:
'JSXElement[openingElement.name.name="Button"] JSXAttribute[name.name="variant"][value.value="filled"]',
message:
'Use variant="primary", "secondary", or "danger" for Button. See agent_docs/code_style.md',
},
{
selector:
'JSXElement[openingElement.name.name="Button"] JSXAttribute[name.name="variant"][value.value="outline"]',
message:
'Use variant="primary", "secondary", or "danger" for Button. See agent_docs/code_style.md',
},
{
selector:
'JSXElement[openingElement.name.name="Button"] JSXAttribute[name.name="variant"][value.value="default"]',
message:
'Use variant="primary", "secondary", or "danger" for Button. See agent_docs/code_style.md',
},
{
selector:
'JSXElement[openingElement.name.name="ActionIcon"] JSXAttribute[name.name="variant"][value.value="light"]',
message:
'Use variant="primary", "secondary", or "danger" for ActionIcon. See agent_docs/code_style.md',
},
{
selector:
'JSXElement[openingElement.name.name="ActionIcon"] JSXAttribute[name.name="variant"][value.value="filled"]',
message:
'Use variant="primary", "secondary", or "danger" for ActionIcon. See agent_docs/code_style.md',
},
{
selector:
'JSXElement[openingElement.name.name="ActionIcon"] JSXAttribute[name.name="variant"][value.value="outline"]',
message:
'Use variant="primary", "secondary", or "danger" for ActionIcon. See agent_docs/code_style.md',
},
];
const DATE_SYNTAX_RESTRICTIONS = [
{
selector:
'CallExpression[callee.object.name="Date"][callee.property.name="now"]',
message:
'Date.now() can cause unnecessary re-renders. Import NOW from @/config for a stable reference, or wrap in useMemo/useCallback for values that must be current.',
},
{
selector: 'NewExpression[callee.name="Date"][arguments.length=0]',
message:
'new Date() can cause unnecessary re-renders. Use new Date(NOW) for a stable reference, or wrap in useMemo/useCallback for values that must be current.',
},
];
export default [
js.configs.recommended,
...tseslint.configs.recommended,
@ -80,59 +150,10 @@ export default [
],
},
],
// Temporary rule to enforce use of @tabler/icons-react instead of bi bi-icons
// Will remove after we've updated all icons and let some PRs merge.
'no-restricted-syntax': [
'error',
{
selector: 'Literal[value=/\\bbi-\\b/i]',
message: 'Please update to use @tabler/icons-react instead',
},
// Enforce custom Button/ActionIcon variants (see agent_docs/code_style.md)
// NOTE: Icon-only Buttons should use ActionIcon instead - this requires manual review
// as ESLint cannot detect children content patterns
{
selector:
'JSXElement[openingElement.name.name="Button"] JSXAttribute[name.name="variant"][value.value="light"]',
message:
'Use variant="primary", "secondary", or "danger" for Button. See agent_docs/code_style.md',
},
{
selector:
'JSXElement[openingElement.name.name="Button"] JSXAttribute[name.name="variant"][value.value="filled"]',
message:
'Use variant="primary", "secondary", or "danger" for Button. See agent_docs/code_style.md',
},
{
selector:
'JSXElement[openingElement.name.name="Button"] JSXAttribute[name.name="variant"][value.value="outline"]',
message:
'Use variant="primary", "secondary", or "danger" for Button. See agent_docs/code_style.md',
},
{
selector:
'JSXElement[openingElement.name.name="Button"] JSXAttribute[name.name="variant"][value.value="default"]',
message:
'Use variant="primary", "secondary", or "danger" for Button. See agent_docs/code_style.md',
},
{
selector:
'JSXElement[openingElement.name.name="ActionIcon"] JSXAttribute[name.name="variant"][value.value="light"]',
message:
'Use variant="primary", "secondary", or "danger" for ActionIcon. See agent_docs/code_style.md',
},
{
selector:
'JSXElement[openingElement.name.name="ActionIcon"] JSXAttribute[name.name="variant"][value.value="filled"]',
message:
'Use variant="primary", "secondary", or "danger" for ActionIcon. See agent_docs/code_style.md',
},
{
selector:
'JSXElement[openingElement.name.name="ActionIcon"] JSXAttribute[name.name="variant"][value.value="outline"]',
message:
'Use variant="primary", "secondary", or "danger" for ActionIcon. See agent_docs/code_style.md',
},
...UI_SYNTAX_RESTRICTIONS,
...DATE_SYNTAX_RESTRICTIONS,
],
'react-hooks/exhaustive-deps': 'error',
'no-console': ['error', { allow: ['warn', 'error'] }],
@ -179,6 +200,13 @@ export default [
'@typescript-eslint/no-unsafe-type-assertion': 'off',
},
},
{
files: ['src/**/__tests__/**/*.{ts,tsx}', 'src/**/*.test.{ts,tsx}'],
rules: {
// Drop date rules — new Date() / Date.now() are fine in tests
'no-restricted-syntax': ['error', ...UI_SYNTAX_RESTRICTIONS],
},
},
{
files: ['tests/e2e/**/*.{ts,js}'],
...playwrightPlugin.configs['flat/recommended'],
@ -189,6 +217,8 @@ export default [
'@typescript-eslint/no-explicit-any': 'off',
'@next/next/no-html-link-for-pages': 'off',
'playwright/no-networkidle': 'off', // temporary until we have a better way to deal with react re-renders
// Drop date rules — Date.now() is fine in e2e tests for unique IDs/timestamps
'no-restricted-syntax': ['error', ...UI_SYNTAX_RESTRICTIONS],
},
},
...storybook.configs['flat/recommended'],

View file

@ -54,6 +54,8 @@ function AlertHistoryCard({
alertUrl: string;
}) {
const start = new Date(history.createdAt.toString());
// eslint-disable-next-line no-restricted-syntax
const today = React.useMemo(() => new Date(), []);
const href = React.useMemo(() => {
@ -141,6 +143,7 @@ function AckAlert({ alert }: { alert: AlertsPageItem }) {
const handleSilenceAlert = React.useCallback(
(duration: Duration) => {
// eslint-disable-next-line no-restricted-syntax
const mutedUntil = add(new Date(), duration);
silenceAlert.mutate(
{

View file

@ -212,13 +212,16 @@ function BenchmarkPage() {
// Hack to get time range
useEffect(() => {
if (_queries.length > 0 && _connections.length > 0) {
// eslint-disable-next-line no-restricted-syntax
setStartTime(new Date(Date.now() - 1000));
}
}, [_queries, _connections]);
useEffect(() => {
if (queryIds != null && queryIds[0] != null) {
setEndTime(
new Date(
// eslint-disable-next-line no-restricted-syntax
Date.now() - 1000 * 9, // minus hard-coded flush interval
),
);

View file

@ -286,6 +286,7 @@ const Tile = forwardRef(
let tooltip = `Has alert and is in ${alert.state} state`;
if (alert.silenced?.at) {
const silencedAt = new Date(alert.silenced.at);
// eslint-disable-next-line no-restricted-syntax
tooltip += `. Ack'd ${formatRelative(silencedAt, new Date())}`;
}
return tooltip;

View file

@ -602,6 +602,7 @@ function useLiveUpdate({
const [refreshOnVisible, setRefreshOnVisible] = useState(false);
const refresh = useCallback(() => {
// eslint-disable-next-line no-restricted-syntax
onTimeRangeSelect(new Date(Date.now() - interval), new Date(), null);
}, [onTimeRangeSelect, interval]);

View file

@ -136,8 +136,8 @@ export default function DOMPlayer({
serviceName,
sessionId,
sourceId,
startDate: dateRange?.[0] ?? new Date(),
endDate: dateRange?.[1] ?? new Date(),
startDate: dateRange[0],
endDate: dateRange[1],
limit: 1000000, // large enough to get all events
onEvent: (event: { b: string; ck: number; tcks: number; t: number }) => {
try {

View file

@ -51,6 +51,7 @@ import {
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
K8S_MEM_NUMBER_FORMAT,
} from './ChartUtils';
import { NOW } from './config';
import { withAppNav } from './layout';
import NamespaceDetailsSidePanel from './NamespaceDetailsSidePanel';
import NodeDetailsSidePanel from './NodeDetailsSidePanel';
@ -772,8 +773,8 @@ const NamespacesTable = ({
dateRange: [
// We should only look at the latest values, otherwise we might
// aggregate pod metrics from pods that have been terminated
sub(dateRange[1] ?? new Date(), { minutes: 5 }),
dateRange[1] ?? new Date(),
sub(dateRange[1], { minutes: 5 }),
dateRange[1],
],
seriesReturnType: 'column',
},

View file

@ -21,6 +21,7 @@ import {
import { useQueriedChartConfig } from './hooks/useChartConfig';
import api from './api';
import { NOW } from './config';
import { useConnections } from './connection';
import { useSources } from './source';
import { useLocalStorage } from './utils';
@ -34,8 +35,6 @@ interface OnboardingStep {
href?: string;
onClick?: () => void;
}
const NOW = Date.now();
const OnboardingChecklist = ({
onAddDataClick,
}: {

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import CopyToClipboard from 'react-copy-to-clipboard';
import { useHotkeys } from 'react-hotkeys-hook';
import {
@ -62,15 +62,12 @@ export default function SessionSidePanel({
},
);
// console.log({ logId: sessionId, subDrawerOpen });
const maxTime =
session != null ? new Date(session?.maxTimestamp) : new Date();
// const minTime =
// session != null ? new Date(session?.['min_timestamp']) : new Date();
const timeAgo = formatDistanceToNowStrictShort(maxTime);
// const durationStr = new Date(maxTime.getTime() - minTime.getTime())
// .toISOString()
// .slice(11, 19);
const timeAgo = useMemo(() => {
const maxTime =
// eslint-disable-next-line no-restricted-syntax
session != null ? new Date(session?.maxTimestamp) : new Date();
return formatDistanceToNowStrictShort(maxTime);
}, [session]);
return (
<Drawer

View file

@ -3,7 +3,7 @@ import Papa from 'papaparse';
interface CsvExportButtonProps {
data: Record<string, any>[];
filename: string;
filename: string | (() => string);
children: React.ReactNode;
className?: string;
title?: string;
@ -46,7 +46,8 @@ export const CsvExportButton: React.FC<CsvExportButtonProps> = ({
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${filename}.csv`;
a.download =
typeof filename === 'string' ? `${filename}.csv` : `${filename()}.csv`;
document.body.appendChild(a);
a.click();
a.remove();

View file

@ -51,6 +51,7 @@ const InfraSubpanelGroup = ({
}[range];
return [
sub(new Date(timestamp), duration),
// eslint-disable-next-line no-restricted-syntax
min([add(new Date(timestamp), duration), new Date()]),
];
}, [timestamp, range]);

View file

@ -932,6 +932,15 @@ export const RawLogTable = memo(
shiftHighlightedLineId(-1);
});
const getCsvFilename = useCallback(() => {
// eslint-disable-next-line no-restricted-syntax
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, '-')
.slice(0, 19);
return `hyperdx_search_results_${timestamp}.csv`;
}, []);
return (
<Flex direction="column" h="100%">
<Box pos="relative" style={{ flex: 1, minHeight: 0 }}>
@ -1050,7 +1059,7 @@ export const RawLogTable = memo(
<CsvExportButton
data={csvData}
filename={`hyperdx_search_results_${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`}
filename={getCsvFilename}
className="fs-6"
>
<MantineTooltip

View file

@ -176,10 +176,12 @@ export const KubeTimeline = ({
anchorEvent?: AnchorEvent;
}) => {
const startDate = React.useMemo(
// eslint-disable-next-line no-restricted-syntax
() => dateRange?.[0] ?? sub(new Date(), { days: 1 }),
[dateRange],
);
const endDate = React.useMemo(
// eslint-disable-next-line no-restricted-syntax
() => dateRange?.[1] ?? new Date(),
[dateRange],
);

View file

@ -22,6 +22,7 @@ const chartConfigByMetricType = ({
metricSource: TSource;
metricType: MetricsDataType;
}) => {
// eslint-disable-next-line no-restricted-syntax
const now = new Date();
let _dateRange: DateRange['dateRange'] = dateRange
? dateRange

View file

@ -96,6 +96,7 @@ const TimePickerComponent = ({
useHotkeys('d', () => toggle(), { preventDefault: true }, [toggle]);
// eslint-disable-next-line no-restricted-syntax
const today = React.useMemo(() => new Date(), []);
const relativeTimeOptions = React.useMemo(() => {

View file

@ -6,6 +6,7 @@ function normalizeParsedDate(parsed?: chrono.ParsedComponents): Date | null {
return null;
}
// eslint-disable-next-line no-restricted-syntax
const now = new Date();
const parsedDate = parsed.date();
@ -58,6 +59,7 @@ export function parseTimeRangeInput(
? parsedTimeResults[0]
: parsedTimeResults[1];
const start = normalizeParsedDate(parsedTimeResult.start);
// eslint-disable-next-line no-restricted-syntax
const end = normalizeParsedDate(parsedTimeResult.end) || new Date();
if (end && start && end < start) {
// For date range strings that omit years, the chrono parser will infer the year

View file

@ -32,6 +32,10 @@ export const IS_LOCAL_MODE = //true;
export const IS_CLICKHOUSE_BUILD =
process.env.NEXT_PUBLIC_CLICKHOUSE_BUILD === 'true';
/** Time captured at module load, use this a stable fallback/default time value instead of Date.now() defined in each React component file */
// eslint-disable-next-line no-restricted-syntax
export const NOW = Date.now();
// Features in development
export const IS_K8S_DASHBOARD_ENABLED = true;
export const IS_METRICS_ENABLED = true;

View file

@ -5,6 +5,7 @@ import {
} from '@hyperdx/common-utils/dist/core/metadata';
import { BuilderChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
import { NOW } from '@/config';
import {
deduplicate2dArray,
useJsonColumns,
@ -19,9 +20,6 @@ export interface ILanguageFormatter {
formatKeyValPair: (key: string, value: string) => string;
}
// Defined outside of the component to fix rerenders
const NOW = Date.now();
export function useAutoCompleteOptions(
formatter: ILanguageFormatter,
value: string,
@ -118,10 +116,12 @@ export function useAutoCompleteOptions(
// just assuming 1/2 day is okay to query over right now
dateRange: [new Date(NOW - (86400 * 1000) / 2), new Date(NOW)],
}));
const { data: keyVals } = useMultipleGetKeyValues({
chartConfigs,
keys: searchKeys,
});
const keyValCompleteOptions = useMemo<
{ value: string; label: string }[]
>(() => {

View file

@ -28,6 +28,7 @@ export const useDashboardRefresh = ({
const timeDiff =
searchedTimeRange[1].getTime() - searchedTimeRange[0].getTime();
const timeDiffRoundedToSecond = Math.round(timeDiff / 1000) * 1000;
// eslint-disable-next-line no-restricted-syntax
const newEnd = new Date();
const newStart = new Date(newEnd.getTime() - timeDiffRoundedToSecond);
onTimeRangeSelect(newStart, newEnd);

View file

@ -51,6 +51,7 @@ function isInputTimeQueryLive(inputTimeQuery: string) {
}
export function parseRelativeTimeQuery(interval: number) {
// eslint-disable-next-line no-restricted-syntax
const end = startOfSecond(new Date());
return [subMilliseconds(end, interval), end];
}
@ -260,6 +261,7 @@ export function useTimeQuery({
) {
// If we haven't set a live tail time range yet, but we're ready and should be in live tail, let's just return one right now
// this is due to the first interval of live tail not kicking in until 2 seconds after our first render
// eslint-disable-next-line no-restricted-syntax
const end = startOfSecond(new Date());
const newLiveTailTimeRange: [Date, Date] = [
sub(end, { minutes: 15 }),
@ -269,6 +271,7 @@ export function useTimeQuery({
} else {
// We're not ready yet, safe to return anything.
// Downstream querying components need to be disabled on isReady
// eslint-disable-next-line no-restricted-syntax
return [new Date(), new Date()];
}
}, [
@ -292,6 +295,7 @@ export function useTimeQuery({
);
}, [isReady, isLiveEnabled, timeRangeQuery, inputTimeQuery]);
const refreshLiveTailTimeRange = () => {
// eslint-disable-next-line no-restricted-syntax
const end = startOfSecond(new Date());
setLiveTailTimeRange([sub(end, { minutes: 15 }), end]);
};
@ -529,6 +533,7 @@ export function useNewTimeQuery({
}
export function getLiveTailTimeRange(): [Date, Date] {
// eslint-disable-next-line no-restricted-syntax
const end = startOfSecond(new Date());
return [sub(end, { minutes: 15 }), end];
}

View file

@ -7,6 +7,7 @@ import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata';
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
import { SortingState } from '@tanstack/react-table';
import { NOW } from './config';
import { dateRangeToString } from './timeQuery';
import { MetricsDataType, NumberFormat } from './types';
@ -35,8 +36,8 @@ export function generateSearchUrl({
lineId?: string;
isUTC?: boolean;
}) {
const fromDate = dateRange ? dateRange[0] : new Date();
const toDate = dateRange ? dateRange[1] : new Date();
const fromDate = dateRange ? dateRange[0] : new Date(NOW);
const toDate = dateRange ? dateRange[1] : new Date(NOW);
const qparams = new URLSearchParams({
q: query ?? '',
from: fromDate.getTime().toString(),

View file

@ -34,6 +34,7 @@ export function intervalToMinutes(interval: AlertInterval): number {
}
export function intervalToDateRange(interval: AlertInterval): [Date, Date] {
// eslint-disable-next-line no-restricted-syntax
const now = new Date();
if (interval === '1m') return [sub(now, { minutes: 15 }), now];
if (interval === '5m') return [sub(now, { hours: 1 }), now];
@ -135,6 +136,7 @@ export const DEFAULT_TILE_ALERT: z.infer<typeof ChartAlertBaseSchema> = {
export function isAlertSilenceExpired(silenced?: {
until: string | Date;
}): boolean {
// eslint-disable-next-line no-restricted-syntax
return silenced ? new Date() > new Date(silenced.until) : false;
}