Compare commits

...

2 Commits

Author SHA1 Message Date
Dhruwang Jariwala 7f5b2bf69d fix: prevent split offline responses on restore (backport #7767) (#7777) 2026-04-20 12:00:34 +05:30
Dhruwang 60e7c7e8ee fix(surveys): prevent split offline responses on restore (backport #7767)
Backport of #7767 to release/4.9. Anchors displayId and responseId back
into saved survey progress as soon as they are created, recovers a
missing responseId from displayId on restore, and falls back to a
bootstrap create path that uses the full accumulated response state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 11:43:46 +05:30
9 changed files with 258 additions and 11 deletions
@@ -0,0 +1,44 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const getResponseIdByDisplayId = async (
environmentId: string,
displayId: string
): Promise<{ responseId: string | null }> => {
validateInputs([environmentId, ZId], [displayId, ZId]);
try {
const display = await prisma.display.findFirst({
where: {
id: displayId,
survey: {
environmentId,
},
},
select: {
response: {
select: {
id: true,
},
},
},
});
if (!display) {
throw new ResourceNotFoundError("Display", displayId);
}
return {
responseId: display.response?.id ?? null,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -0,0 +1,40 @@
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getResponseIdByDisplayId } from "./lib/response";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: THandlerParams<{ params: Promise<{ environmentId: string; displayId: string }> }>) => {
const params = await props.params;
try {
const response = await getResponseIdByDisplayId(params.environmentId, params.displayId);
return {
response: responses.successResponse(response, true),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Display", params.displayId, true),
};
}
logger.error(
{ error, url: req.url, environmentId: params.environmentId, displayId: params.displayId },
"Error in GET /api/v1/client/[environmentId]/displays/[displayId]/response"
);
return {
response: responses.internalServerErrorResponse("Something went wrong. Please try again."),
};
}
},
});
@@ -29,6 +29,7 @@ import {
type SerializedSurveyState,
clearSurveyProgress,
getSurveyProgress,
patchSurveyProgressSnapshot,
saveSurveyProgress,
} from "@/lib/offline-storage";
import { parseRecallInformation } from "@/lib/recall";
@@ -38,13 +39,28 @@ import { useOnlineStatus } from "@/lib/use-online-status";
import { cn, findBlockByElementId, getDefaultLanguageCode, getElementsFromSurveyBlocks } from "@/lib/utils";
import { TResponseErrorCodesEnum } from "@/types/response-error-codes";
const restoreSurveyStateFromSnapshot = (surveyState: SurveyState, snapshot: SerializedSurveyState): void => {
const restoreSurveyStateFromSnapshot = (
surveyState: SurveyState,
snapshot: SerializedSurveyState,
progress: {
responseData: TResponseData;
ttc: TResponseTtc;
currentVariables: TResponseVariables;
}
): void => {
if (snapshot.responseId) surveyState.updateResponseId(snapshot.responseId);
if (snapshot.displayId) surveyState.updateDisplayId(snapshot.displayId);
if (snapshot.userId) surveyState.updateUserId(snapshot.userId);
if (snapshot.contactId) surveyState.updateContactId(snapshot.contactId);
if (snapshot.singleUseId) surveyState.singleUseId = snapshot.singleUseId;
surveyState.responseAcc = snapshot.responseAcc;
surveyState.disableBootstrapResponseCreate();
surveyState.responseAcc = {
...snapshot.responseAcc,
data: progress.responseData,
ttc: progress.ttc,
variables: progress.currentVariables,
displayId: snapshot.displayId ?? snapshot.responseAcc.displayId,
};
};
interface VariableStackEntry {
@@ -127,6 +143,14 @@ export function Survey({
const offlinePersistEnabled =
offlineSupport && isLinkSurvey && !isPreviewMode && !!appUrl && !!environmentId;
const persistSurveyStateSnapshot = useCallback(
async (snapshotPatch: Partial<SerializedSurveyState>) => {
if (!offlinePersistEnabled) return;
await patchSurveyProgressSnapshot(survey.id, snapshotPatch);
},
[offlinePersistEnabled, survey.id]
);
const responseQueue = useMemo(() => {
if (appUrl && environmentId && surveyState) {
return new ResponseQueue(
@@ -160,6 +184,9 @@ export function Survey({
setBlockId(quotaInfo.endingCardId);
}
},
onResponseCreated: (responseId) => {
void persistSurveyStateSnapshot({ responseId });
},
},
surveyState
);
@@ -173,6 +200,7 @@ export function Survey({
getSetIsResponseSendingFinished,
surveyState,
offlinePersistEnabled,
persistSurveyStateSnapshot,
survey.id,
]);
@@ -319,6 +347,7 @@ export function Survey({
surveyState.updateDisplayId(display.data.id);
responseQueue.updateSurveyState(surveyState);
await persistSurveyStateSnapshot({ displayId: display.data.id });
if (onDisplayCreated) {
onDisplayCreated();
@@ -337,6 +366,7 @@ export function Survey({
onDisplayCreated,
isPreviewMode,
onDisplay,
persistSurveyStateSnapshot,
]);
// Create display on mount. When offline persistence is enabled, wait for progress
@@ -458,7 +488,36 @@ export function Survey({
// Restore survey state from snapshot
if (surveyState && progress.surveyStateSnapshot) {
restoreSurveyStateFromSnapshot(surveyState, progress.surveyStateSnapshot);
restoreSurveyStateFromSnapshot(surveyState, progress.surveyStateSnapshot, progress);
if (pendingCount === 0 && !progress.surveyStateSnapshot.responseId) {
if (progress.surveyStateSnapshot.displayId && apiClient) {
const responseLookup = await apiClient.getResponseIdByDisplayId(
progress.surveyStateSnapshot.displayId
);
if (responseLookup.ok && responseLookup.data.responseId) {
surveyState.updateResponseId(responseLookup.data.responseId);
await persistSurveyStateSnapshot({ responseId: responseLookup.data.responseId });
} else if (responseLookup.ok) {
surveyState.enableBootstrapResponseCreate();
} else if (responseLookup.error.status === 404) {
surveyState.updateDisplayId(null);
surveyState.enableBootstrapResponseCreate();
await persistSurveyStateSnapshot({ displayId: null });
} else {
console.error("Formbricks: Failed to recover responseId from displayId", {
displayId: progress.surveyStateSnapshot.displayId,
error: responseLookup.error,
});
surveyState.enableBootstrapResponseCreate();
}
} else {
surveyState.enableBootstrapResponseCreate();
}
}
responseQueue?.updateSurveyState(surveyState);
}
} else {
// Block no longer exists (survey structure changed) — discard UI progress
@@ -466,7 +525,8 @@ export function Survey({
await clearSurveyProgress(survey.id);
if (surveyState && progress.surveyStateSnapshot) {
restoreSurveyStateFromSnapshot(surveyState, progress.surveyStateSnapshot);
restoreSurveyStateFromSnapshot(surveyState, progress.surveyStateSnapshot, progress);
responseQueue?.updateSurveyState(surveyState);
}
}
+10
View File
@@ -46,6 +46,16 @@ export class ApiClient {
);
}
async getResponseIdByDisplayId(
displayId: string
): Promise<Result<{ responseId: string | null }, ApiErrorResponse>> {
return makeRequest(
this.appUrl,
`/api/v1/client/${this.environmentId}/displays/${displayId}/response`,
"GET"
);
}
async createResponse(
responseInput: Omit<TResponseInput, "environmentId"> & {
contactId: string | null;
@@ -241,6 +241,44 @@ export const getSurveyProgress = async (surveyId: string): Promise<SurveyProgres
}
};
export const patchSurveyProgressSnapshot = async (
surveyId: string,
snapshotPatch: Partial<SerializedSurveyState>
): Promise<void> => {
try {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_SURVEY_PROGRESS, "readwrite");
const store = tx.objectStore(STORE_SURVEY_PROGRESS);
const getRequest = store.get(surveyId);
getRequest.onsuccess = () => {
const existing = getRequest.result as SurveyProgressEntry | undefined;
if (!existing) {
resolve();
return;
}
const putRequest = store.put({
...existing,
surveyStateSnapshot: {
...existing.surveyStateSnapshot,
...snapshotPatch,
},
updatedAt: Date.now(),
});
putRequest.onsuccess = () => resolve();
putRequest.onerror = () => reject(putRequest.error ?? new Error("IndexedDB request failed"));
};
getRequest.onerror = () => reject(getRequest.error ?? new Error("IndexedDB request failed"));
});
} catch (e) {
console.warn("Formbricks: Failed to patch survey progress snapshot in IndexedDB", e);
}
};
export const clearSurveyProgress = async (surveyId: string): Promise<void> => {
try {
const db = await openDb();
+36 -2
View File
@@ -20,6 +20,7 @@ interface QueueConfig {
retryAttempts: number;
persistOffline?: boolean;
surveyId?: string;
onResponseCreated?: (responseId: string) => void;
onResponseSendingFailed?: (responseUpdate: TResponseUpdate, errorCode?: TResponseErrorCodesEnum) => void;
onResponseSendingFinished?: () => void;
onQuotaFull?: (quotaInfo: TQuotaFullResponse) => void;
@@ -359,6 +360,37 @@ export class ResponseQueue {
return error.details?.code === RECAPTCHA_VERIFICATION_ERROR_CODE;
}
private getCreatePayload(
responseUpdate: TResponseUpdate
): Omit<
Parameters<ApiClient["createResponse"]>[0],
"contactId" | "userId" | "singleUseId" | "surveyId" | "displayId" | "recaptchaToken"
> {
if (!this.surveyState.shouldCreateResponseFromState) {
return {
finished: responseUpdate.finished,
data: { ...responseUpdate.data, ...responseUpdate.hiddenFields },
ttc: responseUpdate.ttc,
variables: responseUpdate.variables,
language: responseUpdate.language,
meta: responseUpdate.meta,
endingId: responseUpdate.endingId,
};
}
const accumulatedResponse = this.surveyState.responseAcc;
return {
finished: accumulatedResponse.finished,
data: { ...accumulatedResponse.data, ...responseUpdate.hiddenFields },
ttc: accumulatedResponse.ttc,
variables: accumulatedResponse.variables,
language: accumulatedResponse.language ?? responseUpdate.language,
meta: accumulatedResponse.meta ?? responseUpdate.meta,
endingId: accumulatedResponse.endingId ?? responseUpdate.endingId,
};
}
private handleSuccessfulResponse(responseUpdate: TResponseUpdate, quotaFullResponse?: TQuotaFullResponse) {
if (responseUpdate.finished) {
this.config.onResponseSendingFinished?.();
@@ -399,13 +431,13 @@ export class ResponseQueue {
return err(response.error);
}
} else {
const createPayload = this.getCreatePayload(responseUpdate);
response = await this.api.createResponse({
...responseUpdate,
...createPayload,
surveyId: this.surveyState.surveyId,
contactId: this.surveyState.contactId || null,
userId: this.surveyState.userId || null,
singleUseId: this.surveyState.singleUseId || null,
data: { ...responseUpdate.data, ...responseUpdate.hiddenFields },
displayId: this.surveyState.displayId,
recaptchaToken: this.responseRecaptchaToken ?? undefined,
});
@@ -415,6 +447,8 @@ export class ResponseQueue {
}
this.surveyState.updateResponseId(response.data.id);
this.surveyState.disableBootstrapResponseCreate();
this.config.onResponseCreated?.(response.data.id);
if (this.config.setSurveyState) {
this.config.setSurveyState(this.surveyState);
}
@@ -38,11 +38,14 @@ const getSurveyState: () => SurveyState = () => ({
contactId: "contact1",
surveyId: "survey1",
singleUseId: "single1",
shouldCreateResponseFromState: false,
responseAcc: { finished: false, data: {}, ttc: {}, variables: {} },
updateResponseId: vi.fn(),
updateDisplayId: vi.fn(),
updateUserId: vi.fn(),
updateContactId: vi.fn(),
enableBootstrapResponseCreate: vi.fn(),
disableBootstrapResponseCreate: vi.fn(),
accumulateResponse: vi.fn(),
isResponseFinished: vi.fn(),
clear: vi.fn(),
@@ -191,6 +194,7 @@ describe("ResponseQueue", () => {
const result = await queue.sendResponse(responseUpdate);
expect(apiMock.createResponse).toHaveBeenCalled();
expect(surveyState.updateResponseId).toHaveBeenCalledWith("newid");
expect(surveyState.disableBootstrapResponseCreate).toHaveBeenCalled();
expect(config.setSurveyState).toHaveBeenCalledWith(surveyState);
expect(result.ok).toBe(true);
});
@@ -14,6 +14,7 @@ describe("SurveyState", () => {
expect(surveyState.surveyId).toBe(initialSurveyId);
expect(surveyState.responseId).toBeNull();
expect(surveyState.displayId).toBeNull();
expect(surveyState.shouldCreateResponseFromState).toBe(false);
expect(surveyState.userId).toBeNull();
expect(surveyState.contactId).toBeNull();
expect(surveyState.singleUseId).toBeNull();
@@ -137,7 +138,7 @@ describe("SurveyState", () => {
expect(surveyState.responseAcc.finished).toBe(true);
expect(surveyState.responseAcc.data).toEqual({ q1: "newAns1", q2: "ans2" });
expect(surveyState.responseAcc.ttc).toEqual({ q2: 200 }); // ttc is overwritten
expect(surveyState.responseAcc.ttc).toEqual({ q1: 100, q2: 200 });
expect(surveyState.responseAcc.variables).toEqual({ varB: "valB" }); // variables are overwritten
expect(surveyState.responseAcc.displayId).toBe("display123");
});
@@ -158,9 +159,11 @@ describe("SurveyState", () => {
describe("clear", () => {
test("should reset responseId and responseAcc", () => {
surveyState.responseId = "someId";
surveyState.enableBootstrapResponseCreate();
surveyState.responseAcc = { finished: true, data: { q: "a" }, ttc: { q: 1 }, variables: { v: "1" } };
surveyState.clear();
expect(surveyState.responseId).toBeNull();
expect(surveyState.shouldCreateResponseFromState).toBe(false);
expect(surveyState.responseAcc).toEqual({ finished: false, data: {}, ttc: {}, variables: {} });
});
});
+18 -4
View File
@@ -6,6 +6,7 @@ export class SurveyState {
userId: string | null = null;
contactId: string | null = null;
surveyId: string;
shouldCreateResponseFromState = false;
responseAcc: TResponseUpdate = { finished: false, data: {}, ttc: {}, variables: {} };
singleUseId: string | null;
@@ -59,7 +60,7 @@ export class SurveyState {
* Update the display ID after a successful display creation
* @param id - The display ID
*/
updateDisplayId(id: string) {
updateDisplayId(id: string | null) {
this.displayId = id;
}
@@ -79,6 +80,14 @@ export class SurveyState {
this.contactId = id;
}
enableBootstrapResponseCreate() {
this.shouldCreateResponseFromState = true;
}
disableBootstrapResponseCreate() {
this.shouldCreateResponseFromState = false;
}
/**
* Accumulate the responses
* @param responseUpdate - The new response data to add
@@ -86,10 +95,14 @@ export class SurveyState {
accumulateResponse(responseUpdate: TResponseUpdate) {
this.responseAcc = {
finished: responseUpdate.finished,
ttc: responseUpdate.ttc,
ttc: { ...this.responseAcc.ttc, ...responseUpdate.ttc },
data: { ...this.responseAcc.data, ...responseUpdate.data },
variables: responseUpdate.variables,
displayId: responseUpdate.displayId,
variables: responseUpdate.variables ?? this.responseAcc.variables,
displayId: responseUpdate.displayId ?? this.responseAcc.displayId,
language: responseUpdate.language ?? this.responseAcc.language,
meta: responseUpdate.meta ?? this.responseAcc.meta,
hiddenFields: responseUpdate.hiddenFields ?? this.responseAcc.hiddenFields,
endingId: responseUpdate.endingId,
};
}
@@ -105,6 +118,7 @@ export class SurveyState {
*/
clear() {
this.responseId = null;
this.shouldCreateResponseFromState = false;
this.responseAcc = { finished: false, data: {}, ttc: {}, variables: {} };
}
}