Skip to content

Commit 0c1a1a2

Browse files
authored
chore: cache tree view (#7270)
cache-tree Co-authored-by: GitHub Copilot <Tim Arney>
1 parent c4c5002 commit 0c1a1a2

6 files changed

Lines changed: 185 additions & 20 deletions

File tree

app/(gcforms)/[locale]/(form administration)/form-builder/components/shared/right-panel/headless-treeview/TreeItem/ExpandableIcon.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,32 @@
11
import { ArrowDownIcon } from "../icons/ArrowDownIcon";
22
import { ArrowRightIcon } from "../icons/ArrowRightIcon";
33
import { HamburgerIcon } from "../icons/HamburgerIcon";
4+
import { SpinnerIcon } from "@serverComponents/icons/SpinnerIcon";
5+
import { useTranslation } from "@i18n/client";
46

57
import { ItemProps } from "../types";
68
import { useElementType } from "../hooks/useElementType";
79

8-
export const ExpandableIcon = ({ item }: ItemProps) => {
10+
export const ExpandableIcon = ({
11+
item,
12+
isLoading = false,
13+
}: ItemProps & { isLoading?: boolean }) => {
14+
const { t } = useTranslation("form-builder");
915
const { isSectionElement, isRepeatingSet } = useElementType(item);
1016
return (
1117
<>
1218
{isSectionElement && (
13-
<span className="mr-3 inline-block">
14-
{item.isExpanded() ? <ArrowDownIcon /> : <ArrowRightIcon />}
19+
<span className="mr-3 inline-flex h-3.5 w-3.5 items-center justify-center">
20+
{isLoading ? (
21+
<>
22+
<SpinnerIcon className="size-3 animate-spin fill-indigo-700 text-slate-200" />
23+
<span className="sr-only">{t("loading")}</span>
24+
</>
25+
) : item.isExpanded() ? (
26+
<ArrowDownIcon />
27+
) : (
28+
<ArrowRightIcon />
29+
)}
1530
</span>
1631
)}
1732
{isRepeatingSet && (

app/(gcforms)/[locale]/(form administration)/form-builder/components/shared/right-panel/headless-treeview/TreeItem/TreeItem.tsx

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { cn } from "@lib/utils";
2-
import React from "react";
2+
import React, { useEffect, useRef, useState } from "react";
3+
import { flushSync } from "react-dom";
34

45
import { TreeItemProps } from "../types";
56

@@ -15,16 +16,61 @@ import { lockedItems } from "../constants";
1516

1617
export const TreeItem = ({ item, tree, onFocus, onBlur, handleDelete }: TreeItemProps) => {
1718
const { isFormElement, isSectionElement, isRepeatingSet } = useElementType(item);
19+
const [isOpening, setIsOpening] = useState(false);
20+
const openingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
21+
22+
const isExpanded = item.isExpanded();
1823

1924
const canDragItem = !lockedItems.includes(item.getId());
2025

2126
const canDeleteItem =
22-
item.isExpanded() &&
27+
isExpanded &&
2328
handleDelete &&
2429
!isFormElement &&
2530
!isRepeatingSet &&
2631
!["start", "end"].includes(item.getId());
2732

33+
const clearOpeningFeedback = () => {
34+
if (openingTimeoutRef.current) {
35+
clearTimeout(openingTimeoutRef.current);
36+
openingTimeoutRef.current = null;
37+
}
38+
39+
setIsOpening(false);
40+
};
41+
42+
const showOpeningFeedback = () => {
43+
if (!isSectionElement) {
44+
return;
45+
}
46+
47+
if (isExpanded) {
48+
clearOpeningFeedback();
49+
return;
50+
}
51+
52+
if (openingTimeoutRef.current) {
53+
clearTimeout(openingTimeoutRef.current);
54+
}
55+
56+
flushSync(() => {
57+
setIsOpening(true);
58+
});
59+
60+
openingTimeoutRef.current = setTimeout(() => {
61+
setIsOpening(false);
62+
openingTimeoutRef.current = null;
63+
}, 1500);
64+
};
65+
66+
useEffect(() => {
67+
return () => {
68+
if (openingTimeoutRef.current) {
69+
clearTimeout(openingTimeoutRef.current);
70+
}
71+
};
72+
}, []);
73+
2874
// Get interactive props only when not renaming
2975
const getInteractiveProps = () => {
3076
if (item.isRenaming()) {
@@ -35,6 +81,10 @@ export const TreeItem = ({ item, tree, onFocus, onBlur, handleDelete }: TreeItem
3581

3682
return {
3783
...itemProps,
84+
onClick: (e: React.MouseEvent<HTMLDivElement>) => {
85+
showOpeningFeedback();
86+
itemProps.onClick?.(e);
87+
},
3888
onFocus: () => {
3989
onFocus(item);
4090
},
@@ -48,30 +98,40 @@ export const TreeItem = ({ item, tree, onFocus, onBlur, handleDelete }: TreeItem
4898
tree.getItemInstance(item.getId()).startRenaming();
4999
},
50100
onKeyDown: async (e: React.KeyboardEvent<HTMLDivElement>) => {
101+
if (e.key === "Enter" || e.key === " " || e.key === "ArrowRight") {
102+
showOpeningFeedback();
103+
}
104+
51105
if (e.key === "Delete" || e.key === "Backspace") {
52106
if (handleDelete) {
53107
e.preventDefault();
54108
handleDelete(e);
109+
return;
55110
}
56111
}
112+
113+
itemProps.onKeyDown?.(e);
57114
},
58115
};
59116
};
60117

118+
const isOpeningFeedback = isOpening && !isExpanded;
119+
61120
return (
62121
<div
63122
key={item.getId()}
64123
id={item.getId()}
124+
aria-busy={isOpeningFeedback || undefined}
65125
{...getInteractiveProps()}
66126
className={cn(
67127
"block max-w-full",
68128
isFormElement && "outline-none",
69-
isSectionElement && "outline-offset-[-4px] outline-indigo-700",
129+
isSectionElement && "-outline-offset-4 outline-indigo-700",
70130
item.isDraggingOver() && item.isDragTarget() && "border-blue-focus border-1 border-dashed"
71131
)}
72132
>
73133
<ItemContent item={item}>
74-
<ExpandableIcon item={item} />
134+
<ExpandableIcon item={item} isLoading={isOpeningFeedback} />
75135

76136
{item.isRenaming() ? <EditableInput item={item} tree={tree} /> : <ItemTitle item={item} />}
77137

app/(gcforms)/[locale]/(form administration)/form-builder/components/shared/right-panel/headless-treeview/TreeView.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect } from "react";
1+
import { useEffect, useMemo } from "react";
22
import { TreeItemData } from "./types";
33
import {
44
syncDataLoaderFeature,
@@ -11,7 +11,7 @@ import {
1111
import { AssistiveTreeDescription, useTree } from "@headless-tree/react";
1212
import { useTreeHandlers } from "./hooks/useTreeHandlers";
1313
import { TreeItem } from "./TreeItem/TreeItem";
14-
import { getInitialTreeState, createSafeItemLoader, createSafeChildrenLoader } from "./treeUtils";
14+
import { getInitialTreeState, createSafeDataLoader } from "./treeUtils";
1515
import { useGroupStore } from "@lib/groups/useGroupStore";
1616
import { ElementProperties, useElementTitle } from "@lib/hooks/useElementTitle";
1717
import { useTemplateStore } from "@lib/store/useTemplateStore";
@@ -69,6 +69,7 @@ export const HeadlessTreeView = ({ children }: { children?: React.ReactNode }) =
6969
const { autoFlowAll } = useAutoFlowIfNoCustomRules();
7070
const { getTitle } = useElementTitle();
7171
const { headlessTree: headlessTreeRef, startRenamingNewGroup } = useTreeRef();
72+
const dataLoader = useMemo(() => createSafeDataLoader(getTreeData), [getTreeData]);
7273

7374
const tree = useTree<TreeItemData>({
7475
initialState: getInitialTreeState(id ?? "start"),
@@ -142,10 +143,7 @@ export const HeadlessTreeView = ({ children }: { children?: React.ReactNode }) =
142143
return true;
143144
},
144145
indent: 20,
145-
dataLoader: {
146-
getItem: createSafeItemLoader(getTreeData),
147-
getChildren: createSafeChildrenLoader(getTreeData),
148-
},
146+
dataLoader,
149147
features: [
150148
syncDataLoaderFeature,
151149
selectionFeature,

app/(gcforms)/[locale]/(form administration)/form-builder/components/shared/right-panel/headless-treeview/treeUtils.test.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getInitialTreeState } from "./treeUtils";
1+
import { createSafeDataLoader, getInitialTreeState } from "./treeUtils";
22

33
describe("getInitialTreeState", () => {
44
it("expands the root item so tree items render on first load", () => {
@@ -14,4 +14,51 @@ describe("getInitialTreeState", () => {
1414
selectedItems: ["start"],
1515
});
1616
});
17-
});
17+
});
18+
19+
describe("createSafeDataLoader", () => {
20+
const treeData = {
21+
root: {
22+
data: { type: "root" },
23+
children: ["start"],
24+
},
25+
start: {
26+
data: { name: "Start", type: "group" },
27+
children: [1],
28+
},
29+
1: {
30+
data: { titleEn: "Question", type: "textField" },
31+
children: [],
32+
},
33+
};
34+
35+
it("shares one tree data read across item and children lookups in the same task", () => {
36+
let calls = 0;
37+
const dataLoader = createSafeDataLoader(() => {
38+
calls += 1;
39+
return treeData;
40+
});
41+
42+
expect(dataLoader.getChildren("root")).toEqual(["start"]);
43+
expect(dataLoader.getItem("start")).toEqual({ name: "Start", type: "group" });
44+
expect(dataLoader.getChildren("start")).toEqual(["1"]);
45+
expect(dataLoader.getItem("1")).toEqual({ titleEn: "Question", type: "textField" });
46+
expect(calls).toBe(1);
47+
});
48+
49+
it("refreshes the cached tree data after the current task", async () => {
50+
let calls = 0;
51+
const dataLoader = createSafeDataLoader(() => {
52+
calls += 1;
53+
return treeData;
54+
});
55+
56+
dataLoader.getItem("start");
57+
expect(calls).toBe(1);
58+
59+
await Promise.resolve();
60+
61+
dataLoader.getItem("start");
62+
expect(calls).toBe(2);
63+
});
64+
});

app/(gcforms)/[locale]/(form administration)/form-builder/components/shared/right-panel/headless-treeview/treeUtils.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,44 @@ export const getTreeItems = (
6060
}
6161
};
6262

63+
const createCachedTreeItemsReader = (getTreeDataFn: GetTreeDataFunction) => {
64+
let cachedItems: Record<string, unknown> | null | undefined;
65+
let clearScheduled = false;
66+
67+
return () => {
68+
// Headless-tree asks for many items during one expand/render pass. Reuse the
69+
// same full tree snapshot for that pass instead of rebuilding it per item.
70+
if (cachedItems !== undefined) {
71+
return cachedItems;
72+
}
73+
74+
cachedItems = getTreeItems(getTreeDataFn);
75+
76+
if (!clearScheduled) {
77+
clearScheduled = true;
78+
// Clear after the current task so later store changes are picked up on the
79+
// next interaction without carrying a long-lived stale tree snapshot.
80+
queueMicrotask(() => {
81+
cachedItems = undefined;
82+
clearScheduled = false;
83+
});
84+
}
85+
86+
return cachedItems;
87+
};
88+
};
89+
90+
type TreeItemsReader = ReturnType<typeof createCachedTreeItemsReader>;
91+
6392
/**
6493
* Safe data loader function for tree items - converts react-complex-tree to headless-tree format
6594
*/
66-
export const createSafeItemLoader = (getTreeDataFn: GetTreeDataFunction) => {
95+
export const createSafeItemLoader = (
96+
getTreeDataFn: GetTreeDataFunction,
97+
readTreeItems: TreeItemsReader = createCachedTreeItemsReader(getTreeDataFn)
98+
) => {
6799
return (itemId: string): Record<string, unknown> => {
68-
const items = getTreeItems(getTreeDataFn);
100+
const items = readTreeItems();
69101

70102
// Always ensure we return something, never undefined
71103
if (!items) {
@@ -88,9 +120,12 @@ export const createSafeItemLoader = (getTreeDataFn: GetTreeDataFunction) => {
88120
/**
89121
* Safe children loader function for tree items
90122
*/
91-
export const createSafeChildrenLoader = (getTreeDataFn: GetTreeDataFunction) => {
123+
export const createSafeChildrenLoader = (
124+
getTreeDataFn: GetTreeDataFunction,
125+
readTreeItems: TreeItemsReader = createCachedTreeItemsReader(getTreeDataFn)
126+
) => {
92127
return (itemId: string): string[] => {
93-
const items = getTreeItems(getTreeDataFn);
128+
const items = readTreeItems();
94129

95130
// If items is null/undefined, return empty array
96131
if (!items) {
@@ -108,3 +143,12 @@ export const createSafeChildrenLoader = (getTreeDataFn: GetTreeDataFunction) =>
108143
return Array.isArray(children) ? children.map(String) : [];
109144
};
110145
};
146+
147+
export const createSafeDataLoader = (getTreeDataFn: GetTreeDataFunction) => {
148+
const readTreeItems = createCachedTreeItemsReader(getTreeDataFn);
149+
150+
return {
151+
getItem: createSafeItemLoader(getTreeDataFn, readTreeItems),
152+
getChildren: createSafeChildrenLoader(getTreeDataFn, readTreeItems),
153+
};
154+
};

lib/groups/utils/groupsToTreeData.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export const groupsToTreeData = (
6969

7070
const startChildren = [];
7171
const endChildren = [];
72+
const elementsById = new Map(elements.map((element) => [element.id, element]));
7273

7374
for (const [key, value] of Object.entries(formGroups)) {
7475
const children =
@@ -128,7 +129,7 @@ export const groupsToTreeData = (
128129
}
129130

130131
children.forEach((childId) => {
131-
const element = elements.find((el) => el.id === Number(childId));
132+
const element = elementsById.get(Number(childId));
132133
if (!element) return;
133134

134135
// Build tree data for sub elements if they exist

0 commit comments

Comments
 (0)