Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,19 @@ export const GoogleSheetWrapper = ({
(TIntegrationGoogleSheetsConfigData & { index: number }) | null
>(null);

// Depend only on stable values (workspaceId is a primitive; isConnected is local state). The
// server-derived `googleSheetIntegration` object must NOT be a dependency: every authenticated
// server action re-sets the Better Auth session cookie, which refreshes the route and hands this
// component a new `googleSheetIntegration` reference — depending on it would re-fire the action in
// an infinite loop. `isConnected` already implies an integration exists, and the action only needs
// `workspaceId`, so the object isn't needed here.
const validateConnection = useCallback(async () => {
if (!isConnected || !googleSheetIntegration) return;
if (!isConnected) return;
const response = await validateGoogleSheetsConnectionAction({ workspaceId });
if (response?.serverError === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
setShowReconnectButton(true);
}
}, [workspaceId, isConnected, googleSheetIntegration]);
}, [workspaceId, isConnected]);

useEffect(() => {
validateConnection();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,32 +158,59 @@ export const SummaryPage = ({
return registerAnalysisRefreshHandler(refreshSummary);
}, [refreshSummary, registerAnalysisRefreshHandler]);

// Dedupe fetches by the actual filter VALUES. Every authenticated server action re-sets the
// Better Auth session cookie, and `cookies().set()` inside an action makes Next.js refresh the
// route; that refresh hands this component fresh `survey`/`initialSurveySummary`/`fetchSummary`
// references. Without this guard those new references re-fire the fetch effect → another action →
// another refresh, i.e. an infinite loop.
const lastFetchedFiltersKeyRef = useRef<string | null>(null);

// Only fetch data when filters change or when there's no initial data
useEffect(() => {
// If we have initial data and no filters are applied, don't fetch
const hasNoFilters =
(!selectedFilter || Object.keys(selectedFilter).length === 0 || selectedFilter.filter?.length === 0) &&
(!dateRange || (!dateRange.from && !dateRange.to));
// A default date range only sets `to` (today) with no `from`, which means "all time" and is
// NOT an active filter. Treat filters as active only when the user has actually narrowed the data.
const hasActiveFilters =
(selectedFilter?.filter?.length ?? 0) > 0 ||
(!!selectedFilter?.responseStatus && selectedFilter.responseStatus !== "all") ||
Boolean(dateRange?.from);

if (initialSurveySummary && hasNoFilters) {
// If we have server-provided initial data and no active filters, use it instead of refetching.
// (Also covers clearing filters: fall back to the unfiltered initial summary without a fetch.)
if (initialSurveySummary && !hasActiveFilters) {
lastFetchedFiltersKeyRef.current = null;
setSurveySummary(initialSurveySummary);
setIsLoading(false);
return;
}

// Skip when only prop references changed (a route refresh) but the filter values are identical
// to the last fetch. getFormattedFilters is deterministic for fixed inputs.
const filtersKey = JSON.stringify(getFormattedFilters(survey, selectedFilter, dateRange));
if (filtersKey === lastFetchedFiltersKeyRef.current) {
return;
}
// Commit the key BEFORE awaiting so an action-triggered route refresh that re-runs this effect
// finds it already set and skips — this is what breaks the loop.
lastFetchedFiltersKeyRef.current = filtersKey;

const fetchFilteredSummary = async () => {
setIsLoading(true);

try {
await fetchSummary();
} catch (error) {
console.error(error);
// Roll back the key on failure so re-selecting the same filter retries the fetch.
lastFetchedFiltersKeyRef.current = null;
// fetchSummary throws an Error whose message is already the formatted server error.
toast.error(error instanceof Error ? error.message : t("common.something_went_wrong"));
} finally {
setIsLoading(false);
}
};

fetchFilteredSummary();
}, [selectedFilter, dateRange, initialSurveySummary, fetchSummary]);
}, [selectedFilter, dateRange, initialSurveySummary, fetchSummary, survey, t]);

const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useAutoAnimate } from "@formkit/auto-animate/react";
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
Expand Down Expand Up @@ -86,29 +86,36 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
return typeof firstOption === "object" ? getLocalizedValue(firstOption, "default") : firstOption;
};

// Keep the latest survey in a ref so the effect below can read it without listing `survey` (a
// server-derived object) as a dependency. Every authenticated server action re-sets the Better Auth
// 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);
surveyRef.current = survey;

