diff --git a/components/clientComponents/forms/ConditionalWrapper/ConditionalWrapper.tsx b/components/clientComponents/forms/ConditionalWrapper/ConditionalWrapper.tsx index b1f829528b..cbe967ac32 100644 --- a/components/clientComponents/forms/ConditionalWrapper/ConditionalWrapper.tsx +++ b/components/clientComponents/forms/ConditionalWrapper/ConditionalWrapper.tsx @@ -1,8 +1,7 @@ "use client"; -import React from "react"; -import { useGCFormsContext } from "@lib/hooks/useGCFormContext"; +import { useGCFormsContext, useVisibilityContext } from "@lib/hooks/useGCFormContext"; import { type ConditionalRule, type FormElement } from "@gcforms/types"; -import { inGroup, checkVisibilityRecursive } from "@gcforms/core"; +import { inGroup } from "@gcforms/core"; export const ConditionalWrapper = ({ children, @@ -14,7 +13,8 @@ export const ConditionalWrapper = ({ rules: ConditionalRule[] | null; lang: string; }) => { - const { getValues, currentGroup, groups, formRecord } = useGCFormsContext(); + const { currentGroup, groups } = useGCFormsContext(); + const { isElementVisible } = useVisibilityContext(); // Check if the element is a child of a dynamic element if (element.subId) { @@ -26,7 +26,6 @@ export const ConditionalWrapper = ({ currentGroup && groups && Object.keys(groups).length >= 1 && - groups && !inGroup(currentGroup, element.id, groups) ) return null; @@ -34,11 +33,7 @@ export const ConditionalWrapper = ({ // If there's no rule or no choiceId, just return the children if (!rules || rules.length < 1) return children; - const values = getValues() || {}; - - const isVisible = checkVisibilityRecursive(formRecord, element, values); - - if (!isVisible) { + if (!isElementVisible(element.id.toString())) { return null; } diff --git a/lib/hooks/useCustomEvent.tsx b/lib/hooks/useCustomEvent.tsx index f73a8e7d19..be1b36e301 100644 --- a/lib/hooks/useCustomEvent.tsx +++ b/lib/hooks/useCustomEvent.tsx @@ -26,8 +26,14 @@ export const EventKeys = { submitProgress: "submit-progress", openUnconfirmedApiKeyDialog: "open-unconfirmed-api-key-dialog", openCreateDraftConfirmDialog: "open-create-draft-confirm-dialog", + formValuesChanged: "form-values-changed", } as const; +// Per-callback, per-event wrapper registry so the same callback can be +// registered/removed for multiple event names without interference. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const listenerMap = new WeakMap<(detail: any) => void, Map void>>(); + export const useCustomEvent = () => { // Attach listeners to a documentRef instead of document directly // https://www.preciousorigho.com/articles/a-better-way-to-create-event-listeners-in-react @@ -55,10 +61,23 @@ export const useCustomEvent = () => { * @param callback (detail: CustomEventDetails) => void */ on: (eventName, callback) => { - documentRef.current && - documentRef.current.addEventListener(eventName, (event: Event) => { - callback((event as CustomEvent).detail); - }); + if (!documentRef.current) return; + const wrapper = (event: Event) => { + callback((event as CustomEvent).detail); + }; + let perEvent = listenerMap.get(callback); + if (!perEvent) { + perEvent = new Map(); + listenerMap.set(callback, perEvent); + } + // Remove any previously registered wrapper for this (callback, eventName) pair + // before adding the new one, to prevent duplicate listeners and leaks. + const existing = perEvent.get(eventName); + if (existing) { + documentRef.current.removeEventListener(eventName, existing); + } + perEvent.set(eventName, wrapper); + documentRef.current.addEventListener(eventName, wrapper); }, /** @@ -67,10 +86,16 @@ export const useCustomEvent = () => { * @param callback (detail: CustomEventDetails) => void */ off: (eventName, callback) => { - documentRef.current && - documentRef.current.removeEventListener(eventName, (event: Event) => { - callback((event as CustomEvent).detail); - }); + if (!documentRef.current) return; + const perEvent = listenerMap.get(callback); + const wrapper = perEvent?.get(eventName); + if (wrapper) { + documentRef.current.removeEventListener(eventName, wrapper); + perEvent!.delete(eventName); + if (perEvent!.size === 0) { + listenerMap.delete(callback); + } + } }, }; diff --git a/lib/hooks/useGCFormContext.tsx b/lib/hooks/useGCFormContext.tsx index 229a921762..06828484e3 100644 --- a/lib/hooks/useGCFormContext.tsx +++ b/lib/hooks/useGCFormContext.tsx @@ -4,6 +4,7 @@ import React, { createContext, useContext, ReactNode, useCallback } from "react" import { type FormValues, type GroupsType, type PublicFormRecord } from "@gcforms/types"; import { type Language } from "@lib/types/form-builder-types"; import { getGroupTitle as groupTitle } from "@lib/utils/getGroupTitle"; +import { useCustomEvent, EventKeys } from "@lib/hooks/useCustomEvent"; import { getNextAction, filterValuesByVisibleElements, idArraysMatch } from "@lib/formContext"; @@ -11,6 +12,11 @@ import { mapIdsToValues, getValuesWithMatchedIds, getVisibleGroupsBasedOnValuesRecursive, + buildElementDependencies, + buildElementMap, + computeAllVisibility, + recomputeAffectedVisibility, + getChangedChoiceElementIds, } from "@gcforms/core"; import { formHasGroups } from "@lib/utils/form-builder/formHasGroups"; @@ -73,6 +79,12 @@ interface GCFormsContextValueType { const GCFormsContext = createContext(undefined); +interface VisibilityContextValueType { + isElementVisible: (elementId: string) => boolean; +} + +const VisibilityContext = createContext(undefined); + export const GCFormsProvider = ({ children, formRecord, @@ -82,6 +94,7 @@ export const GCFormsProvider = ({ formRecord: PublicFormRecord; nonce?: string; }) => { + const { Event } = useCustomEvent(); const groups: GroupsType = formRecord.form.groups || {}; const initialGroup = groups ? LOCKED_GROUPS.START : null; const values = React.useRef({}); @@ -91,6 +104,26 @@ export const GCFormsProvider = ({ const [submissionId, setSubmissionId] = React.useState(undefined); const [submissionDate, setSubmissionDate] = React.useState(undefined); + // Initialize visibility state with element dependencies + const elementDependencies = React.useMemo( + () => buildElementDependencies(formRecord.form.elements), + [formRecord.form.elements] + ); + + // Build the element lookup map once — elements don't change during a session. + // Passed to recomputeAffectedVisibility to avoid rebuilding it on every value change. + const elementMap = React.useMemo( + () => buildElementMap(formRecord.form.elements), + [formRecord.form.elements] + ); + + const [visibilityMap, setVisibilityMap] = React.useState>(() => + computeAllVisibility(formRecord, {}) + ); + // Ref stays in sync with state so updateValues always reads the latest map + // synchronously, even if multiple updates arrive before the next re-render. + const visibilityMapRef = React.useRef(visibilityMap); + const hasNextAction = (group: string) => { return groups[group]?.nextAction ? true : false; }; @@ -136,11 +169,56 @@ export const GCFormsProvider = ({ }: { formValues: Record; }): void => { + const oldValues = values.current; values.current = formValues; const valueIds = mapIdsToValues(formRecord.form.elements, formValues); if (!idArraysMatch(matchedIds, valueIds)) { setMatchedIds(valueIds); } + + const changedChoiceIds = getChangedChoiceElementIds( + oldValues, + formValues, + formRecord.form.elements, + elementDependencies, + elementMap + ); + + if (changedChoiceIds.length > 0) { + // Read the latest map from the ref (not stale closure state) and recompute + const prevVisibilityMap = visibilityMapRef.current; + const updatedVisibility = recomputeAffectedVisibility( + formRecord, + formValues, + changedChoiceIds, + elementDependencies, + prevVisibilityMap, + elementMap + ); + + // Keep the ref in sync so rapid successive calls use the latest base map + visibilityMapRef.current = updatedVisibility; + // Pure state update - no side effects inside the setter + setVisibilityMap(updatedVisibility); + + // Build the diff outside the setter so it's available to the event payload + const visibilityChanges: Record = {}; + updatedVisibility.forEach((isVisible, id) => { + if (prevVisibilityMap.get(id) !== isVisible) { + visibilityChanges[id] = isVisible; + } + }); + + // Deferring to next tick because CustomEvent dispatch is synchronous and + // firing inline triggers a React "setState during render" warning + queueMicrotask(() => { + Event.fire(EventKeys.formValuesChanged, { + changedChoiceIds, + visibilityChanges, + values: formValues, + }); + }); + } }; // Helper to not expose the setter @@ -156,6 +234,13 @@ export const GCFormsProvider = ({ return nonce || ""; }; + const isElementVisible = useCallback( + (elementId: string): boolean => { + return visibilityMap.get(elementId) ?? true; + }, + [visibilityMap] + ); + // TODO: once groups flag is on, just use formHasGroups const groupsCheck = (groupsFlag: boolean | undefined) => { // Check that the conditional logic flag is on and that this is a groups enabled form @@ -278,7 +363,9 @@ export const GCFormsProvider = ({ getNonce, }} > - {children} + + {children} + ); }; @@ -330,3 +417,12 @@ export const useGCFormsContext = () => { } return formsContext; }; + +export const useVisibilityContext = () => { + const ctx = useContext(VisibilityContext); + // Outside the provider (e.g. tests, Storybook) treat every element as visible + if (ctx === undefined) { + return { isElementVisible: () => true }; + } + return ctx; +}; diff --git a/lib/vitests/visibility.test.ts b/lib/vitests/visibility.test.ts new file mode 100644 index 0000000000..a2da2bdd75 --- /dev/null +++ b/lib/vitests/visibility.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect } from "vitest"; +import { + buildElementDependencies, + collectDependentElements, + computeAllVisibility, + recomputeAffectedVisibility, + getChangedChoiceElementIds, + initializeVisibilityState, +} from "@gcforms/core"; +import { FormElement, PublicFormRecord, FormValues } from "@gcforms/types"; + +// TODO: These tests only cover the updated visibility logic (not the old). +// Could also test more edge-like cases. + +describe("visibility", () => { + // Helper to create a simple form element + const createElement = ( + id: number, + type: string = "textField", + conditionalRules: Array<{ choiceId: string }> = [] + ): FormElement => { + return { + id, + type, + properties: { + titleEn: `Element ${id}`, + titleFr: `Element ${id}`, + conditionalRules, + choices: + type === "radio" + ? [ + { en: "Option A", fr: "Option A" }, + { en: "Option B", fr: "Option B" }, + ] + : undefined, + }, + } as FormElement; + }; + + describe("buildElementDependencies", () => { + it("should build element dependencies from conditional rules", () => { + const elements = [ + createElement(1, "radio"), + createElement(2, "textField", [{ choiceId: "1.0" }]), + createElement(3, "textField", [{ choiceId: "1.1" }]), + ]; + + const elementDependencies = buildElementDependencies(elements); + + expect(elementDependencies.has("1")).toBe(true); + expect(elementDependencies.get("1")).toEqual(new Set(["2", "3"])); + }); + + it("should handle elements with no rules", () => { + const elements = [createElement(1, "textField"), createElement(2, "textField")]; + + const elementDependencies = buildElementDependencies(elements); + + expect(elementDependencies.size).toBe(0); + }); + + it("should handle multiple elements depending on the same parent", () => { + const elements = [ + createElement(1, "radio"), + createElement(2, "textField", [{ choiceId: "1.0" }]), + createElement(3, "textField", [{ choiceId: "1.1" }]), + createElement(4, "textField", [{ choiceId: "1.0" }]), + ]; + + const elementDependencies = buildElementDependencies(elements); + + expect(elementDependencies.get("1")).toEqual(new Set(["2", "3", "4"])); + }); + }); + + describe("collectDependentElements", () => { + it("should collect direct dependents", () => { + const elementDependencies = new Map([["1", new Set(["2", "3"])]]); + + const dependents = collectDependentElements(["1"], elementDependencies); + + expect(dependents).toEqual(new Set(["2", "3"])); + }); + + it("should collect transitive dependents", () => { + const elementDependencies = new Map([ + ["1", new Set(["2"])], + ["2", new Set(["3"])], + ]); + + const dependents = collectDependentElements(["1"], elementDependencies); + + expect(dependents).toEqual(new Set(["2", "3"])); + }); + + it("should handle circular dependencies", () => { + const elementDependencies = new Map([ + ["1", new Set(["2"])], + ["2", new Set(["1"])], + ]); + + const dependents = collectDependentElements(["1"], elementDependencies); + + // Should not infinite loop and should collect both + expect(dependents).toEqual(new Set(["2", "1"])); + }); + + it("should return empty set when element has no dependents", () => { + const elementDependencies = new Map([["1", new Set(["2"])]]); + + const dependents = collectDependentElements(["3"], elementDependencies); + + expect(dependents.size).toBe(0); + }); + }); + + describe("computeAllVisibility", () => { + it("should compute visibility for all elements", () => { + const formRecord: PublicFormRecord = { + id: "test", + form: { + elements: [ + createElement(1, "radio"), + createElement(2, "textField", [{ choiceId: "1.0" }]), + createElement(3, "textField"), + ], + }, + } as PublicFormRecord; + + const values: FormValues = { "1": "Option A" }; + + const visibilityMap = computeAllVisibility(formRecord, values); + + expect(visibilityMap.get("1")).toBe(true); // Radio is always visible + expect(visibilityMap.get("2")).toBe(true); // Dependent on 1.0, and 1 has value "Option A" + expect(visibilityMap.get("3")).toBe(true); // No rules, always visible + }); + + it("should handle elements without rules as visible", () => { + const formRecord: PublicFormRecord = { + id: "test", + form: { + elements: [createElement(1, "textField"), createElement(2, "textField")], + }, + } as PublicFormRecord; + + const visibilityMap = computeAllVisibility(formRecord, {}); + + expect(visibilityMap.get("1")).toBe(true); + expect(visibilityMap.get("2")).toBe(true); + }); + }); + + describe("getChangedChoiceElementIds", () => { + it("should detect changed choice elements", () => { + const elements = [ + createElement(1, "radio"), + createElement(2, "textField", [{ choiceId: "1.0" }]), + ]; + const elementDependencies = buildElementDependencies(elements); + + const oldValues: FormValues = {}; + const newValues: FormValues = { "1": "Option A" }; + + const changed = getChangedChoiceElementIds(oldValues, newValues, elements, elementDependencies); + + expect(changed).toEqual(["1"]); + }); + + it("should only return choice elements that have dependents", () => { + const elements = [ + createElement(1, "radio"), + createElement(2, "radio"), + createElement(3, "textField", [{ choiceId: "1.0" }]), + ]; + const elementDependencies = buildElementDependencies(elements); + + const oldValues: FormValues = {}; + const newValues: FormValues = { "1": "Option A", "2": "Option B" }; + + const changed = getChangedChoiceElementIds(oldValues, newValues, elements, elementDependencies); + + // Only element 1 should be returned because only it has dependents + expect(changed).toEqual(["1"]); + }); + + it("should not return non-choice elements", () => { + const elements = [ + createElement(1, "textField"), + createElement(2, "textField", [{ choiceId: "1" }]), + ]; + const elementDependencies = buildElementDependencies(elements); + + const oldValues: FormValues = {}; + const newValues: FormValues = { "1": "some text" }; + + const changed = getChangedChoiceElementIds(oldValues, newValues, elements, elementDependencies); + + expect(changed).toEqual([]); + }); + }); + + describe("recomputeAffectedVisibility", () => { + it("should only recompute visibility for affected elements", () => { + const formRecord: PublicFormRecord = { + id: "test", + form: { + elements: [ + createElement(1, "radio"), + createElement(2, "textField", [{ choiceId: "1.0" }]), + createElement(3, "textField"), + ], + }, + } as PublicFormRecord; + + const elementDependencies = buildElementDependencies(formRecord.form.elements); + const initialMap = new Map([ + ["1", true], + ["2", false], + ["3", true], + ]); + + const values: FormValues = { "1": "Option A" }; + + const updatedMap = recomputeAffectedVisibility( + formRecord, + values, + ["1"], + elementDependencies, + initialMap + ); + + // Element 2 should be recomputed to true + expect(updatedMap.get("2")).toBe(true); + // Element 3 should remain unchanged + expect(updatedMap.get("3")).toBe(true); + }); + }); + + describe("initializeVisibilityState", () => { + it("should initialize visibility state with element dependencies and visibility map", () => { + const formRecord: PublicFormRecord = { + id: "test", + form: { + elements: [ + createElement(1, "radio"), + createElement(2, "textField", [{ choiceId: "1.0" }]), + ], + }, + } as PublicFormRecord; + + const values: FormValues = {}; + + const state = initializeVisibilityState(formRecord, values); + + expect(state.elementDependencies).toBeDefined(); + expect(state.visibilityMap).toBeDefined(); + expect(state.elementDependencies.has("1")).toBe(true); + expect(state.visibilityMap.get("1")).toBe(true); + }); + }); +}); diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index c681e86050..9f31b88375 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [2.2.15] - 2026-07-14 + +- Refactor visibility to be event driven to improve performance and maintainability + ## [2.2.14] - 2026-06-15 - Replaced the self-import from @gcforms/core with a local import diff --git a/packages/core/package.json b/packages/core/package.json index 433c2090c6..022221c6aa 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@gcforms/core", - "version": "2.2.14", + "version": "2.2.15", "author": "Canadian Digital Service", "license": "MIT", "publishConfig": { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1976583498..696f09abd7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -27,6 +27,15 @@ export { checkPageVisibility, checkVisibilityRecursive, isElementVisible, + type ElementDependencies, + type VisibilityState, + buildElementDependencies, + buildElementMap, + collectDependentElements, + computeAllVisibility, + recomputeAffectedVisibility, + getChangedChoiceElementIds, + initializeVisibilityState, } from "./visibility"; export { diff --git a/packages/core/src/visibility.ts b/packages/core/src/visibility.ts index f0cf9990c1..b6c98795b3 100644 --- a/packages/core/src/visibility.ts +++ b/packages/core/src/visibility.ts @@ -1,13 +1,19 @@ import { FormElement, PublicFormRecord, GroupsType, FormValues } from "@gcforms/types"; - import { - getElementById, + isChoiceInputType, matchRule, getValuesWithMatchedIds, findGroupByElementId, inGroup, } from "./helpers"; +export type ElementDependencies = Map>; + +// Helper to build an element lookup map to replace slower array searches +export const buildElementMap = (elements: FormElement[]): Map => { + return new Map(elements.map((el) => [el.id.toString(), el])); +}; + /** * Recursively traverses the form groups to build a list of visible groups based on values. * Essentially, we are reconstructing the group history (the path the @@ -37,7 +43,6 @@ export const getVisibleGroupsBasedOnValuesRecursive = ( return visibleGroups; } - // Push the current group to visibleGroups visibleGroups.push(currentGroup); // If there is no nextAction, treat as "end" @@ -56,7 +61,9 @@ export const getVisibleGroupsBasedOnValuesRecursive = ( nextAction, visibleGroups ); - } else if (Array.isArray(nextAction)) { + } + + if (Array.isArray(nextAction)) { const currentGroupElementIds = new Set((groups[currentGroup].elements || []).map(String)); const relevantNextActions = nextAction.filter((action) => { if (action.choiceId.includes("catch-all")) { @@ -73,7 +80,6 @@ export const getVisibleGroupsBasedOnValuesRecursive = ( const catchAllRule = relevantNextActions.find((action) => action.choiceId.includes("catch-all") ); - let matchFound = false; for (const action of relevantNextActions) { @@ -117,7 +123,8 @@ export const getVisibleGroupsBasedOnValuesRecursive = ( export const checkPageVisibility = ( formRecord: PublicFormRecord, element: FormElement, - values: FormValues + values: FormValues, + precomputedVisibleGroups?: Set ): boolean => { // If groups object is empty or not defined, default to visible if (!formRecord.form.groups || Object.keys(formRecord.form.groups).length === 0) { @@ -126,6 +133,12 @@ export const checkPageVisibility = ( // Get the current element's group ID const groupId = findGroupByElementId(formRecord, element.id); + if (!groupId) return false; + + // Use pre-computed set if it exists to avoid walking the "tree" repeatedly + if (precomputedVisibleGroups) { + return precomputedVisibleGroups.has(groupId); + } // Get an array of values with matched ids instead of raw values const valuesWithMatchedIds = getValuesWithMatchedIds(formRecord.form.elements, values); @@ -161,18 +174,10 @@ export const checkVisibilityRecursive = ( formRecord: PublicFormRecord, element: FormElement, values: FormValues, - checked: Record = {} + checked: Record = {}, + elementMap?: Map, + visibleGroupsCache?: Set ): boolean => { - // If the current page is not visible, the element is not visible - if (!checkPageVisibility(formRecord, element, values)) { - return false; - } - - const rules = element.properties.conditionalRules; - if (!rules || rules.length === 0) return true; - - const formElements = formRecord.form.elements; - const elId = element.id.toString(); // Check if already computed @@ -180,6 +185,32 @@ export const checkVisibilityRecursive = ( return checked[elId]; } + // Resolve or initialize helper functions dynamically + const activeElementMap = elementMap ?? buildElementMap(formRecord.form.elements); + + const hasGroups = !!formRecord.form.groups && Object.keys(formRecord.form.groups).length > 0; + const activeVisibleGroups = + visibleGroupsCache ?? + (hasGroups + ? new Set( + getVisibleGroupsBasedOnValuesRecursive( + formRecord, + getValuesWithMatchedIds(formRecord.form.elements, values), + "start" + ) + ) + : undefined); + if (!checkPageVisibility(formRecord, element, values, activeVisibleGroups)) { + checked[elId] = false; + return false; + } + + const rules = element.properties.conditionalRules; + if (!rules || rules.length === 0) { + checked[elId] = true; + return true; + } + // Mark as being checked (prevents circular dependency infinite loops) // Assume false during recursion to break cycles checked[elId] = false; @@ -187,12 +218,18 @@ export const checkVisibilityRecursive = ( // At least one rule must be satisfied for the element to be visible const isVisible = rules.some((rule) => { const [elementId] = rule.choiceId.split("."); - const ruleParent = getElementById(formElements, elementId); - if (!ruleParent) return matchRule(rule, formElements, values); + const ruleParent = activeElementMap.get(elementId); + if (!ruleParent) return matchRule(rule, formRecord.form.elements, values); return ( - checkVisibilityRecursive(formRecord, ruleParent, values, checked) && - matchRule(rule, formElements, values) + checkVisibilityRecursive( + formRecord, + ruleParent, + values, + checked, + activeElementMap, + activeVisibleGroups + ) && matchRule(rule, formRecord.form.elements, values) ); }); @@ -204,19 +241,262 @@ export const checkVisibilityRecursive = ( export const isElementVisible = ( currentGroup: string | undefined, - groups: GroupsType, + groups: GroupsType | undefined, values: FormValues, formRecord: PublicFormRecord, - element: FormElement -) => { - if ( - currentGroup && - groups && - Object.keys(groups).length >= 1 && - groups && - !inGroup(currentGroup, element.id, groups) - ) - return false; + element: FormElement, + elementMap?: Map, + checked: Record = {} +): boolean => { + if (currentGroup && groups && Object.keys(groups).length > 0) { + if (!inGroup(currentGroup, element.id, groups)) return false; + } + + return checkVisibilityRecursive(formRecord, element, values, checked, elementMap); +}; + +/** + * Builds an element dependencies map showing which elements depend on which other elements + * based on conditional rules. + * + * @param formElements - The form elements to analyze + * @returns A map where keys are element IDs and values are sets of element IDs that depend on them + */ +export const buildElementDependencies = (formElements: FormElement[]): ElementDependencies => { + const elementDependencies: ElementDependencies = new Map(); + + formElements.forEach((element) => { + const rules = element.properties.conditionalRules; + if (!rules || rules.length === 0) return; + + rules.forEach((rule) => { + const [parentElementId] = rule.choiceId.split("."); + let dependents = elementDependencies.get(parentElementId); + if (!dependents) { + dependents = new Set(); + elementDependencies.set(parentElementId, dependents); + } + dependents.add(element.id.toString()); + }); + }); + + return elementDependencies; +}; + +/** + * Collects all elements that depend on the given element IDs. + * This handles chains of dependencies (A depends on B, B depends on C, etc.) + * + * Note: could also use a stack for slight perf gains but IMO recursion is more readable. + * + * @param elementIds - The starting element IDs to check dependencies for + * @param elementDependencies - The element dependencies map + * @param visited - Set of already visited element IDs to prevent infinite loops + * @returns A set of all element IDs that directly or transitively depend on the input elements + */ +export const collectDependentElements = ( + initialElementIds: string[], + elementDependencies: ElementDependencies +): Set => { + const dependents = new Set(); + // Separate visited set so the initial element IDs don't accidentally block + // their own dependents from being traversed (also handles circular deps) + const visited = new Set(); + + const traverse = (currentIds: string[]) => { + currentIds.forEach((id) => { + if (visited.has(id)) return; + visited.add(id); + + const directDependents = elementDependencies.get(id); + if (!directDependents) return; + + directDependents.forEach((depId) => dependents.add(depId)); + traverse(Array.from(directDependents)); + }); + }; + + traverse(initialElementIds); + + return dependents; +}; + +/** + * Computes the visibility of all form elements at once. + * + * @param formRecord - The form record + * @param values - The current form values + * @returns A map of element IDs to their visibility status + */ +export const computeAllVisibility = ( + formRecord: PublicFormRecord, + values: FormValues +): Map => { + const visibilityMap = new Map(); + const checked: Record = {}; + + const elementMap = buildElementMap(formRecord.form.elements); + + const hasGroups = !!formRecord.form.groups && Object.keys(formRecord.form.groups).length > 0; + const visibleGroupsCache = hasGroups + ? new Set( + getVisibleGroupsBasedOnValuesRecursive( + formRecord, + getValuesWithMatchedIds(formRecord.form.elements, values), + "start" + ) + ) + : undefined; + formRecord.form.elements.forEach((element) => { + const isVisible = checkVisibilityRecursive( + formRecord, + element, + values, + checked, + elementMap, + visibleGroupsCache + ); + visibilityMap.set(element.id.toString(), isVisible); + }); + + return visibilityMap; +}; + +/** + * Recomputes visibility only for elements that depend on the changed elements. + * + * @param formRecord - The form record + * @param values - The current form values + * @param changedElementIds - IDs of elements whose values changed + * @param elementDependencies - The pre-computed element dependencies map + * @param currentVisibilityMap - The current visibility map to update + * @param cachedElementMap - Optional pre-built element lookup map (avoids rebuilding on every call) + * @returns Updated visibility map + */ +export const recomputeAffectedVisibility = ( + formRecord: PublicFormRecord, + values: FormValues, + changedElementIds: string[], + elementDependencies: ElementDependencies, + currentVisibilityMap: Map, + cachedElementMap?: Map +): Map => { + // If the form uses groups, page visibility can change independently of conditional rules. + // To keep the visibility map correct, recompute everything. + if (formRecord.form.groups && Object.keys(formRecord.form.groups).length > 0) { + return computeAllVisibility(formRecord, values); + } + + // Collect all elements that depend on the changed elements + const affectedElementIds = collectDependentElements(changedElementIds, elementDependencies); + + // If no elements are affected, return the current map + if (affectedElementIds.size === 0) { + return currentVisibilityMap; + } - return checkVisibilityRecursive(formRecord, element, values); + // Create a new map to avoid mutating the current one + const updatedMap = new Map(currentVisibilityMap); + // Reuse caller-provided map to avoid rebuilding on every call (elements are static) + const elementMap = cachedElementMap ?? buildElementMap(formRecord.form.elements); + + // No groups here (we return early above when groups are enabled), so avoid building visibleGroupsCache. + const visibleGroupsCache = undefined; + // Build a cache + const checked: Record = {}; + currentVisibilityMap.forEach((val, key) => { + if (!affectedElementIds.has(key)) { + checked[key] = val; + } + }); + + // Recompute visibility only for affected elements + affectedElementIds.forEach((elementId) => { + const element = elementMap.get(elementId); + if (element) { + const isVisible = checkVisibilityRecursive( + formRecord, + element, + values, + checked, + elementMap, + visibleGroupsCache + ); + updatedMap.set(elementId, isVisible); + } + }); + + return updatedMap; +}; + +/** + * Determines which element IDs have changed between two sets of form values. + * Only returns IDs for choice-based elements (radio, checkbox, dropdown) that have conditional dependents. + * + * @param oldValues - The previous form values + * @param newValues - The new form values + * @param formElements - The form elements + * @param elementDependencies - The element dependencies map to check which elements have dependents + * @returns Array of element IDs that changed and have dependents + */ +export const getChangedChoiceElementIds = ( + oldValues: FormValues, + newValues: FormValues, + formElements: FormElement[], + elementDependencies: ElementDependencies, + cachedElementMap?: Map +): string[] => { + const changedIds: string[] = []; + const allKeys = new Set([...Object.keys(oldValues), ...Object.keys(newValues)]); + const elementMap = cachedElementMap ?? buildElementMap(formElements); + allKeys.forEach((key) => { + // Shallow comparison - FormValues is Record so + // JSON.stringify is unnecessarily expensive and allocates on every update. + const oldVal = oldValues[key]; + const newVal = newValues[key]; + if (oldVal === newVal) return; + if ( + Array.isArray(oldVal) && + Array.isArray(newVal) && + oldVal.length === newVal.length && + oldVal.every((v, i) => v === newVal[i]) + ) + return; + + // Check if this element is a choice-based input + const element = elementMap.get(key); + if (!element || !isChoiceInputType(element.type)) return; + + // Check if any elements depend on this one + if (elementDependencies.has(key)) { + changedIds.push(key); + } + }); + + return changedIds; +}; + +export interface VisibilityState { + visibilityMap: Map; + elementDependencies: ElementDependencies; +} + +/** + * Initializes the visibility state for a form + * + * @param formRecord - The form record + * @param values - Initial form values + * @returns Initial visibility state + */ +export const initializeVisibilityState = ( + formRecord: PublicFormRecord, + values: FormValues +): VisibilityState => { + const elementDependencies = buildElementDependencies(formRecord.form.elements); + const visibilityMap = computeAllVisibility(formRecord, values); + + return { + visibilityMap, + elementDependencies, + }; };