Compare commits

...

5 Commits

Author SHA1 Message Date
Dhruwang Jariwala
367bc23dd4 fix: prevent offline replay from dropping survey blocks after completion (#7743) 2026-04-15 19:59:15 +00:00
XHamzaX
a1a11b2bb8 fix: prevent OIDC button text overlap with 'last used' indicator (#7731)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-15 09:42:20 +00:00
Marius
0653c6a59f fix: strip @layer properties block to prevent host page CSS pollution (#7685)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 06:58:35 +00:00
Anshuman Pandey
b6d793e109 fix: fixes unique constraint error with singleUseId and surveyId (#7737) 2026-04-15 06:50:20 +00:00
Dhruwang Jariwala
439dd0b44e fix: add loading skeleton for responses page (#7700)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-13 16:56:20 +00:00
13 changed files with 398 additions and 33 deletions

View File

@@ -0,0 +1,22 @@
"use client";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
const Loading = () => {
return (
<PageContentWrapper>
<PageHeader pageTitle="" />
<div className="flex h-9 animate-pulse gap-2">
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
</div>
<SkeletonLoader type="summary" />
</PageContentWrapper>
);
};
export default Loading;

View File

@@ -0,0 +1,23 @@
"use client";
import { useTranslation } from "react-i18next";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
const Loading = () => {
const { t } = useTranslation();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.responses")} />
<div className="flex h-9 animate-pulse gap-1.5">
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
</div>
<SkeletonLoader type="responseTable" />
</PageContentWrapper>
);
};
export default Loading;

View File

@@ -2,7 +2,7 @@ import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -175,10 +175,34 @@ describe("createResponse V2", () => {
).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
test("should throw UniqueConstraintError on P2002 with singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["surveyId", "singleUseId"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(UniqueConstraintError);
});
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["displayId"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(DatabaseError);
});
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
clientVersion: "test",
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(

View File

@@ -2,7 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -129,6 +129,13 @@ export const createResponse = async (
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
const target = (error.meta?.target as string[]) ?? [];
if (target?.includes("singleUseId")) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
}
throw new DatabaseError(error.message);
}

View File

@@ -1,6 +1,6 @@
import { UAParser } from "ua-parser-js";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { InvalidInputError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
@@ -177,6 +177,10 @@ const createResponseForRequest = async ({
return responses.badRequestResponse(error.message, undefined, true);
}
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message, undefined, true);
}
const response = getUnexpectedPublicErrorResponse();
reportApiError({
request,

View File

@@ -339,6 +339,56 @@ describe("API Response Utilities", () => {
});
});
describe("conflictResponse", () => {
test("should return a conflict response", () => {
const message = "Resource already exists";
const details = { field: "singleUseId" };
const response = responses.conflictResponse(message, details);
expect(response.status).toBe(409);
return response.json().then((body) => {
expect(body).toEqual({
code: "conflict",
message,
details,
});
});
});
test("should handle undefined details", () => {
const message = "Resource already exists";
const response = responses.conflictResponse(message);
expect(response.status).toBe(409);
return response.json().then((body) => {
expect(body).toEqual({
code: "conflict",
message,
details: {},
});
});
});
test("should include CORS headers when cors is true", () => {
const message = "Resource already exists";
const response = responses.conflictResponse(message, undefined, true);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE, OPTIONS");
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, Authorization");
});
test("should use custom cache control header when provided", () => {
const message = "Resource already exists";
const customCache = "no-cache";
const response = responses.conflictResponse(message, undefined, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("tooManyRequestsResponse", () => {
test("should return a too many requests response", () => {
const message = "Rate limit exceeded";

View File

@@ -16,7 +16,8 @@ interface ApiErrorResponse {
| "method_not_allowed"
| "not_authenticated"
| "forbidden"
| "too_many_requests";
| "too_many_requests"
| "conflict";
message: string;
details: {
[key: string]: string | string[] | number | number[] | boolean | boolean[];
@@ -236,6 +237,30 @@ const internalServerErrorResponse = (
);
};
const conflictResponse = (
message: string,
details?: { [key: string]: string },
cors: boolean = false,
cache: string = "private, no-store"
) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
code: "conflict",
message,
details: details || {},
} as ApiErrorResponse,
{
status: 409,
headers,
}
);
};
const tooManyRequestsResponse = (
message: string,
cors: boolean = false,
@@ -270,4 +295,5 @@ export const responses = {
successResponse,
tooManyRequestsResponse,
forbiddenResponse,
conflictResponse,
};

View File

@@ -98,14 +98,11 @@ describe("Users Lib", () => {
test("returns conflict error if user with email already exists", async () => {
(prisma.user.create as any).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError(
"Unique constraint failed on the fields: (`email`)",
{
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "1.0.0",
meta: { target: ["email"] },
}
)
new Prisma.PrismaClientKnownRequestError("Unique constraint failed on the fields: (`email`)", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "1.0.0",
meta: { target: ["email"] },
})
);
const result = await createUser(
{ name: "Duplicate", email: "test@example.com", role: "member" },

View File

@@ -46,9 +46,9 @@ export const OpenIdButton = ({
type="button"
onClick={handleLogin}
variant="secondary"
className="relative w-full justify-center">
{text ? text : t("auth.continue_with_openid")}
{lastUsed && <span className="absolute right-3 text-xs opacity-50">{t("auth.last_used")}</span>}
className="w-full items-center justify-center gap-2 px-2">
<span className="truncate">{text || t("auth.continue_with_openid")}</span>
{lastUsed && <span className="shrink-0 text-xs opacity-50">{t("auth.last_used")}</span>}
</Button>
);
};

View File

@@ -1,7 +1,7 @@
import { Skeleton } from "@/modules/ui/components/skeleton";
type SkeletonLoaderProps = {
type: "response" | "summary";
type: "response" | "responseTable" | "summary";
};
export const SkeletonLoader = ({ type }: SkeletonLoaderProps) => {
@@ -25,6 +25,43 @@ export const SkeletonLoader = ({ type }: SkeletonLoaderProps) => {
);
}
if (type === "responseTable") {
const renderTableCells = () => (
<>
<Skeleton className="h-4 w-4 rounded-xl bg-slate-400" />
<Skeleton className="h-4 w-24 rounded-xl bg-slate-200" />
<Skeleton className="h-4 w-32 rounded-xl bg-slate-200" />
<Skeleton className="h-4 w-40 rounded-xl bg-slate-200" />
<Skeleton className="h-4 w-40 rounded-xl bg-slate-200" />
<Skeleton className="h-4 w-32 rounded-xl bg-slate-200" />
</>
);
return (
<div className="animate-pulse space-y-4" data-testid="skeleton-loader-response-table">
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-48 rounded-md bg-slate-300" />
<div className="flex gap-2">
<Skeleton className="h-8 w-8 rounded-md bg-slate-300" />
<Skeleton className="h-8 w-8 rounded-md bg-slate-300" />
</div>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200">
<div className="flex h-12 items-center gap-4 border-b border-slate-200 bg-slate-100 px-4">
{renderTableCells()}
</div>
{Array.from({ length: 10 }, (_, i) => (
<div
key={i}
className="flex h-12 items-center gap-4 border-b border-slate-100 px-4 last:border-b-0">
{renderTableCells()}
</div>
))}
</div>
</div>
);
}
if (type === "response") {
return (
<div className="group space-y-4 rounded-lg bg-white p-6" data-testid="skeleton-loader-response">

View File

@@ -1,5 +1,6 @@
// basic regex -- [whitespace](number)(rem)[whitespace or ;]
const REM_REGEX = /\b(\d+(\.\d+)?)(rem)\b/gi;
// Matches a CSS numeric value followed by "rem" — e.g. "1rem", "1.5rem", "16rem".
// Single character-class + single quantifier: no nested quantifiers, no backtracking risk.
const REM_REGEX = /([\d.]+)(rem)/gi; // NOSONAR -- single character-class quantifier on trusted CSS input; no backtracking risk
const PROCESSED = Symbol("processed");
const remtoEm = (opts = {}) => {
@@ -26,6 +27,36 @@ const remtoEm = (opts = {}) => {
};
};
module.exports = {
plugins: [require("@tailwindcss/postcss"), require("autoprefixer"), remtoEm()],
// Strips the `@layer properties { ... }` block that Tailwind v4 emits as a
// browser-compatibility fallback for `@property` declarations.
//
// Problem: CSS `@layer` at-rules are globally scoped by spec — they cannot be
// confined by a surrounding selector. Even though all other Formbricks survey
// styles are correctly scoped to `#fbjs`, the `@layer properties` block
// contains a bare `*, :before, :after, ::backdrop` selector that resets all
// `--tw-*` CSS custom properties on every element of the host page. This
// breaks shadows, rings, transforms, and other Tailwind utilities on any site
// that uses Tailwind v4 alongside the Formbricks SDK.
//
// The `@property` declarations already present in the same stylesheet cover
// the same browser-compatibility need for all supporting browsers, so removing
// `@layer properties` does not affect survey rendering.
//
// See: https://github.com/formbricks/js/issues/46
const stripLayerProperties = () => {
return {
postcssPlugin: "postcss-strip-layer-properties",
AtRule: {
layer: (atRule) => {
if (atRule.params === "properties") {
atRule.remove();
}
},
},
};
};
stripLayerProperties.postcss = true;
module.exports = {
plugins: [require("@tailwindcss/postcss"), require("autoprefixer"), remtoEm(), stripLayerProperties()],
};

View File

@@ -32,16 +32,32 @@ export const delay = (ms: number): Promise<void> => {
});
};
// Module-level locks keyed by surveyId.
// Survive ResponseQueue instance recreation (e.g. React useMemo recomputation)
// so that only one sync/send runs at a time per survey, even across instances.
const syncingBySurvey = new Map<string, boolean>();
const requestInProgressBySurvey = new Map<string, boolean>();
/** @internal Exposed for tests only. */
export const _syncLocks = {
clear: () => {
syncingBySurvey.clear();
requestInProgressBySurvey.clear();
},
set: (surveyId: string, value: boolean) => syncingBySurvey.set(surveyId, value),
get: (surveyId: string) => syncingBySurvey.get(surveyId) ?? false,
setRequestInProgress: (surveyId: string, value: boolean) => requestInProgressBySurvey.set(surveyId, value),
getRequestInProgress: (surveyId: string) => requestInProgressBySurvey.get(surveyId) ?? false,
};
export class ResponseQueue {
readonly queue: TResponseUpdate[] = [];
readonly config: QueueConfig;
private surveyState: SurveyState;
private isRequestInProgress = false;
readonly api: ApiClient;
private responseRecaptchaToken?: string;
// Maps in-memory queue index → IndexedDB id for cleanup after successful send
private readonly pendingDbIds: Map<TResponseUpdate, number> = new Map();
private isSyncing = false;
constructor(config: QueueConfig, surveyState: SurveyState) {
this.config = config;
@@ -52,6 +68,26 @@ export class ResponseQueue {
});
}
private get isSyncing(): boolean {
return this.config.surveyId ? (syncingBySurvey.get(this.config.surveyId) ?? false) : false;
}
private set isSyncing(value: boolean) {
if (this.config.surveyId) {
syncingBySurvey.set(this.config.surveyId, value);
}
}
private get isRequestInProgress(): boolean {
return this.config.surveyId ? (requestInProgressBySurvey.get(this.config.surveyId) ?? false) : false;
}
private set isRequestInProgress(value: boolean) {
if (this.config.surveyId) {
requestInProgressBySurvey.set(this.config.surveyId, value);
}
}
setResponseRecaptchaToken(token?: string) {
this.responseRecaptchaToken = token;
}
@@ -111,9 +147,26 @@ export class ResponseQueue {
return { success: false };
}
this.isRequestInProgress = true;
const responseUpdate = this.queue[0];
// When offline support is active and there are multiple pending entries in
// IndexedDB, defer to syncPersistedResponses which drains them in order.
// This prevents processQueue and syncPersistedResponses from racing to
// create the same response concurrently (duplicate POSTs).
if (this.config.persistOffline && this.config.surveyId) {
const pendingCount = await countPendingResponses(this.config.surveyId);
// Re-check after await — another processQueue/sync may have started during the yield
if (this.isSyncing || this.isRequestInProgress || this.queue.length === 0) {
return { success: false };
}
if (pendingCount > 1) {
void this.syncPersistedResponses();
return { success: false };
}
}
const responseUpdate = this.queue[0];
this.isRequestInProgress = true;
const result = await this.sendResponseWithRetry(responseUpdate);
if (result.success) {
@@ -169,6 +222,11 @@ export class ResponseQueue {
// Concurrency guard: prevent duplicate syncs from online/offline flicker
if (this.isSyncing) return { success: false, syncedCount: 0 };
// If processQueue already has a request in flight, don't start syncing —
// let it finish first to avoid both paths creating the same response.
if (this.isRequestInProgress) return { success: false, syncedCount: 0 };
this.isSyncing = true;
try {

View File

@@ -3,7 +3,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vit
import { err, ok } from "@formbricks/types/error-handlers";
import { TResponseUpdate } from "@formbricks/types/responses";
import { TResponseErrorCodesEnum } from "@/types/response-error-codes";
import { ResponseQueue, delay } from "./response-queue";
import { ResponseQueue, _syncLocks, delay } from "./response-queue";
import { SurveyState } from "./survey-state";
// Suppress noisy console output from retry logic during tests
@@ -86,6 +86,7 @@ describe("ResponseQueue", () => {
queue = new ResponseQueue(config, surveyState);
apiMock = queue.api;
vi.clearAllMocks();
_syncLocks.clear();
});
test("constructor initializes properties", () => {
@@ -309,12 +310,75 @@ describe("ResponseQueue", () => {
});
test("processQueue returns false when isSyncing is true", async () => {
queue.queue.push(responseUpdate);
queue["isSyncing"] = true;
const result = await queue.processQueue();
const offlineQueue = new ResponseQueue(
getConfig({ persistOffline: true, surveyId: "s1" }),
getSurveyState()
);
offlineQueue.queue.push(responseUpdate);
_syncLocks.set("s1", true);
const result = await offlineQueue.processQueue();
expect(result.success).toBe(false);
});
test("processQueue defers to sync when multiple IDB entries exist", async () => {
const { countPendingResponses } = await import("./offline-storage");
vi.mocked(countPendingResponses).mockResolvedValue(3);
const offlineQueue = new ResponseQueue(
getConfig({ persistOffline: true, surveyId: "s1" }),
getSurveyState()
);
offlineQueue.queue.push({ data: { q1: "answer" }, finished: false });
const syncSpy = vi.spyOn(offlineQueue, "syncPersistedResponses").mockResolvedValue({
success: true,
syncedCount: 3,
});
const result = await offlineQueue.processQueue();
expect(result.success).toBe(false);
expect(syncSpy).toHaveBeenCalled();
expect(_syncLocks.getRequestInProgress("s1")).toBe(false);
});
test("processQueue bails out if syncPersistedResponses starts during countPendingResponses await", async () => {
const { countPendingResponses } = await import("./offline-storage");
// Simulate syncPersistedResponses starting during the async gap
vi.mocked(countPendingResponses).mockImplementation(async () => {
// While countPendingResponses is resolving, isSyncing becomes true
_syncLocks.set("s1", true);
return 1;
});
const offlineQueue = new ResponseQueue(
getConfig({ persistOffline: true, surveyId: "s1" }),
getSurveyState()
);
offlineQueue.queue.push({ data: { q1: "answer" }, finished: false });
const sendSpy = vi.spyOn(offlineQueue as any, "sendResponseWithRetry");
const result = await offlineQueue.processQueue();
expect(result.success).toBe(false);
expect(sendSpy).not.toHaveBeenCalled();
});
test("processQueue sends directly when it is the only IDB entry", async () => {
const { countPendingResponses } = await import("./offline-storage");
vi.mocked(countPendingResponses).mockResolvedValue(1);
const offlineQueue = new ResponseQueue(
getConfig({ persistOffline: true, surveyId: "s1" }),
getSurveyState()
);
offlineQueue.queue.push({ data: { q1: "answer" }, finished: false });
vi.spyOn(offlineQueue as any, "sendResponseWithRetry").mockResolvedValue({ success: true });
const result = await offlineQueue.processQueue();
expect(result.success).toBe(true);
});
test("loadPersistedQueue returns 0 when persistOffline is disabled", async () => {
const count = await queue.loadPersistedQueue();
expect(count).toBe(0);
@@ -347,11 +411,33 @@ describe("ResponseQueue", () => {
getConfig({ persistOffline: true, surveyId: "s1" }),
getSurveyState()
);
offlineQueue["isSyncing"] = true;
_syncLocks.set("s1", true);
const result = await offlineQueue.syncPersistedResponses();
expect(result).toEqual({ success: false, syncedCount: 0 });
});
test("syncPersistedResponses returns early when a processQueue request is in flight", async () => {
_syncLocks.setRequestInProgress("s1", true);
const offlineQueue = new ResponseQueue(
getConfig({ persistOffline: true, surveyId: "s1" }),
getSurveyState()
);
const result = await offlineQueue.syncPersistedResponses();
expect(result).toEqual({ success: false, syncedCount: 0 });
});
test("syncPersistedResponses on a new instance sees isRequestInProgress from an old instance", async () => {
// Simulate instance A having a request in flight (module-level lock)
_syncLocks.setRequestInProgress("s1", true);
// Instance B is newly created (e.g. React useMemo recomputation)
const instanceB = new ResponseQueue(
getConfig({ persistOffline: true, surveyId: "s1" }),
getSurveyState()
);
const result = await instanceB.syncPersistedResponses();
expect(result).toEqual({ success: false, syncedCount: 0 });
});
test("syncPersistedResponses sends entries and clears queue on success", async () => {
const { getPendingResponses, removePendingResponse } = await import("./offline-storage");
vi.mocked(getPendingResponses).mockResolvedValue([
@@ -382,7 +468,7 @@ describe("ResponseQueue", () => {
expect(result).toEqual({ success: true, syncedCount: 1 });
expect(removePendingResponse).toHaveBeenCalledWith(10);
expect(offlineQueue.queue.length).toBe(0);
expect(offlineQueue["isSyncing"]).toBe(false);
expect(_syncLocks.get("s1")).toBe(false);
});
test("syncPersistedResponses stops on server error", async () => {
@@ -415,7 +501,7 @@ describe("ResponseQueue", () => {
const result = await offlineQueue.syncPersistedResponses();
expect(result).toEqual({ success: false, syncedCount: 0 });
expect(offlineQueue["isSyncing"]).toBe(false);
expect(_syncLocks.get("s1")).toBe(false);
});
test("syncPersistedResponses retries 404 as createResponse by resetting responseId", async () => {