Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
cec69ce
Add start on visibility refactor
thiessenp-cds Jul 13, 2026
09ae10b
Update to use our Custom Event vs native
thiessenp-cds Jul 13, 2026
fcf636c
Combine old and new visibility functions and add a start at unit tests
thiessenp-cds Jul 14, 2026
3a262d8
Minor visibility refactoring
thiessenp-cds Jul 14, 2026
d7faba3
Bump the version
thiessenp-cds Jul 14, 2026
63f11e4
Merge branch 'main' into show-hide-event
thiessenp-cds Jul 14, 2026
49754b7
Merge branch 'show-hide-event' of https://github.com/cds-snc/platform…
thiessenp-cds Jul 14, 2026
db8f83f
Update a few comments
thiessenp-cds Jul 14, 2026
12749c4
Update more comments
thiessenp-cds Jul 14, 2026
bb53671
Update a comment
thiessenp-cds Jul 15, 2026
e33e5cb
Use recursion instead of stack for readabilit
thiessenp-cds Jul 15, 2026
248300f
Adds few performance and bug fixes
thiessenp-cds Jul 15, 2026
b19a55f
Merge branch 'main' into show-hide-event
thiessenp-cds Jul 15, 2026
fd4e983
Merge branch 'main' into show-hide-event
thiessenp-cds Jul 15, 2026
3e3bf79
Merge branch 'show-hide-event' of https://github.com/cds-snc/platform…
thiessenp-cds Jul 15, 2026
5753cd4
Merge branch 'main' into show-hide-event
thiessenp-cds Jul 15, 2026
4e269bb
Apply suggestions from code review
thiessenp-cds Jul 15, 2026
e959b6d
Apply github copilot suggestion
thiessenp-cds Jul 15, 2026
a2b94aa
Apply suggestions from code review
thiessenp-cds Jul 15, 2026
42f794f
Fix a few copilot sugestions...
thiessenp-cds Jul 15, 2026
a39807e
Update a few comments
thiessenp-cds Jul 16, 2026
ef83fd3
Merge branch 'main' into show-hide-event
thiessenp-cds Jul 16, 2026
d3864ca
Moved visibilityMap to own provider for slight perf gain
thiessenp-cds Jul 16, 2026
3e3b7f5
Merge branch 'main' into show-hide-event
thiessenp-cds Jul 16, 2026
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
@@ -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,
Expand All @@ -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) {
Expand All @@ -26,19 +26,14 @@ export const ConditionalWrapper = ({
currentGroup &&
groups &&
Object.keys(groups).length >= 1 &&
groups &&
!inGroup(currentGroup, element.id, groups)
)
return null;

// 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;
}

Expand Down
41 changes: 33 additions & 8 deletions lib/hooks/useCustomEvent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, (event: Event) => 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
Expand Down Expand Up @@ -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);
};
Comment thread
thiessenp-cds marked this conversation as resolved.
Comment thread
thiessenp-cds marked this conversation as resolved.
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);
},

/**
Expand All @@ -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);
}
}
},
};

Expand Down
98 changes: 97 additions & 1 deletion lib/hooks/useGCFormContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@ 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";

import {
mapIdsToValues,
getValuesWithMatchedIds,
getVisibleGroupsBasedOnValuesRecursive,
buildElementDependencies,
buildElementMap,
computeAllVisibility,
recomputeAffectedVisibility,
getChangedChoiceElementIds,
} from "@gcforms/core";

import { formHasGroups } from "@lib/utils/form-builder/formHasGroups";
Expand Down Expand Up @@ -73,6 +79,12 @@ interface GCFormsContextValueType {

const GCFormsContext = createContext<GCFormsContextValueType | undefined>(undefined);

interface VisibilityContextValueType {
isElementVisible: (elementId: string) => boolean;
}

const VisibilityContext = createContext<VisibilityContextValueType | undefined>(undefined);

export const GCFormsProvider = ({
children,
formRecord,
Expand All @@ -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({});
Expand All @@ -91,6 +104,26 @@ export const GCFormsProvider = ({
const [submissionId, setSubmissionId] = React.useState<string | undefined>(undefined);
const [submissionDate, setSubmissionDate] = React.useState<string | undefined>(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<Map<string, boolean>>(() =>
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;
};
Expand Down Expand Up @@ -136,11 +169,56 @@ export const GCFormsProvider = ({
}: {
formValues: Record<string, string[] | string>;
}): 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<string, boolean> = {};
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
Expand All @@ -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
Expand Down Expand Up @@ -278,7 +363,9 @@ export const GCFormsProvider = ({
getNonce,
}}
>
{children}
<VisibilityContext.Provider value={{ isElementVisible }}>
{children}
</VisibilityContext.Provider>
</GCFormsContext.Provider>
);
};
Expand Down Expand Up @@ -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;
};
Loading
Loading