useEffect(() => {
if (!isOpen) return;
// Fetch the initial data for the filter and load it into the state
const handleInitialData = async () => {
if (isOpen) {
const surveyFilterData = await getSurveyFilterDataAction({ surveyId: survey.id });
const survey = surveyRef.current;
const surveyFilterData = await getSurveyFilterDataAction({ surveyId: survey.id });

if (!surveyFilterData?.data) return;
if (!surveyFilterData?.data) return;

const { attributes, meta, environmentTags, hiddenFields, quotas } = surveyFilterData.data;
const { elementFilterOptions, elementOptions } = generateElementAndFilterOptions(
survey,
environmentTags,
attributes,
meta,
hiddenFields,
quotas
);
setSelectedOptions({ elementFilterOptions: elementFilterOptions, elementOptions: elementOptions });
}
const { attributes, meta, environmentTags, hiddenFields, quotas } = surveyFilterData.data;
const { elementFilterOptions, elementOptions } = generateElementAndFilterOptions(
survey,
environmentTags,
attributes,
meta,
hiddenFields,
quotas
);
setSelectedOptions({ elementFilterOptions: elementFilterOptions, elementOptions: elementOptions });
};

handleInitialData();
}, [isOpen, setSelectedOptions, survey]);
}, [isOpen, setSelectedOptions]);

