diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index d7776edad8a9..000000000000 --- a/.eslintignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -packages/config-eslint/ \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index 07f3803000fe..000000000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,13 +0,0 @@ -module.exports = { - root: true, - ignorePatterns: ["node_modules/", "dist/", "coverage/"], - overrides: [ - { - files: ["packages/cache/**/*.{ts,js}"], - extends: ["@formbricks/eslint-config/library.js"], - parserOptions: { - project: "./packages/cache/tsconfig.json", - }, - }, - ], -}; diff --git a/apps/storybook/.eslintrc.cjs b/apps/storybook/.eslintrc.cjs deleted file mode 100644 index f301e1e5d487..000000000000 --- a/apps/storybook/.eslintrc.cjs +++ /dev/null @@ -1,16 +0,0 @@ -module.exports = { - root: true, - env: { browser: true, es2020: true }, - extends: [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:react-hooks/recommended", - "plugin:storybook/recommended", - ], - ignorePatterns: ["dist", ".eslintrc.cjs"], - parser: "@typescript-eslint/parser", - plugins: ["react-refresh"], - rules: { - "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], - }, -}; diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts index 0b16b26f64fa..48b3c31e25a0 100644 --- a/apps/storybook/.storybook/main.ts +++ b/apps/storybook/.storybook/main.ts @@ -11,6 +11,7 @@ const __dirname = dirname(__filename); * This function is used to resolve the absolute path of a package. * It is needed in projects that use Yarn PnP or are set up within a monorepo. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Storybook boilerplate; the return value feeds differently-typed addon/framework fields (migration ENG-1677) function getAbsolutePath(value: string): any { return dirname(require.resolve(join(value, "package.json"))); } diff --git a/apps/storybook/eslint.config.mjs b/apps/storybook/eslint.config.mjs new file mode 100644 index 000000000000..31dbed2dceb6 --- /dev/null +++ b/apps/storybook/eslint.config.mjs @@ -0,0 +1,30 @@ +import js from "@eslint/js"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import storybook from "eslint-plugin-storybook"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default [ + { ignores: ["dist/**", "node_modules/**", "storybook-static/**"] }, + js.configs.recommended, + ...tseslint.configs.recommended, + // eslint-plugin-react-hooks v7 exposes flat configs under `configs.flat.*`; + // the top-level `recommended-latest` is the legacy (eslintrc) format and crashes ESLint 9. + reactHooks.configs.flat.recommended, + ...storybook.configs["flat/recommended"], + { + languageOptions: { + globals: { + ...globals.browser, + ...globals.es2020, + }, + }, + plugins: { + "react-refresh": reactRefresh, + }, + rules: { + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + }, + }, +]; diff --git a/apps/storybook/package.json b/apps/storybook/package.json index a1c94620d579..b53736d820db 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "lint": "eslint . --config .eslintrc.cjs --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0", + "lint": "eslint . --report-unused-disable-directives --max-warnings 0", "typecheck": "tsc --noEmit", "preview": "vite preview", "storybook": "storybook dev -p 6006", @@ -13,19 +13,21 @@ }, "devDependencies": { "@chromatic-com/storybook": "5.0.2", + "@eslint/js": "9.39.5", "@storybook/addon-a11y": "10.3.6", "@storybook/addon-docs": "10.3.6", "@storybook/addon-links": "10.3.6", "@storybook/addon-onboarding": "10.3.6", "@storybook/react-vite": "10.3.6", "@tailwindcss/vite": "4.2.4", - "@typescript-eslint/eslint-plugin": "8.57.2", - "@typescript-eslint/parser": "8.57.2", "@vitejs/plugin-react": "5.1.4", + "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.4.26", "eslint-plugin-storybook": "10.3.6", + "globals": "16.5.0", "storybook": "10.3.6", "typescript": "5.9.3", + "typescript-eslint": "8.63.0", "vite": "7.3.5" } } diff --git a/apps/web/.eslintignore b/apps/web/.eslintignore deleted file mode 100644 index e49cb573d5b6..000000000000 --- a/apps/web/.eslintignore +++ /dev/null @@ -1,7 +0,0 @@ -node_modules/ -.next/ -public/ -playwright/ -dist/ -coverage/ -vendor/ diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js deleted file mode 100644 index a114584fc8dc..000000000000 --- a/apps/web/.eslintrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - extends: ["@formbricks/eslint-config/legacy-next.js"], - ignorePatterns: ["**/package.json", "**/tsconfig.json"], -}; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx index 2657c752656f..f894cde1d592 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx @@ -246,6 +246,7 @@ export const MainNavigation = ({ const ts = new Date(trialEnd).getTime(); if (!Number.isFinite(ts)) return null; const msPerDay = 86_400_000; + // eslint-disable-next-line react-hooks/purity -- migration ENG-1677 return Math.ceil((ts - Date.now()) / msPerDay); }, [ isFormbricksCloud, diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/NotificationSwitch.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/NotificationSwitch.tsx index c227b3e93288..80a6b2217026 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/NotificationSwitch.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/NotificationSwitch.tsx @@ -114,7 +114,6 @@ export const NotificationSwitch = ({ break; } } - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AddIntegrationModal.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AddIntegrationModal.tsx index 2b3e6584a5c3..22e4c6f56c86 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AddIntegrationModal.tsx @@ -216,7 +216,6 @@ export const AddIntegrationModal = ({ } else { reset(); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [isEditMode]); const survey = watch("survey"); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/AddIntegrationModal.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/AddIntegrationModal.tsx index d074b379dd77..c979073e602b 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/AddIntegrationModal.tsx @@ -110,7 +110,6 @@ export const AddIntegrationModal = ({ type: dbProperties[fieldKey].type, })) || [] ); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedDatabase?.id]); const elementItems = useMemo(() => { @@ -156,7 +155,6 @@ export const AddIntegrationModal = ({ })); return [...mappedElements, ...variables, ...hiddenFields, ...Metadata, ...createdAt, ...personAttributes]; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedSurvey?.id, contactAttributeKeys]); useEffect(() => { diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/MappingRow.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/MappingRow.tsx index d14f38fdcdb0..4670d4f50799 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/MappingRow.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/MappingRow.tsx @@ -73,7 +73,6 @@ const MappingErrorMessage = ({ default: return null; } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [error, col, elem, t]); if (!error) return null; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx index 956765bf31d5..579e2f4ff621 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx @@ -51,7 +51,6 @@ export const ResponsePage = ({ const filters = useMemo( () => getFormattedFilters(survey, selectedFilter, dateRange), - // eslint-disable-next-line react-hooks/exhaustive-deps [selectedFilter, dateRange] ); @@ -163,7 +162,6 @@ export const ResponsePage = ({ }; fetchFilteredResponses(); // page is intentionally omitted to avoid refetching after the initial page setup. - // eslint-disable-next-line react-hooks/exhaustive-deps }, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]); return ( diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/CustomFilter.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/CustomFilter.tsx index 156ea86603ea..24ff71ac4d26 100755 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/CustomFilter.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/CustomFilter.tsx @@ -158,7 +158,6 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => { const filters = useMemo( () => getFormattedFilters(survey, selectedFilter, dateRange), - // eslint-disable-next-line react-hooks/exhaustive-deps [selectedFilter, dateRange] ); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/ElementFilterComboBox.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/ElementFilterComboBox.tsx index 3d66fe9110fe..cf6a90aca1f9 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/ElementFilterComboBox.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/ElementFilterComboBox.tsx @@ -252,7 +252,6 @@ export const ElementFilterComboBox = ({ /> ) : ( - {/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role */}
- {/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role */}
{ // session cookie → Next.js route refresh → new `survey` reference; depending on it here would re-fire // getSurveyFilterDataAction on every refresh while the popover is open, i.e. an infinite loop. const surveyRef = useRef(survey); + // eslint-disable-next-line react-hooks/refs -- intentional latest-value ref (see above); the effect reads surveyRef.current, never renders from it surveyRef.current = survey; useEffect(() => { @@ -160,7 +161,6 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => { if (!isOpen) { clearItem(); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]); const handleAddNewFilter = () => { diff --git a/apps/web/app/api/v1/auth.test.ts b/apps/web/app/api/v1/auth.test.ts index 429f3748b636..4c5a20bf08b8 100644 --- a/apps/web/app/api/v1/auth.test.ts +++ b/apps/web/app/api/v1/auth.test.ts @@ -292,7 +292,9 @@ describe("handleErrorResponse", () => { }); test("returns 409 conflict for UniqueConstraintError", async () => { - const { response } = handleErrorResponse(new UniqueConstraintError("Action with name foo already exists")); + const { response } = handleErrorResponse( + new UniqueConstraintError("Action with name foo already exists") + ); expect(response.status).toBe(409); const body = await response.json(); expect(body.code).toBe("conflict"); diff --git a/apps/web/app/api/v3/feedbackRecords/[[...path]]/route.ts b/apps/web/app/api/v3/feedbackRecords/[[...path]]/route.ts new file mode 100644 index 000000000000..32604af87b9b --- /dev/null +++ b/apps/web/app/api/v3/feedbackRecords/[[...path]]/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; +import { proxyFeedbackRecordsRequest } from "@/modules/hub/feedback-records-proxy"; + +const handler = async (request: NextRequest): Promise => { + return await proxyFeedbackRecordsRequest(request); +}; + +export const GET = handler; +export const POST = handler; +export const PUT = handler; +export const PATCH = handler; +export const DELETE = handler; +export const HEAD = handler; +export const OPTIONS = handler; diff --git a/apps/web/app/sentry/SentryProvider.tsx b/apps/web/app/sentry/SentryProvider.tsx index 1d2b5dbb5684..ddfe51fccc03 100644 --- a/apps/web/app/sentry/SentryProvider.tsx +++ b/apps/web/app/sentry/SentryProvider.tsx @@ -63,7 +63,6 @@ export const SentryProvider = ({ }); } // We only want to run this once - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return <>{children}; diff --git a/apps/web/app/v1/feedback-records/[[...path]]/route.ts b/apps/web/app/v1/feedback-records/[[...path]]/route.ts new file mode 100644 index 000000000000..32604af87b9b --- /dev/null +++ b/apps/web/app/v1/feedback-records/[[...path]]/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; +import { proxyFeedbackRecordsRequest } from "@/modules/hub/feedback-records-proxy"; + +const handler = async (request: NextRequest): Promise => { + return await proxyFeedbackRecordsRequest(request); +}; + +export const GET = handler; +export const POST = handler; +export const PUT = handler; +export const PATCH = handler; +export const DELETE = handler; +export const HEAD = handler; +export const OPTIONS = handler; diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs new file mode 100644 index 000000000000..d1ca21334ab6 --- /dev/null +++ b/apps/web/eslint.config.mjs @@ -0,0 +1,33 @@ +import next from "@formbricks/eslint-config/next"; + +const config = [ + // carried over from the legacy .eslintignore / ignorePatterns + { + ignores: [ + ".next/**", + "public/**", + "playwright/**", + "vendor/**", + "**/package.json", + "**/tsconfig.json", + ], + }, + ...next, + { + rules: { + // runtime-only env read in integration/gen-boolean-client.mjs; hashing it in turbo.json is tracked separately (ENG-1682) + "turbo/no-undeclared-env-vars": ["error", { allowList: ["PATH"] }], + // TODO(ENG-1677): enable incrementally — pre-existing violations from the React Compiler-era react-hooks rules + "react-hooks/set-state-in-effect": "off", + "react-hooks/incompatible-library": "off", + "react-hooks/error-boundaries": "off", + "react-hooks/immutability": "off", + "react-hooks/preserve-manual-memoization": "off", + // Kept as a warning (not off): exhaustive-deps is the main guard against stale closures, and the + // web lint script has no `--max-warnings 0`, so it surfaces violations without blocking (ENG-1677). + "react-hooks/exhaustive-deps": "warn", + }, + }, +]; + +export default config; diff --git a/apps/web/lib/useDocumentVisibility.ts b/apps/web/lib/useDocumentVisibility.ts index 44729f44e65b..9254cf079ca2 100644 --- a/apps/web/lib/useDocumentVisibility.ts +++ b/apps/web/lib/useDocumentVisibility.ts @@ -14,6 +14,5 @@ export const useDocumentVisibility = (onVisible: () => void) => { return () => { document.removeEventListener("visibilitychange", listener); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); }; diff --git a/apps/web/lib/utils/action-client/index.ts b/apps/web/lib/utils/action-client/index.ts index 530b3c467947..e71096ac58c5 100644 --- a/apps/web/lib/utils/action-client/index.ts +++ b/apps/web/lib/utils/action-client/index.ts @@ -25,7 +25,6 @@ export const actionClient = createSafeActionClient({ }, }); - // eslint-disable-next-line no-console -- This error needs to be logged for debugging server-side errors logger.withContext({ eventId }).error(e, "SERVER ERROR"); return DEFAULT_SERVER_ERROR_MESSAGE; }, diff --git a/apps/web/modules/analysis/components/RatingSmiley/index.tsx b/apps/web/modules/analysis/components/RatingSmiley/index.tsx index 69cbb3b78133..11214fafdad7 100644 --- a/apps/web/modules/analysis/components/RatingSmiley/index.tsx +++ b/apps/web/modules/analysis/components/RatingSmiley/index.tsx @@ -67,6 +67,7 @@ const getSmiley = ({ const containerSize = size * 2; const icon = ( + // eslint-disable-next-line @next/next/no-img-element -- migration ENG-1677 { console.log("🟢 Rate Limiter Load Tests: Redis available - tests will run"); // Clear any existing test keys - // eslint-disable-next-line @typescript-eslint/await-thenable + const redis = await cache.getRedisClient(); if (redis) { const testKeys = await redis.keys("fb:rate_limit:test:*"); @@ -166,7 +165,6 @@ describe("Rate Limiter Load Tests - Race Conditions", () => { return; } - // eslint-disable-next-line @typescript-eslint/await-thenable const redis = await cache.getRedisClient(); if (redis) { const testKeys = await redis.keys("fb:rate_limit:test:*"); @@ -329,7 +327,7 @@ describe("Rate Limiter Load Tests - Race Conditions", () => { const identifier = "stress-test"; // Clear any existing keys first to ensure clean state - // eslint-disable-next-line @typescript-eslint/await-thenable + const redis = await cache.getRedisClient(); if (redis) { const existingKeys = await redis.keys(`fb:rate_limit:${config.namespace}:*`); @@ -458,7 +456,7 @@ describe("Rate Limiter Load Tests - Race Conditions", () => { const identifier = "ttl-test-user"; // Clear any existing keys first - // eslint-disable-next-line @typescript-eslint/await-thenable + const redis = await cache.getRedisClient(); if (redis) { const existingKeys = await redis.keys(`fb:rate_limit:${config.namespace}:*`); diff --git a/apps/web/modules/ee/analysis/charts/components/chart-row.tsx b/apps/web/modules/ee/analysis/charts/components/chart-row.tsx index 1a6eb6554445..2493a94910cb 100644 --- a/apps/web/modules/ee/analysis/charts/components/chart-row.tsx +++ b/apps/web/modules/ee/analysis/charts/components/chart-row.tsx @@ -37,7 +37,6 @@ export function ChartRow({ chart, workspaceId, isReadOnly, directories }: Readon return ( <> {/* Cannot use native