fix: resolve infinite re-render loop in Survey Editor

Stabilize handler references in ElementsView to prevent "Maximum update depth exceeded" errors. Break dependency cycles in useEffects that were triggering infinite updates and causing memory/performance degradation.
This commit is contained in:
TheodorTomas
2026-01-21 17:44:53 +07:00
parent 8f6d27c1ef
commit 47098d13eb
2 changed files with 47 additions and 75 deletions

View File

@@ -48,7 +48,7 @@ import {
} from "@/modules/survey/editor/lib/blocks";
import { findElementUsedInLogic, isUsedInQuota, isUsedInRecall } from "@/modules/survey/editor/lib/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { isEndingCardValid, isWelcomeCardValid, validateSurveyElementsInBatch } from "../lib/validation";
interface ElementsViewProps {
localSurvey: TSurvey;
@@ -58,7 +58,6 @@ interface ElementsViewProps {
project: Project;
projectLanguages: Language[];
invalidElements: string[] | null;
setInvalidElements: React.Dispatch<SetStateAction<string[] | null>>;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
isMultiLanguageAllowed?: boolean;
@@ -80,7 +79,6 @@ export const ElementsView = ({
project,
projectLanguages,
invalidElements,
setInvalidElements,
setSelectedLanguageCode,
selectedLanguageCode,
isMultiLanguageAllowed,
@@ -97,14 +95,9 @@ export const ElementsView = ({
const elements = useMemo(() => getElementsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
const internalElementIdMap = useMemo(() => {
return elements.reduce((acc, element) => {
acc[element.id] = createId();
return acc;
}, {});
}, [elements]);
const surveyLanguages = localSurvey.languages;
const getBlockName = (index: number): string => {
return `Block ${index + 1}`;
@@ -195,34 +188,7 @@ export const ElementsView = ({
};
};
useEffect(() => {
if (!invalidElements) return;
let updatedInvalidElements: string[] = [...invalidElements];
// Check welcome card
if (localSurvey.welcomeCard.enabled && !isWelcomeCardValid(localSurvey.welcomeCard, surveyLanguages)) {
if (!updatedInvalidElements.includes("start")) {
updatedInvalidElements = [...updatedInvalidElements, "start"];
}
} else {
updatedInvalidElements = updatedInvalidElements.filter((elementId) => elementId !== "start");
}
// Check thank you card
localSurvey.endings.forEach((ending) => {
if (!isEndingCardValid(ending, surveyLanguages)) {
if (!updatedInvalidElements.includes(ending.id)) {
updatedInvalidElements = [...updatedInvalidElements, ending.id];
}
} else {
updatedInvalidElements = updatedInvalidElements.filter((elementId) => elementId !== ending.id);
}
});
if (JSON.stringify(updatedInvalidElements) !== JSON.stringify(invalidElements)) {
setInvalidElements(updatedInvalidElements);
}
}, [localSurvey.welcomeCard, localSurvey.endings, surveyLanguages, invalidElements, setInvalidElements]);
const updateElement = (elementIdx: number, updatedAttributes: any) => {
// Get element ID from current elements array (for validation)
@@ -234,7 +200,6 @@ export const ElementsView = ({
// Track side effects that need to happen after state update
let newActiveElementId: string | null = null;
let invalidElementsUpdate: string[] | null = null;
// Use functional update to ensure we work with the latest state
setLocalSurvey((prevSurvey) => {
@@ -280,19 +245,11 @@ export const ElementsView = ({
const initialElementId = elementId;
updatedSurvey = handleElementLogicChange(updatedSurvey, initialElementId, elementLevelAttributes.id);
// Track side effects to apply after state update
if (invalidElements?.includes(initialElementId)) {
invalidElementsUpdate = invalidElements.map((id) =>
id === initialElementId ? elementLevelAttributes.id : id
);
}
// Track new active element ID
newActiveElementId = elementLevelAttributes.id;
// Update internal element ID map
internalElementIdMap[elementLevelAttributes.id] = internalElementIdMap[elementId];
delete internalElementIdMap[elementId];
}
// Update element-level attributes if any
@@ -328,9 +285,6 @@ export const ElementsView = ({
});
// Apply side effects after state update is queued
if (invalidElementsUpdate) {
setInvalidElements(invalidElementsUpdate);
}
if (newActiveElementId) {
setActiveElementId(newActiveElementId);
}
@@ -468,7 +422,7 @@ export const ElementsView = ({
updatedSurvey = result.data;
setLocalSurvey(updatedSurvey);
delete internalElementIdMap[elementId];
handleActiveElementAfterDeletion(elementId, elementIdx, updatedSurvey, activeElementIdTemp);
@@ -495,7 +449,6 @@ export const ElementsView = ({
}
setActiveElementId(newElementId);
internalElementIdMap[newElementId] = createId();
setLocalSurvey(result.data);
toast.success(t("environments.surveys.edit.question_duplicated"));
@@ -524,7 +477,6 @@ export const ElementsView = ({
});
setActiveElementId(element.id);
internalElementIdMap[element.id] = createId();
};
const _addElementToBlock = (element: TSurveyElement, blockId: string, afterElementIdx: number) => {
@@ -549,7 +501,6 @@ export const ElementsView = ({
setLocalSurvey(result.data);
setActiveElementId(updatedElement.id);
internalElementIdMap[updatedElement.id] = createId();
};
const moveElementToBlock = (elementId: string, targetBlockId: string) => {
@@ -664,7 +615,6 @@ export const ElementsView = ({
const duplicatedBlock = result.data.blocks[blockIndex + 1];
if (duplicatedBlock?.elements[0]) {
setActiveElementId(duplicatedBlock.elements[0].id);
internalElementIdMap[duplicatedBlock.elements[0].id] = createId();
}
}
@@ -721,22 +671,7 @@ export const ElementsView = ({
};
//useEffect to validate survey when changes are made to languages
useEffect(() => {
if (!invalidElements) return;
let updatedInvalidElements: string[] = invalidElements;
// Validate each element
elements.forEach((element) => {
updatedInvalidElements = validateSurveyElementsInBatch(
element,
updatedInvalidElements,
surveyLanguages
);
});
if (JSON.stringify(updatedInvalidElements) !== JSON.stringify(invalidElements)) {
setInvalidElements(updatedInvalidElements);
}
}, [elements, surveyLanguages, invalidElements, setInvalidElements]);
useEffect(() => {
const elementWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, selectedLanguageCode);

View File

@@ -1,7 +1,6 @@
"use client";
import { ActionClass, Environment, Language, OrganizationRole, Project } from "@prisma/client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TSegment } from "@formbricks/types/segment";
@@ -19,6 +18,7 @@ import { StylingView } from "@/modules/survey/editor/components/styling-view";
import { SurveyEditorTabs } from "@/modules/survey/editor/components/survey-editor-tabs";
import { SurveyMenuBar } from "@/modules/survey/editor/components/survey-menu-bar";
import { TFollowUpEmailToUser } from "@/modules/survey/editor/types/survey-follow-up";
import { isEndingCardValid, isWelcomeCardValid, validateSurveyElementsInBatch } from "../lib/validation";
import { FollowUpsView } from "@/modules/survey/follow-ups/components/follow-ups-view";
import { PreviewSurvey } from "@/modules/ui/components/preview-survey";
import { refetchProjectAction } from "../actions";
@@ -85,7 +85,10 @@ export const SurveyEditor = ({
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("elements");
const [activeElementId, setActiveElementId] = useState<string | null>(null);
const [localSurvey, setLocalSurvey] = useState<TSurvey | null>(() => structuredClone(survey));
const [invalidElements, setInvalidElements] = useState<string[] | null>(null);
// Submission-time errors from SurveyMenuBar (Zod validation)
const [submissionInvalidElements, setSubmissionInvalidElements] = useState<string[] | null>(null);
const [selectedLanguageCode, setSelectedLanguageCode] = useState<string>("default");
const surveyEditorRef = useRef(null);
const [localProject, setLocalProject] = useState<Project>(project);
@@ -93,6 +96,40 @@ export const SurveyEditor = ({
const [styling, setStyling] = useState(localSurvey?.styling);
const [localStylingChanges, setLocalStylingChanges] = useState<TSurveyStyling | null>(null);
// Derived validation state to prevent infinite loops and double renders
const derivedInvalidElements = useMemo(() => {
if (!localSurvey) return [];
let invalidIds: string[] = [];
const surveyLanguages = localSurvey.languages ?? [];
// Validate Welcome Card
if (localSurvey.welcomeCard.enabled && !isWelcomeCardValid(localSurvey.welcomeCard, surveyLanguages)) {
invalidIds.push("start");
}
// Validate Endings
localSurvey.endings.forEach((ending) => {
if (!isEndingCardValid(ending, surveyLanguages)) {
invalidIds.push(ending.id);
}
});
// Validate Elements
const elements = localSurvey.blocks.flatMap(b => b.elements);
elements.forEach((element) => {
invalidIds = validateSurveyElementsInBatch(element, invalidIds, surveyLanguages);
});
return invalidIds;
}, [localSurvey]);
// Combine derived errors with submission errors
const invalidElements = useMemo(() => {
const uniqueInvalid = new Set([...derivedInvalidElements, ...(submissionInvalidElements ?? [])]);
return Array.from(uniqueInvalid);
}, [derivedInvalidElements, submissionInvalidElements]);
const fetchLatestProject = useCallback(async () => {
const refetchProjectResponse = await refetchProjectAction({ projectId: localProject.id });
if (refetchProjectResponse?.data) {
@@ -169,7 +206,7 @@ export const SurveyEditor = ({
environmentId={environment.id}
activeId={activeView}
setActiveId={setActiveView}
setInvalidElements={setInvalidElements}
setInvalidElements={setSubmissionInvalidElements}
project={localProject}
responseCount={responseCount}
selectedLanguageCode={selectedLanguageCode}
@@ -200,7 +237,7 @@ export const SurveyEditor = ({
project={localProject}
projectLanguages={projectLanguages}
invalidElements={invalidElements}
setInvalidElements={setInvalidElements}
selectedLanguageCode={selectedLanguageCode || "default"}
setSelectedLanguageCode={setSelectedLanguageCode}
isMultiLanguageAllowed={isMultiLanguageAllowed}