const handleOnChangeElementComboBoxValue = (value: ElementOption, index: number) => {
const matchingFilterOption = selectedOptions.elementFilterOptions.find(
Expand Down
10 changes: 5 additions & 5 deletions apps/web/i18n.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2906,11 +2906,6 @@ checksums:
workspace/surveys/edit/checkbox_label: 12a07d6bdf38e283a2e95892ec49b7f8
workspace/surveys/edit/choose_the_actions_which_trigger_the_survey: 773b311a148a112243f3b139506b9987
workspace/surveys/edit/choose_the_first_question_on_your_block: bdece06ca04f89d0c445ba1554dd5b80
workspace/surveys/edit/element_category_input: c281c28cbb062bc3538cbd4a42d79cf6
workspace/surveys/edit/element_category_choice: 307101bb385166b538e9370428963461
workspace/surveys/edit/element_category_scoring: 29dbcaf797b1e9f985bbd7b5258749e4
workspace/surveys/edit/element_category_contact: 9afa39bc47019ee6dec6c74b6273967c
workspace/surveys/edit/element_category_content: 669b337031ef165af577f04f2b7fd54f
workspace/surveys/edit/choose_where_to_run_the_survey: ad87bcae97c445f1fd9ac110ea24f117
workspace/surveys/edit/city: 1831f32e1babbb29af27fac3053504a2
workspace/surveys/edit/clear_close_on_date: 673ed6940f36b02cf871ffacf034e114
Expand Down Expand Up @@ -2962,6 +2957,11 @@ checksums:
workspace/surveys/edit/duplicate_question: 910751de01fdd327165968214717711b
workspace/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54
workspace/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318
workspace/surveys/edit/element_category_choice: 307101bb385166b538e9370428963461
workspace/surveys/edit/element_category_contact: 9afa39bc47019ee6dec6c74b6273967c
workspace/surveys/edit/element_category_content: 669b337031ef165af577f04f2b7fd54f
workspace/surveys/edit/element_category_input: c281c28cbb062bc3538cbd4a42d79cf6
workspace/surveys/edit/element_category_scoring: 29dbcaf797b1e9f985bbd7b5258749e4
workspace/surveys/edit/element_not_found: 196777ff6811dd177971ffc8e27a72c1
workspace/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: c70466147d49dcbb3686452f35c46428
workspace/surveys/edit/enable_recaptcha_to_protect_your_survey_from_spam: 4483a5763718d201ac97caa1e1216e13
Expand Down
16 changes: 8 additions & 8 deletions apps/web/modules/analysis/components/RatingSmiley/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,19 +82,19 @@ const getSmiley = ({
);

return (
<table
<table // NOSONAR S5256 - Need table layout for email compatibility (gmail)
style={{
width: `${containerSize}px`,
height: `${containerSize}px`,
...(centered ? { marginLeft: "auto", marginRight: "auto" } : {}),
}}>
{" "}
{/* NOSONAR S5256 - Need table layout for email compatibility (gmail) */}
<tr>
<td align="center" valign="middle">
{icon}
</td>
</tr>
<tbody>
<tr>
<td align="center" valign="middle">
{icon}
</td>
</tr>
</tbody>
</table>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,12 @@ export function useChartDialog({
return () => {
cancelled = true;
};
// Key on initialChart?.id, NOT the object reference. Every authenticated action here
// (getChartAction / executeQueryAction) re-sets the Better Auth session cookie → Next.js route
// refresh → new `initialChart` reference; depending on the object would re-fire executeQueryAction on
// every refresh → infinite loop. The id is the stable identity; content is unchanged across refreshes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, chartId, workspaceId, initialChart]);
}, [open, chartId, workspaceId, initialChart?.id]);

const handleChartGenerated = (data: AnalyticsResponse) => {
setChartData(data);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { zodResolver } from "@hookform/resolvers/zod";
import { FingerprintIcon, LanguagesIcon, PlusIcon, SparklesIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
Expand Down Expand Up @@ -154,11 +154,18 @@ export const FeedbackRecordFormDrawer = ({
setCustomSourceType("");
}, [directories, form]);

// resetForCreate depends on `directories`, which is derived from a server prop (frdMap) that gets a
// fresh reference on every route refresh. Every authenticated action here (retrieveFeedbackRecordAction)
// re-sets the Better Auth session cookie → route refresh → new resetForCreate ref → this effect would
// re-fire the action → infinite loop. Read it through a ref so it isn't an effect trigger.
const resetForCreateRef = useRef(resetForCreate);
resetForCreateRef.current = resetForCreate;

useEffect(() => {
if (!open) return;

if (mode === "create") {
resetForCreate();
resetForCreateRef.current();
return;
}

Expand All @@ -184,7 +191,7 @@ export const FeedbackRecordFormDrawer = ({
};

void loadRecord();
}, [form, mode, onOpenChange, open, recordId, resetForCreate, t, workspaceId]);
}, [form, mode, onOpenChange, open, recordId, t, workspaceId]);

const requestClose = useCallback(() => {
if (form.formState.isDirty && !isSubmitting) {
Expand Down
22 changes: 22 additions & 0 deletions apps/web/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,26 @@ describe("proxy", () => {

expect(response.cookies.get("formbricks-workspace-id")).toBeUndefined();
});

test("does not re-set the active-workspace cookie when the request already carries the same value", async () => {
mockGetProxySession.mockResolvedValue(null);

const request = new NextRequest("http://localhost:3000/workspaces/ws-123/surveys");
request.cookies.set("formbricks-workspace-id", "ws-123");

const response = await proxy(request);

expect(response.cookies.get("formbricks-workspace-id")).toBeUndefined();
});

test("updates the active-workspace cookie when navigating to a different workspace", async () => {
mockGetProxySession.mockResolvedValue(null);

const request = new NextRequest("http://localhost:3000/workspaces/ws-456/surveys");
request.cookies.set("formbricks-workspace-id", "ws-123");

const response = await proxy(request);

expect(response.cookies.get("formbricks-workspace-id")?.value).toBe("ws-456");
});
});
8 changes: 7 additions & 1 deletion apps/web/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,14 @@ export const proxy = async (originalRequest: NextRequest) => {

// Remember the active workspace so the workspace-agnostic org-settings shell can resolve it
// server-side (localStorage is browser-only). Mirrors the /workspaces/[workspaceId] path segment.
// Only set the cookie when the value actually changes: a Set-Cookie on a server-action POST makes
// Next.js treat the action as revalidated, forcing a router refresh after every action — on pages
// that call an action on mount this becomes an infinite POST/refresh loop.
const workspaceMatch = /^\/workspaces\/([^/]+)/.exec(request.nextUrl.pathname);
if (workspaceMatch?.[1]) {
if (
workspaceMatch?.[1] &&
request.cookies.get(FORMBRICKS_WORKSPACE_ID_COOKIE)?.value !== workspaceMatch[1]
) {
nextResponseWithCustomHeader.cookies.set(FORMBRICKS_WORKSPACE_ID_COOKIE, workspaceMatch[1], {
path: "/",
sameSite: "lax",
Expand Down
Loading
Loading