From e54641b01c838a57c9794807e76e0fc893f491a9 Mon Sep 17 00:00:00 2001 From: Bhagya Amarasinghe Date: Mon, 27 Jul 2026 16:16:25 +0530 Subject: [PATCH 1/3] fix: proxy feedback records in local development (#8646) --- .../v3/feedbackRecords/[[...path]]/route.ts | 14 ++ .../v1/feedback-records/[[...path]]/route.ts | 14 ++ .../modules/hub/feedback-records-gateway.ts | 29 +-- .../hub/feedback-records-proxy.test.ts | 237 ++++++++++++++++++ .../web/modules/hub/feedback-records-proxy.ts | 103 ++++++++ .../hub/feedback-records-routing.test.ts | 37 +++ .../modules/hub/feedback-records-routing.ts | 36 +++ docker/README.md | 6 + 8 files changed, 448 insertions(+), 28 deletions(-) create mode 100644 apps/web/app/api/v3/feedbackRecords/[[...path]]/route.ts create mode 100644 apps/web/app/v1/feedback-records/[[...path]]/route.ts create mode 100644 apps/web/modules/hub/feedback-records-proxy.test.ts create mode 100644 apps/web/modules/hub/feedback-records-proxy.ts create mode 100644 apps/web/modules/hub/feedback-records-routing.test.ts create mode 100644 apps/web/modules/hub/feedback-records-routing.ts 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/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/modules/hub/feedback-records-gateway.ts b/apps/web/modules/hub/feedback-records-gateway.ts index e8feb91ddbdc..6eb18b7e110e 100644 --- a/apps/web/modules/hub/feedback-records-gateway.ts +++ b/apps/web/modules/hub/feedback-records-gateway.ts @@ -18,10 +18,9 @@ import { allowGatewayRequest, buildGatewayStatusResponse, } from "@/modules/gateway-auth/lib/request"; +import { normalizeFeedbackRecordsPath } from "@/modules/hub/feedback-records-routing"; import { getFeedbackRecordTenant } from "@/modules/hub/service"; -const FEEDBACK_RECORDS_V3_PREFIX = "/api/v3/feedbackRecords"; -const FEEDBACK_RECORDS_SDK_PREFIX = "/v1/feedback-records"; const ZFeedbackRecordId = z.uuid(); type TFeedbackRecordsGatewayPermission = "read" | "write"; @@ -53,32 +52,6 @@ const gatewayPermissionToApiKeyPermissionWeight: Record { - if (pathname === prefix) { - return "/"; - } - - if (!pathname.startsWith(`${prefix}/`)) { - return null; - } - - return pathname.slice(prefix.length) || "/"; -}; - -const normalizeFeedbackRecordsPath = (pathname: string): string | null => { - const v3Path = stripFeedbackRecordsPrefix(pathname, FEEDBACK_RECORDS_V3_PREFIX); - if (v3Path) { - return v3Path; - } - - const sdkPath = stripFeedbackRecordsPrefix(pathname, FEEDBACK_RECORDS_SDK_PREFIX); - if (sdkPath) { - return sdkPath; - } - - return null; -}; - const parseFeedbackRecordsGatewayRoute = (method: string, pathname: string): TParsedGatewayRoute | null => { const normalizedPath = normalizeFeedbackRecordsPath(pathname); if (!normalizedPath) { diff --git a/apps/web/modules/hub/feedback-records-proxy.test.ts b/apps/web/modules/hub/feedback-records-proxy.test.ts new file mode 100644 index 000000000000..089e45b8fd95 --- /dev/null +++ b/apps/web/modules/hub/feedback-records-proxy.test.ts @@ -0,0 +1,237 @@ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { proxyFeedbackRecordsRequest } from "@/modules/hub/feedback-records-proxy"; + +const { mockAuthorizeGatewayRequest, mockLoggerError, runtime } = vi.hoisted(() => ({ + mockAuthorizeGatewayRequest: vi.fn(), + mockLoggerError: vi.fn(), + runtime: { + isProduction: false, + }, +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + error: mockLoggerError, + }, +})); + +vi.mock("@/lib/constants", () => ({ + HUB_API_KEY: "hub-api-key", + HUB_API_URL: "https://hub.test", + get IS_PRODUCTION() { + return runtime.isProduction; + }, +})); + +vi.mock("@/modules/gateway-auth/lib/request", () => ({ + authorizeGatewayRequest: mockAuthorizeGatewayRequest, +})); + +vi.mock("@/modules/hub/feedback-records-gateway", () => ({ + feedbackRecordsGatewayAuthorizer: { + authorize: vi.fn(), + matches: vi.fn(), + }, +})); + +describe("proxyFeedbackRecordsRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + runtime.isProduction = false; + mockAuthorizeGatewayRequest.mockResolvedValue(new Response(null, { status: 200 })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test.each([ + [ + "http://localhost:3000/api/v3/feedbackRecords?tenant_id=dir_1&limit=25", + "https://hub.test/v1/feedback-records?tenant_id=dir_1&limit=25", + ], + [ + "http://localhost:3000/api/v3/feedbackRecords/record_1/similar?limit=3", + "https://hub.test/v1/feedback-records/record_1/similar?limit=3", + ], + [ + "http://localhost:3000/v1/feedback-records?tenant_id=dir_1&limit=25", + "https://hub.test/v1/feedback-records?tenant_id=dir_1&limit=25", + ], + [ + "http://localhost:3000/v1/feedback-records/record_1?include=fields", + "https://hub.test/v1/feedback-records/record_1?include=fields", + ], + ])("proxies %s to %s", async (requestUrl, expectedHubUrl) => { + const fetchMock = vi.fn().mockResolvedValue(Response.json({ data: [] })); + vi.stubGlobal("fetch", fetchMock); + + await proxyFeedbackRecordsRequest(new NextRequest(requestUrl)); + + const hubRequest = fetchMock.mock.calls[0][0] as Request; + expect(hubRequest.url).toBe(expectedHubUrl); + expect(fetchMock).toHaveBeenCalledWith(hubRequest, { cache: "no-store" }); + }); + + test("authorizes a cloned request before forwarding the original body", async () => { + const body = JSON.stringify({ tenant_id: "dir_1", text: "Feedback" }); + mockAuthorizeGatewayRequest.mockImplementationOnce(async ({ request }: { request: NextRequest }) => { + expect(await request.text()).toBe(body); + return new Response(null, { status: 200 }); + }); + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 201 })); + vi.stubGlobal("fetch", fetchMock); + + await proxyFeedbackRecordsRequest( + new NextRequest("http://localhost:3000/api/v3/feedbackRecords", { + method: "POST", + body, + headers: { + "content-type": "application/json", + "x-request-id": "request_1", + }, + }) + ); + + const authorizationInput = mockAuthorizeGatewayRequest.mock.calls[0][0]; + expect(authorizationInput.originalRequest).toEqual({ + method: "POST", + url: new URL("http://localhost:3000/api/v3/feedbackRecords"), + }); + expect(authorizationInput.requestId).toBe("request_1"); + + const hubRequest = fetchMock.mock.calls[0][0] as Request; + expect(hubRequest.method).toBe("POST"); + expect(hubRequest.headers.get("content-type")).toBe("application/json"); + expect(await hubRequest.text()).toBe(body); + }); + + test("replaces client credentials with the internal Hub credential", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetchMock); + + await proxyFeedbackRecordsRequest( + new NextRequest("http://localhost:3000/v1/feedback-records?tenant_id=dir_1", { + headers: { + authorization: "Bearer client-token", + connection: "keep-alive, X-Client-Context", + cookie: "session=secret", + host: "localhost:3000", + "x-api-key": "fbk_client-key", + "x-client-context": "sensitive-client-context", + }, + }) + ); + + const hubRequest = fetchMock.mock.calls[0][0] as Request; + expect(hubRequest.headers.get("authorization")).toBe("Bearer hub-api-key"); + expect(hubRequest.headers.has("cookie")).toBe(false); + expect(hubRequest.headers.has("x-api-key")).toBe(false); + expect(hubRequest.headers.has("connection")).toBe(false); + expect(hubRequest.headers.has("host")).toBe(false); + expect(hubRequest.headers.has("x-client-context")).toBe(false); + }); + + test("passes the Hub response through unchanged", async () => { + const hubResponse = new Response("created", { + status: 201, + headers: { + "content-type": "application/json", + "x-hub-request-id": "hub_request_1", + }, + }); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(hubResponse)); + + const response = await proxyFeedbackRecordsRequest( + new NextRequest("http://localhost:3000/api/v3/feedbackRecords?tenant_id=dir_1") + ); + + expect(response).toBe(hubResponse); + expect(response.status).toBe(201); + expect(response.headers.get("x-hub-request-id")).toBe("hub_request_1"); + expect(await response.text()).toBe("created"); + }); + + test("returns the authorization response without calling Hub", async () => { + const authorizationResponse = new Response("Forbidden", { status: 403 }); + mockAuthorizeGatewayRequest.mockResolvedValueOnce(authorizationResponse); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const response = await proxyFeedbackRecordsRequest( + new NextRequest("http://localhost:3000/v1/feedback-records?tenant_id=dir_1") + ); + + expect(response).toBe(authorizationResponse); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("returns the authorizer response for an unsupported operation", async () => { + mockAuthorizeGatewayRequest.mockResolvedValueOnce( + new Response("Unsupported FeedbackRecords route", { status: 400 }) + ); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const response = await proxyFeedbackRecordsRequest( + new NextRequest("http://localhost:3000/api/v3/feedbackRecords?tenant_id=dir_1", { + method: "PUT", + }) + ); + + expect(response.status).toBe(400); + expect(await response.text()).toBe("Unsupported FeedbackRecords route"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("rejects requests outside the FeedbackRecords route prefixes", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const response = await proxyFeedbackRecordsRequest( + new NextRequest("http://localhost:3000/api/v3/feedbackRecordsFoo") + ); + + expect(response.status).toBe(400); + expect(mockAuthorizeGatewayRequest).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("returns a sanitized bad gateway response when Hub is unavailable", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("connect ECONNREFUSED secret-url"))); + + const response = await proxyFeedbackRecordsRequest( + new NextRequest("http://localhost:3000/api/v3/feedbackRecords?tenant_id=dir_1", { + headers: { + "x-request-id": "request_1", + }, + }) + ); + + expect(response.status).toBe(502); + expect(await response.text()).toBe("Bad Gateway"); + expect(mockLoggerError).toHaveBeenCalledWith( + { + requestId: "request_1", + method: "GET", + pathname: "/api/v3/feedbackRecords", + }, + "Feedback records local proxy request failed" + ); + }); + + test("is unavailable in production", async () => { + runtime.isProduction = true; + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const response = await proxyFeedbackRecordsRequest( + new NextRequest("http://localhost:3000/api/v3/feedbackRecords?tenant_id=dir_1") + ); + + expect(response.status).toBe(404); + expect(mockAuthorizeGatewayRequest).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/hub/feedback-records-proxy.ts b/apps/web/modules/hub/feedback-records-proxy.ts new file mode 100644 index 000000000000..c9d525bee0bf --- /dev/null +++ b/apps/web/modules/hub/feedback-records-proxy.ts @@ -0,0 +1,103 @@ +import "server-only"; +import { NextRequest } from "next/server"; +import { logger } from "@formbricks/logger"; +import { HUB_API_KEY, HUB_API_URL, IS_PRODUCTION } from "@/lib/constants"; +import { authorizeGatewayRequest } from "@/modules/gateway-auth/lib/request"; +import { feedbackRecordsGatewayAuthorizer } from "@/modules/hub/feedback-records-gateway"; +import { getFeedbackRecordsHubPathname } from "@/modules/hub/feedback-records-routing"; + +const FORWARDED_CREDENTIAL_HEADERS = ["authorization", "cookie", "x-api-key"] as const; +const HOP_BY_HOP_REQUEST_HEADERS = [ + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +] as const; +const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +const buildHubRequestUrl = (requestUrl: URL): URL | null => { + const hubPathname = getFeedbackRecordsHubPathname(requestUrl.pathname); + if (!hubPathname) { + return null; + } + + const hubUrl = new URL(HUB_API_URL); + hubUrl.pathname = hubPathname; + hubUrl.search = requestUrl.search; + hubUrl.hash = ""; + + return hubUrl; +}; + +const buildHubRequest = (request: NextRequest, hubUrl: URL): Request => { + const hubRequest = new Request(hubUrl, request); + const connectionHeaders = (request.headers.get("connection") ?? "") + .split(",") + .map((header) => header.trim().toLowerCase()) + .filter((header) => HEADER_NAME_PATTERN.test(header)); + const headersToRemove = new Set([ + ...FORWARDED_CREDENTIAL_HEADERS, + ...HOP_BY_HOP_REQUEST_HEADERS, + ...connectionHeaders, + ]); + + for (const header of headersToRemove) { + hubRequest.headers.delete(header); + } + + hubRequest.headers.set("authorization", `Bearer ${HUB_API_KEY}`); + + return hubRequest; +}; + +const buildAllowResponse = (): Response => new Response(null, { status: 200 }); + +export const proxyFeedbackRecordsRequest = async (request: NextRequest): Promise => { + if (IS_PRODUCTION) { + return new Response(null, { status: 404 }); + } + + const originalUrl = new URL(request.url); + const requestId = request.headers.get("x-request-id") ?? "unknown"; + const hubUrl = buildHubRequestUrl(originalUrl); + if (!hubUrl) { + return new Response("Unsupported FeedbackRecords proxy route", { status: 400 }); + } + + const authorizationResponse = await authorizeGatewayRequest({ + request: new NextRequest(request.clone()), + originalRequest: { + method: request.method.toUpperCase(), + url: originalUrl, + }, + authorizers: [feedbackRecordsGatewayAuthorizer], + requestId, + buildAllowResponse, + unsupportedRouteMessage: "Unsupported FeedbackRecords proxy route", + }); + + if (!authorizationResponse.ok) { + return authorizationResponse; + } + + try { + return await fetch(buildHubRequest(request, hubUrl), { cache: "no-store" }); + } catch { + logger.error( + { + requestId, + method: request.method, + pathname: originalUrl.pathname, + }, + "Feedback records local proxy request failed" + ); + + return new Response("Bad Gateway", { status: 502 }); + } +}; diff --git a/apps/web/modules/hub/feedback-records-routing.test.ts b/apps/web/modules/hub/feedback-records-routing.test.ts new file mode 100644 index 000000000000..934e7294c8af --- /dev/null +++ b/apps/web/modules/hub/feedback-records-routing.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "vitest"; +import { + getFeedbackRecordsHubPathname, + normalizeFeedbackRecordsPath, +} from "@/modules/hub/feedback-records-routing"; + +describe("feedback records routing", () => { + test.each([ + ["/api/v3/feedbackRecords", "/"], + ["/api/v3/feedbackRecords/", "/"], + ["/api/v3/feedbackRecords/record_1", "/record_1"], + ["/v1/feedback-records", "/"], + ["/v1/feedback-records/search/semantic", "/search/semantic"], + ])("normalizes %s", (pathname, expected) => { + expect(normalizeFeedbackRecordsPath(pathname)).toBe(expected); + }); + + test.each([ + ["/api/v3/feedbackRecords", "/v1/feedback-records"], + ["/api/v3/feedbackRecords/", "/v1/feedback-records/"], + ["/api/v3/feedbackRecords/record_1/similar", "/v1/feedback-records/record_1/similar"], + ["/v1/feedback-records", "/v1/feedback-records"], + ["/v1/feedback-records/record_1", "/v1/feedback-records/record_1"], + ])("maps %s to the Hub pathname", (pathname, expected) => { + expect(getFeedbackRecordsHubPathname(pathname)).toBe(expected); + }); + + test.each([ + "/api/v3/feedbackRecordsFoo", + "/v1/feedback-records-other", + "/api/v3/other", + "/feedback-records", + ])("does not match %s", (pathname) => { + expect(normalizeFeedbackRecordsPath(pathname)).toBeNull(); + expect(getFeedbackRecordsHubPathname(pathname)).toBeNull(); + }); +}); diff --git a/apps/web/modules/hub/feedback-records-routing.ts b/apps/web/modules/hub/feedback-records-routing.ts new file mode 100644 index 000000000000..463d25e8b1e3 --- /dev/null +++ b/apps/web/modules/hub/feedback-records-routing.ts @@ -0,0 +1,36 @@ +export const FEEDBACK_RECORDS_V3_PREFIX = "/api/v3/feedbackRecords"; +export const FEEDBACK_RECORDS_SDK_PREFIX = "/v1/feedback-records"; + +const stripFeedbackRecordsPrefix = (pathname: string, prefix: string): string | null => { + if (pathname === prefix) { + return "/"; + } + + if (!pathname.startsWith(`${prefix}/`)) { + return null; + } + + return pathname.slice(prefix.length) || "/"; +}; + +export const normalizeFeedbackRecordsPath = (pathname: string): string | null => { + const v3Path = stripFeedbackRecordsPrefix(pathname, FEEDBACK_RECORDS_V3_PREFIX); + if (v3Path) { + return v3Path; + } + + const sdkPath = stripFeedbackRecordsPrefix(pathname, FEEDBACK_RECORDS_SDK_PREFIX); + if (sdkPath) { + return sdkPath; + } + + return null; +}; + +export const getFeedbackRecordsHubPathname = (pathname: string): string | null => { + if (stripFeedbackRecordsPrefix(pathname, FEEDBACK_RECORDS_V3_PREFIX) !== null) { + return `${FEEDBACK_RECORDS_SDK_PREFIX}${pathname.slice(FEEDBACK_RECORDS_V3_PREFIX.length)}`; + } + + return stripFeedbackRecordsPrefix(pathname, FEEDBACK_RECORDS_SDK_PREFIX) !== null ? pathname : null; +}; diff --git a/docker/README.md b/docker/README.md index 610e6e0854cb..945d0f7fef28 100644 --- a/docker/README.md +++ b/docker/README.md @@ -152,3 +152,9 @@ The one-click Traefik installer exposes Hub-backed FeedbackRecords on the Formbr `/api/v3/feedbackRecords` and `/v1/feedback-records`. Traefik uses Formbricks gateway auth, rewrites the v3 path to Hub's `/v1/feedback-records`, injects `Authorization: Bearer ${HUB_API_KEY}` for Hub, and strips client API key/cookie headers before the Hub hop. + +The local development server exposes the same routes at `http://localhost:3000`. With `HUB_API_URL` and +`HUB_API_KEY` configured as shown in `.env.example`, the Next.js app applies the same Formbricks gateway +authorization, rewrites `/api/v3/feedbackRecords` to Hub's `/v1/feedback-records`, replaces client credentials +with the Hub API key, and proxies the request to the Hub service running on port **8080**. This app-level proxy +is development-only; production deployments continue to use Traefik or Envoy for FeedbackRecords routing. From 6848be6cceeb8f66f2cf2e3e1355b7adf54cd958 Mon Sep 17 00:00:00 2001 From: Matti Nannt <675065+mattinannt@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:29:05 +0200 Subject: [PATCH 2/3] fix(deps): bump brace-expansion@5 to 5.0.8 and align postcss pins (#8641) Co-authored-by: Claude Co-authored-by: pandeymangg --- apps/web/package.json | 2 +- packages/email/package.json | 2 +- packages/surveys/package.json | 2 +- packages/vite-plugins/package.json | 2 +- pnpm-lock.yaml | 28 ++++++++-------------------- pnpm-workspace.yaml | 19 +++++++++++-------- 6 files changed, 23 insertions(+), 32 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 6aaf98ee84f8..468c42e6a938 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -184,7 +184,7 @@ "dotenv-cli": "11.0.0", "playwright": "1.58.2", "jsdom": "29.1.1", - "postcss": "8.5.14", + "postcss": "8.5.18", "resize-observer-polyfill": "1.5.1", "storybook": "10.3.6", "tailwindcss-animate": "1.0.7", diff --git a/packages/email/package.json b/packages/email/package.json index 42526284c00e..1bc784c33f78 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -27,7 +27,7 @@ "@types/react": "19.2.14", "autoprefixer": "10.4.27", "clsx": "2.1.1", - "postcss": "8.5.14", + "postcss": "8.5.18", "tailwind-merge": "3.5.0", "tailwindcss": "3.4.19", "typescript": "5.9.3" diff --git a/packages/surveys/package.json b/packages/surveys/package.json index 4d056858e811..0594f366a1f0 100644 --- a/packages/surveys/package.json +++ b/packages/surveys/package.json @@ -69,7 +69,7 @@ "dotenv-cli": "11.0.0", "fake-indexeddb": "6.2.5", "happy-dom": "20.8.9", - "postcss": "8.5.14", + "postcss": "8.5.18", "rollup-plugin-visualizer": "7.0.1", "tailwindcss": "4.2.4", "typescript": "5.9.3", diff --git a/packages/vite-plugins/package.json b/packages/vite-plugins/package.json index 8b356fc00402..3ce2b7762bcb 100644 --- a/packages/vite-plugins/package.json +++ b/packages/vite-plugins/package.json @@ -20,7 +20,7 @@ "devDependencies": { "@formbricks/config-typescript": "workspace:*", "@formbricks/eslint-config": "workspace:*", - "postcss": "8.5.14", + "postcss": "8.5.18", "typescript": "5.9.3", "vite": "7.3.5", "vitest": "4.1.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc058177ad62..060330686510 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,7 +39,7 @@ overrides: engine.io@6: 6.6.7 brace-expansion@1: 1.1.16 brace-expansion@2: 2.1.2 - brace-expansion@5: 5.0.7 + brace-expansion@5: 5.0.8 fast-uri@3: 3.1.4 linkify-it: 5.0.2 '@opentelemetry/propagator-jaeger': 2.9.0 @@ -2101,15 +2101,9 @@ packages: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - '@better-auth/utils@0.4.1': - resolution: {integrity: sha512-SZBPRPF3z0nBvE5ygOkxae35wnnXPRShmqFo78S+qslLeFoPu/pMgnXAuNKFMMybac3tiLaVg1e3MQW5MC+1iA==} - '@better-auth/utils@0.4.2': resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} - '@better-fetch/fetch@1.3.0': - resolution: {integrity: sha512-Lgkl18IrUURFqa1nE38GNDWXf7XGzfxXQDCE/alCQV0yZ98YrPGtlmW61ch1T4YRt3lxrSAGA+Ft73FzuWWb3A==} - '@better-fetch/fetch@1.3.1': resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} @@ -7196,9 +7190,9 @@ packages: brace-expansion@2.1.2: resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.8: + resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -14160,16 +14154,10 @@ snapshots: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - '@better-auth/utils@0.4.1': - dependencies: - '@noble/hashes': 2.0.1 - '@better-auth/utils@0.4.2': dependencies: '@noble/hashes': 2.0.1 - '@better-fetch/fetch@1.3.0': {} - '@better-fetch/fetch@1.3.1': {} '@boxyhq/error-code-mnemonic@0.1.1': {} @@ -19948,8 +19936,8 @@ snapshots: better-call@1.3.7(zod@4.3.6): dependencies: - '@better-auth/utils': 0.4.1 - '@better-fetch/fetch': 1.3.0 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 rou3: 0.7.12 set-cookie-parser: 3.1.0 optionalDependencies: @@ -20019,7 +20007,7 @@ snapshots: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.7: + brace-expansion@5.0.8: dependencies: balanced-match: 4.0.4 @@ -22720,7 +22708,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.8 minimatch@3.1.5: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cfd0051a9296..f48c200c5207 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -115,16 +115,19 @@ overrides: engine.io@6: 6.6.7 # ENG-1955 / ENG-1954 / ENG-1953 / GHSA-3jxr-9vmj-r5cp (CVE-2026-13149): brace-expansion expand() # exhibits O(2ⁿ) DoS on consecutive non-expanding {} groups. Three affected major lines are present in - # the tree; each is pinned to its lowest patched release: 1.x→1.1.16, 2.x→2.1.2, 3.x–5.x→5.0.7. + # the tree; each is pinned to its lowest patched release: 1.x→1.1.16, 2.x→2.1.2, 3.x–5.x→5.0.8. brace-expansion@1: 1.1.16 brace-expansion@2: 2.1.2 - # GHSA-mh99-v99m-4gvg: brace-expansion@5 <=5.0.7 DoS via unbounded expansion length causing - # an out-of-memory crash; patched in 5.0.8 (published 2026-07-23T11:39 UTC). NOT yet bumped: 5.0.8 is - # still inside the minimumReleaseAge (4320min/3-day) cooldown, which clears 2026-07-26T11:39 UTC. Only - # transitive dev-tooling path affected (@redocly/cli openapi tooling via minimatch), not a runtime web - # dependency. Bump this pin to 5.0.8 once the cooldown clears — do not add a minimumReleaseAgeExclude - # entry for this without going through the dependency-cooldown exception process first. - brace-expansion@5: 5.0.7 + # GHSA-mh99-v99m-4gvg: brace-expansion@5 <=5.0.7 DoS via unbounded expansion length causing an + # out-of-memory crash; patched in 5.0.8 (published 2026-07-23T11:39 UTC). The minimumReleaseAge + # (4320min/3-day) cooldown cleared on 2026-07-26T11:39 UTC, so this bumps on the normal policy — no + # minimumReleaseAgeExclude entry and no cooldown exception needed. + # `pnpm audit` still reports this advisory as high afterwards, expectedly: its range is `<=5.0.7`, so + # the 1.x and 2.x pins above match it too, and 1.1.16 / 2.1.2 are each the latest release in their + # line with no patch coming. Those paths are devDependency-only (eslint, prettier plugins and + # @redocly/cli, all via minimatch) and never run against untrusted input, so the residual risk is + # accepted until their consumers move to brace-expansion@5. + brace-expansion@5: 5.0.8 # ENG-1994 / GHSA-4c8g-83qw-93j6 (CVE-2026-13676) + ENG-1988 / GHSA-v2hh-gcrm-f6hx (CVE-2026-16221): # fast-uri 3.x host confusion via failed IDN canonicalization and via literal-backslash authority # delimiter. 3.1.4 patches both (3.1.3 fixes only the first). Scoped to the 3.x line. From 5704833d963fa6fbffab109c4e77195361abf255 Mon Sep 17 00:00:00 2001 From: Matti Nannt <675065+mattinannt@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:49:50 +0200 Subject: [PATCH 3/3] chore(eslint): migrate to ESLint 9 flat config (#8505) Co-authored-by: Claude Fable 5 Co-authored-by: pandeymangg --- .eslintignore | 2 - .eslintrc.cjs | 13 - apps/storybook/.eslintrc.cjs | 16 - apps/storybook/.storybook/main.ts | 1 + apps/storybook/eslint.config.mjs | 30 + apps/storybook/package.json | 8 +- apps/web/.eslintignore | 7 - apps/web/.eslintrc.js | 4 - .../components/MainNavigation.tsx | 1 + .../components/NotificationSwitch.tsx | 1 - .../components/AddIntegrationModal.tsx | 1 - .../notion/components/AddIntegrationModal.tsx | 2 - .../notion/components/MappingRow.tsx | 1 - .../responses/components/ResponsePage.tsx | 2 - .../[surveyId]/components/CustomFilter.tsx | 1 - .../components/ElementFilterComboBox.tsx | 1 - .../components/ElementsComboBox.tsx | 1 - .../[surveyId]/components/ResponseFilter.tsx | 2 +- apps/web/app/api/v1/auth.test.ts | 4 +- apps/web/app/sentry/SentryProvider.tsx | 1 - apps/web/eslint.config.mjs | 33 + apps/web/lib/useDocumentVisibility.ts | 1 - apps/web/lib/utils/action-client/index.ts | 1 - .../components/RatingSmiley/index.tsx | 1 + ...ter-auth-observability.integration.test.ts | 2 - .../core/rate-limit/rate-limit-load.test.ts | 8 +- .../analysis/charts/components/chart-row.tsx | 1 - .../components/add-existing-charts-dialog.tsx | 1 + .../components/attributes-table.tsx | 1 - .../contacts/components/contact-data-view.tsx | 1 - .../ee/contacts/components/contacts-table.tsx | 1 - .../ee/quotas/components/quota-list.tsx | 11 +- .../taxonomy-display/taxonomy-tree.tsx | 1 - .../settings/hooks/use-switcher-data.ts | 2 + .../components/recall-wrapper.tsx | 1 - .../components/element-form-input/index.tsx | 1 - .../components/address-element-form.tsx | 1 - .../editor/components/cal-element-form.tsx | 2 - .../components/contact-info-element-form.tsx | 2 - .../editor/components/elements-view.tsx | 119 +- .../editor/components/survey-editor.tsx | 3 - .../link/components/survey-client-wrapper.tsx | 1 + .../components/manage-translations-modal.tsx | 1 - .../components/connect-integration/index.tsx | 1 - .../editor/components/toolbar-plugin.tsx | 3 - .../ui/components/file-input/index.tsx | 1 - .../pending-downgrade-banner/index.tsx | 3 +- .../ui/components/preview-survey/index.tsx | 1 - .../modules/ui/components/sidebar/index.tsx | 1 + .../modules/ui/components/survey/index.tsx | 1 - .../theme-styling-preview-survey/index.tsx | 1 + apps/web/package.json | 2 +- .../standards/practices/code-formatting.mdx | 42 +- package.json | 2 +- packages/ai/.eslintrc.cjs | 7 - packages/ai/eslint.config.mjs | 3 + packages/ai/package.json | 4 +- packages/cache/.eslintrc.cjs | 7 - packages/cache/eslint.config.mjs | 3 + packages/cache/package.json | 4 +- packages/config-eslint/README.md | 26 +- packages/config-eslint/base.mjs | 64 + packages/config-eslint/legacy-library.js | 7 - packages/config-eslint/legacy-next.js | 9 - packages/config-eslint/legacy-react.js | 7 - packages/config-eslint/library.js | 38 - packages/config-eslint/library.mjs | 29 + packages/config-eslint/next.js | 47 - packages/config-eslint/next.mjs | 25 + packages/config-eslint/package.json | 23 +- packages/config-eslint/react-hooks.mjs | 26 + packages/config-eslint/react.js | 50 - packages/config-eslint/react.mjs | 40 + packages/database/.eslintrc.cjs | 10 - packages/database/eslint.config.mjs | 5 + packages/database/src/prisma.ts | 3 +- .../database/src/scripts/migration-runner.ts | 2 +- packages/database/zod/surveys.ts | 2 - packages/email/.eslintrc.cjs | 4 - packages/email/eslint.config.mjs | 3 + packages/email/package.json | 2 +- packages/i18n-utils/.eslintrc.cjs | 7 - packages/i18n-utils/eslint.config.mjs | 3 + packages/i18n-utils/package.json | 4 +- packages/jobs/.eslintrc.cjs | 7 - packages/jobs/eslint.config.mjs | 3 + packages/jobs/package.json | 2 +- packages/js-core/.eslintrc.cjs | 8 - packages/js-core/.prettierrc.cjs | 23 +- packages/js-core/eslint.config.mjs | 3 + packages/js-core/package.json | 2 +- packages/js-core/src/index.ts | 4 +- .../js-core/src/lib/common/command-queue.ts | 1 - packages/js-core/src/lib/common/logger.ts | 1 - packages/js-core/src/lib/common/recaptcha.ts | 2 +- packages/js-core/src/lib/common/setup.ts | 4 +- .../src/lib/common/tests/config.test.ts | 1 - .../src/lib/common/tests/setup.test.ts | 4 +- .../js-core/src/lib/common/timeout-stack.ts | 1 - .../js-core/src/lib/survey/no-code-action.ts | 5 - .../lib/survey/tests/no-code-action.test.ts | 2 - .../src/lib/survey/tests/widget.test.ts | 3 - packages/js-core/src/lib/survey/widget.ts | 1 - packages/js-core/src/lib/user/attribute.ts | 1 - .../src/lib/user/tests/update-queue.test.ts | 4 +- packages/js-core/src/lib/user/update-queue.ts | 1 - packages/js-core/src/lib/user/update.ts | 1 - packages/js-core/src/lib/user/user.ts | 1 - packages/js-core/src/lib/workspace/state.ts | 5 +- packages/logger/.eslintrc.cjs | 7 - packages/logger/eslint.config.mjs | 11 + packages/logger/package.json | 4 +- packages/logger/src/logger.test.ts | 10 +- packages/storage/.eslintrc.cjs | 7 - packages/storage/eslint.config.mjs | 11 + packages/storage/package.json | 4 +- packages/storage/src/service.test.ts | 1 - packages/survey-ui/.eslintrc.cjs | 5 - packages/survey-ui/eslint.config.mjs | 10 + packages/survey-ui/package.json | 2 +- .../src/components/elements/form-field.tsx | 1 - packages/surveys/.eslintrc.cjs | 4 - packages/surveys/eslint.config.mjs | 3 + packages/surveys/package.json | 2 +- packages/surveys/src/index.ts | 1 - packages/types/.eslintrc.cjs | 7 - packages/types/eslint.config.mjs | 3 + packages/types/package.json | 2 +- packages/vite-plugins/.eslintrc.cjs | 11 - packages/vite-plugins/eslint.config.mjs | 3 + packages/vite-plugins/package.json | 2 +- pnpm-lock.yaml | 1591 +++++------------ 132 files changed, 931 insertions(+), 1678 deletions(-) delete mode 100644 .eslintignore delete mode 100644 .eslintrc.cjs delete mode 100644 apps/storybook/.eslintrc.cjs create mode 100644 apps/storybook/eslint.config.mjs delete mode 100644 apps/web/.eslintignore delete mode 100644 apps/web/.eslintrc.js create mode 100644 apps/web/eslint.config.mjs delete mode 100644 packages/ai/.eslintrc.cjs create mode 100644 packages/ai/eslint.config.mjs delete mode 100644 packages/cache/.eslintrc.cjs create mode 100644 packages/cache/eslint.config.mjs create mode 100644 packages/config-eslint/base.mjs delete mode 100644 packages/config-eslint/legacy-library.js delete mode 100644 packages/config-eslint/legacy-next.js delete mode 100644 packages/config-eslint/legacy-react.js delete mode 100644 packages/config-eslint/library.js create mode 100644 packages/config-eslint/library.mjs delete mode 100644 packages/config-eslint/next.js create mode 100644 packages/config-eslint/next.mjs create mode 100644 packages/config-eslint/react-hooks.mjs delete mode 100644 packages/config-eslint/react.js create mode 100644 packages/config-eslint/react.mjs delete mode 100644 packages/database/.eslintrc.cjs create mode 100644 packages/database/eslint.config.mjs delete mode 100644 packages/email/.eslintrc.cjs create mode 100644 packages/email/eslint.config.mjs delete mode 100644 packages/i18n-utils/.eslintrc.cjs create mode 100644 packages/i18n-utils/eslint.config.mjs delete mode 100644 packages/jobs/.eslintrc.cjs create mode 100644 packages/jobs/eslint.config.mjs delete mode 100644 packages/js-core/.eslintrc.cjs create mode 100644 packages/js-core/eslint.config.mjs delete mode 100644 packages/logger/.eslintrc.cjs create mode 100644 packages/logger/eslint.config.mjs delete mode 100644 packages/storage/.eslintrc.cjs create mode 100644 packages/storage/eslint.config.mjs delete mode 100644 packages/survey-ui/.eslintrc.cjs create mode 100644 packages/survey-ui/eslint.config.mjs delete mode 100644 packages/surveys/.eslintrc.cjs create mode 100644 packages/surveys/eslint.config.mjs delete mode 100644 packages/types/.eslintrc.cjs create mode 100644 packages/types/eslint.config.mjs delete mode 100644 packages/vite-plugins/.eslintrc.cjs create mode 100644 packages/vite-plugins/eslint.config.mjs 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/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/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