Skip to content

Commit d681b53

Browse files
committed
feat: enhance useFilesRealtimeEvents and nodesStore with additional functionality and optimizations
1 parent e511e9c commit d681b53

2 files changed

Lines changed: 127 additions & 4 deletions

File tree

src/cotton.client/src/pages/files/hooks/useFilesRealtimeEvents.ts

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useEffect, useMemo, useRef } from "react";
22
import { eventHub } from "../../../shared/signalr";
33
import { useAuth } from "../../../features/auth";
4-
import type { JsonValue } from "../../../shared/types/json";
4+
import { isJsonObject, type JsonValue } from "../../../shared/types/json";
55

66
interface UseFilesRealtimeEventsOptions {
77
nodeId: string | null;
@@ -22,6 +22,110 @@ const FILES_HUB_METHODS = [
2222
] as const;
2323

2424
const PREVIEW_GENERATED_METHOD = "PreviewGenerated";
25+
const GUID_REGEX =
26+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
27+
const MAX_PAYLOAD_SCAN_DEPTH = 4;
28+
29+
const normalizeKey = (key: string): string =>
30+
key.replace(/[^a-z]/gi, "").toLowerCase();
31+
32+
const looksLikeNodeRelationKey = (key: string): boolean => {
33+
const normalized = normalizeKey(key);
34+
35+
if (
36+
normalized === "node" ||
37+
normalized === "parent" ||
38+
normalized === "folder"
39+
) {
40+
return true;
41+
}
42+
43+
return (
44+
normalized.includes("nodeid") ||
45+
normalized.includes("parentid") ||
46+
normalized.includes("folderid") ||
47+
normalized.includes("sourceid") ||
48+
normalized.includes("targetid") ||
49+
normalized.includes("destinationid") ||
50+
normalized.includes("fromid") ||
51+
normalized.includes("toid")
52+
);
53+
};
54+
55+
const isGuid = (value: string): boolean => GUID_REGEX.test(value);
56+
57+
const collectAffectedNodeIdsFromValue = (
58+
value: JsonValue,
59+
depth: number,
60+
relationContext: boolean,
61+
): string[] => {
62+
if (depth > MAX_PAYLOAD_SCAN_DEPTH) {
63+
return [];
64+
}
65+
66+
if (typeof value === "string") {
67+
return relationContext && isGuid(value) ? [value] : [];
68+
}
69+
70+
if (Array.isArray(value)) {
71+
return value.flatMap((entry) =>
72+
collectAffectedNodeIdsFromValue(entry, depth + 1, relationContext),
73+
);
74+
}
75+
76+
if (!isJsonObject(value)) {
77+
return [];
78+
}
79+
80+
const ids: string[] = [];
81+
82+
for (const [key, nested] of Object.entries(value)) {
83+
const nextRelationContext = relationContext || looksLikeNodeRelationKey(key);
84+
ids.push(
85+
...collectAffectedNodeIdsFromValue(
86+
nested,
87+
depth + 1,
88+
nextRelationContext,
89+
),
90+
);
91+
}
92+
93+
return ids;
94+
};
95+
96+
const collectAffectedNodeIds = (args: JsonValue[]): Set<string> => {
97+
const ids = new Set<string>();
98+
99+
for (const arg of args) {
100+
if (typeof arg === "string" && isGuid(arg)) {
101+
ids.add(arg);
102+
}
103+
104+
const nestedIds = collectAffectedNodeIdsFromValue(arg, 0, false);
105+
for (const id of nestedIds) {
106+
ids.add(id);
107+
}
108+
}
109+
110+
return ids;
111+
};
112+
113+
const shouldInvalidateCurrentNode = (
114+
args: JsonValue[],
115+
currentNodeId: string | null,
116+
): boolean => {
117+
if (!currentNodeId) {
118+
return false;
119+
}
120+
121+
const affectedNodeIds = collectAffectedNodeIds(args);
122+
if (affectedNodeIds.size === 0) {
123+
// Keep compatibility with events that do not include node identifiers.
124+
return true;
125+
}
126+
127+
return affectedNodeIds.has(currentNodeId);
128+
};
25129

26130
const isPreviewGeneratedArgs = (
27131
args: JsonValue[],
@@ -85,7 +189,11 @@ export function useFilesRealtimeEvents({
85189

86190
const invalidationMethods = FILES_HUB_METHODS.flatMap((m) => [m, m.toLowerCase()]);
87191
const unsubscribes = invalidationMethods.map((method) =>
88-
eventHub.on(method, () => {
192+
eventHub.on(method, (...args: JsonValue[]) => {
193+
if (!shouldInvalidateCurrentNode(args, nodeIdRef.current)) {
194+
return;
195+
}
196+
89197
scheduleInvalidate();
90198
}),
91199
);

src/cotton.client/src/shared/store/nodesStore.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { NODES_STORAGE_KEY } from "../config/storageKeys";
66
import { isAxiosError } from "../api/httpClient";
77

88
let rootResolvePromise: Promise<void> | null = null;
9+
let lastRootResolveStartedAt = 0;
10+
const ROOT_RESOLVE_MIN_INTERVAL_MS = 600_000;
911

1012
type NodesState = {
1113
cacheOwnerUserId: string | null;
@@ -24,7 +26,7 @@ type NodesState = {
2426
nodeId: string,
2527
options?: { loadChildren?: boolean; allowRootRecovery?: boolean },
2628
) => Promise<void>;
27-
resolveRootInBackground: (options?: { loadChildren?: boolean }) => void;
29+
resolveRootInBackground: (options?: { loadChildren?: boolean; force?: boolean }) => void;
2830
refreshNodeContent: (nodeId: string) => Promise<void>;
2931
addFolderToCache: (parentNodeId: string, folder: NodeDto) => void;
3032
createFolder: (parentNodeId: string, name: string) => Promise<NodeDto | null>;
@@ -195,14 +197,26 @@ async function fetchAllNodeChildren(nodeId: string): Promise<NodeContentDto> {
195197
export const useNodesStore = create<NodesState>()(
196198
persist(
197199
(set, get) => {
198-
const scheduleRootResolve = (options?: { loadChildren?: boolean }): void => {
200+
const scheduleRootResolve = (options?: {
201+
loadChildren?: boolean;
202+
force?: boolean;
203+
}): void => {
199204
// If we don't have a root yet, loadRoot() will resolve it.
200205
const existingRootId = get().rootNodeId;
201206
if (!existingRootId) return;
202207

203208
if (rootResolvePromise) return;
204209

210+
const force = options?.force ?? false;
211+
if (!force) {
212+
const elapsedSinceLastResolve = Date.now() - lastRootResolveStartedAt;
213+
if (elapsedSinceLastResolve < ROOT_RESOLVE_MIN_INTERVAL_MS) {
214+
return;
215+
}
216+
}
217+
205218
const loadChildren = options?.loadChildren ?? true;
219+
lastRootResolveStartedAt = Date.now();
206220

207221
rootResolvePromise = (async () => {
208222
try {
@@ -649,6 +663,7 @@ export const useNodesStore = create<NodesState>()(
649663

650664
reset: (cacheOwnerUserId) => {
651665
rootResolvePromise = null;
666+
lastRootResolveStartedAt = 0;
652667
set((prev) => ({
653668
cacheOwnerUserId: cacheOwnerUserId ?? prev.cacheOwnerUserId,
654669
currentNode: null,

0 commit comments

Comments
 (0)