-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathuseNavigationSync.ts
More file actions
710 lines (638 loc) · 25.2 KB
/
Copy pathuseNavigationSync.ts
File metadata and controls
710 lines (638 loc) · 25.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
/**
* useNavigationSync
*
* Synchronizes the App navigation tree when Pages or Dashboards are
* created, deleted, or renamed. Pure utility helpers are exported for
* unit-testing; the React hook wires them to the adapter / metadata
* context and shows sonner toasts with an undo action.
*
* @module
*/
import { useCallback, useEffect, useRef } from 'react';
import { toast } from 'sonner';
import type { NavigationItem, AppSchema } from '@object-ui/types';
import { useObjectTranslation } from '@object-ui/i18n';
import { useAdapter } from '../providers/AdapterProvider';
import { useMetadata } from '../providers/MetadataProvider';
import { matchAppBySegment } from '../utils/appRoute';
import { usePreviewDrafts } from '../preview/PreviewModeContext';
// ============================================================================
// Pure utility helpers (exported for testing)
// ============================================================================
let _idCounter = 0;
/** Generate a simple unique id for a new navigation item. */
export function generateNavId(prefix = 'nav'): string {
return `${prefix}_${Date.now()}_${++_idCounter}`;
}
/**
* Add a navigation item to the end of a navigation array (immutable).
*/
export function addNavigationItem(
navigation: NavigationItem[],
item: NavigationItem,
): NavigationItem[] {
return [...navigation, item];
}
/**
* Recursively remove all navigation items that match a given type and name.
* Returns a new array (immutable).
*/
export function removeNavigationItems(
navigation: NavigationItem[],
type: 'page' | 'dashboard',
name: string,
): NavigationItem[] {
return navigation
.filter((item) => {
if (item.type === type) {
if (type === 'page' && item.pageName === name) return false;
if (type === 'dashboard' && item.dashboardName === name) return false;
}
return true;
})
.map((item) => {
if (item.children && item.children.length > 0) {
return {
...item,
children: removeNavigationItems(item.children, type, name),
};
}
return item;
});
}
/**
* Recursively rename all navigation items that reference the old name.
* Returns a new array (immutable).
*/
export function renameNavigationItems(
navigation: NavigationItem[],
type: 'page' | 'dashboard',
oldName: string,
newName: string,
): NavigationItem[] {
return navigation.map((item) => {
let updated = item;
if (type === 'page' && item.type === 'page' && item.pageName === oldName) {
updated = { ...item, pageName: newName };
} else if (type === 'dashboard' && item.type === 'dashboard' && item.dashboardName === oldName) {
updated = { ...item, dashboardName: newName };
}
if (updated.children && updated.children.length > 0) {
return {
...updated,
children: renameNavigationItems(updated.children, type, oldName, newName),
};
}
return updated;
});
}
/**
* Shallow-structural equality check for two NavigationItem arrays.
* Works reliably because NavigationItem objects are plain serializable JSON
* whose key ordering is deterministic (we control object creation).
*/
export function navigationEqual(a: NavigationItem[], b: NavigationItem[]): boolean {
if (a.length !== b.length) return false;
// Fast-path: same reference
if (a === b) return true;
return JSON.stringify(a) === JSON.stringify(b);
}
/**
* `sys_` is the platform-reserved metadata namespace (sys_organization_detail,
* sys_user_detail, …). Fallback signal only — see isPlatformArtifact.
*/
export function isSystemArtifactName(name: unknown): boolean {
return typeof name === 'string' && name.startsWith('sys_');
}
/**
* Package/platform-shipped pages and dashboards are never "something the user
* just created", so they must not trigger navigation sync no matter when they
* appear in (or vanish from) the metadata lists — package install/uninstall
* mid-session looks exactly like user CRUD to a name-set diff. A package that
* ships pages also ships (or contributes to) the navigation that exposes
* them; auto-syncing on top of that would duplicate entries at best and write
* into apps the package never intended at worst.
*
* Provenance is the primary signal: ADR-0010's applyProtection stamps
* `_packageId` + `_provenance: 'package'` on artifacts registered with
* package coords (the engine manifest path passes them). The `sys_` name
* prefix is only a fallback for registration paths that don't stamp
* provenance yet — third-party plugin pages are NOT guaranteed to carry it,
* which is why the field check comes first.
*/
export function isPlatformArtifact(item: unknown): boolean {
if (!item || typeof item !== 'object') return false;
const a = item as { name?: unknown; _packageId?: unknown; _provenance?: unknown };
if (a._packageId != null || a._provenance === 'package') return true;
return isSystemArtifactName(a.name);
}
/**
* Whether an app may be targeted by automatic navigation writes.
*
* ADR-0010 metadata protection: the loader stamps a `_lock` envelope on
* packaged artifacts (translated from the author-facing `protection` block,
* e.g. the `setup` app ships `protection.lock = 'full'`). Locks 'full' and
* 'no-overlay' reject PUT with 403 — a navigation write into such an app can
* never succeed, so don't attempt it (each attempt surfaced as a red
* "Failed to update navigation" toast).
*/
export function isNavigationSyncableApp(app: unknown): boolean {
if (!app || typeof app !== 'object') return false;
const a = app as { _lock?: string; protection?: { lock?: string } };
const lock = a._lock ?? a.protection?.lock;
return lock !== 'full' && lock !== 'no-overlay';
}
// ============================================================================
// React Hook
// ============================================================================
export interface UseNavigationSyncReturn {
/** Call after a new page has been created / saved for the first time. */
syncPageCreated: (appName: string, pageName: string, label?: string) => Promise<void>;
/** Call after a new dashboard has been created / saved for the first time. */
syncDashboardCreated: (appName: string, dashboardName: string, label?: string) => Promise<void>;
/** Call after a page has been deleted. */
syncPageDeleted: (appName: string, pageName: string) => Promise<void>;
/** Call after a dashboard has been deleted. */
syncDashboardDeleted: (appName: string, dashboardName: string) => Promise<void>;
/** Call after a page has been renamed. */
syncPageRenamed: (appName: string, oldName: string, newName: string) => Promise<void>;
/** Call after a dashboard has been renamed. */
syncDashboardRenamed: (appName: string, oldName: string, newName: string) => Promise<void>;
/** Convenience: add page to navigation of ALL apps. */
syncPageCreatedAllApps: (pageName: string, label?: string) => Promise<void>;
/** Convenience: add dashboard to navigation of ALL apps. */
syncDashboardCreatedAllApps: (dashboardName: string, label?: string) => Promise<void>;
/** Convenience: remove page from navigation of ALL apps. */
syncPageDeletedAllApps: (pageName: string) => Promise<void>;
/** Convenience: remove dashboard from navigation of ALL apps. */
syncDashboardDeletedAllApps: (dashboardName: string) => Promise<void>;
/** Convenience: rename page references across ALL apps. */
syncPageRenamedAllApps: (oldName: string, newName: string) => Promise<void>;
/** Convenience: rename dashboard references across ALL apps. */
syncDashboardRenamedAllApps: (oldName: string, newName: string) => Promise<void>;
}
/**
* Hook that provides methods to keep the App navigation tree in sync with
* Page / Dashboard CRUD operations. Each method:
*
* 1. Finds the target app from metadata
* 2. Mutates the `navigation` array (immutably)
* 3. Persists via `client.meta.saveItem`
* 4. Refreshes metadata cache
* 5. Shows a toast with an **Undo** action
*/
export function useNavigationSync(): UseNavigationSyncReturn {
const adapter = useAdapter();
const { apps, refresh } = useMetadata();
const { t } = useObjectTranslation();
// Keep a ref so the undo closure always reads the latest adapter
const adapterRef = useRef(adapter);
adapterRef.current = adapter;
const refreshRef = useRef(refresh);
refreshRef.current = refresh;
/** Persist an updated app schema and refresh metadata. */
const saveApp = useCallback(
async (appName: string, schema: AppSchema) => {
const client = adapterRef.current?.getClient();
if (client) {
await client.meta.saveItem('app', appName, schema);
}
await refreshRef.current?.();
},
[],
);
/** Find the current app schema from metadata by name. */
const findApp = useCallback(
(appName: string): AppSchema | undefined =>
matchAppBySegment(apps, appName) as AppSchema | undefined,
[apps],
);
// ------------------------------------------------------------------
// Created
// ------------------------------------------------------------------
const syncPageCreated = useCallback(
async (appName: string, pageName: string, label?: string) => {
const app = findApp(appName);
if (!app) return;
const prev = app.navigation ?? [];
const newItem: NavigationItem = {
id: generateNavId('nav_page'),
type: 'page',
label: label || pageName,
pageName,
icon: 'FileText',
};
const updated = addNavigationItem(prev, newItem);
const updatedApp: AppSchema = { ...app, navigation: updated };
try {
await saveApp(appName, updatedApp);
toast.success(t('navigationSync.addedPage', { name: label || pageName }), {
action: {
label: t('navigationSync.undoLabel'),
onClick: async () => {
try {
await saveApp(appName, { ...app, navigation: prev });
toast.info(t('navigationSync.undone'));
} catch {
toast.error(t('navigationSync.undoFailed'));
}
},
},
});
} catch {
toast.error(t('navigationSync.updateFailed'));
}
},
[findApp, saveApp, t],
);
const syncDashboardCreated = useCallback(
async (appName: string, dashboardName: string, label?: string) => {
const app = findApp(appName);
if (!app) return;
const prev = app.navigation ?? [];
const newItem: NavigationItem = {
id: generateNavId('nav_dash'),
type: 'dashboard',
label: label || dashboardName,
dashboardName,
icon: 'LayoutDashboard',
};
const updated = addNavigationItem(prev, newItem);
const updatedApp: AppSchema = { ...app, navigation: updated };
try {
await saveApp(appName, updatedApp);
toast.success(t('navigationSync.addedDashboard', { name: label || dashboardName }), {
action: {
label: t('navigationSync.undoLabel'),
onClick: async () => {
try {
await saveApp(appName, { ...app, navigation: prev });
toast.info(t('navigationSync.undone'));
} catch {
toast.error(t('navigationSync.undoFailed'));
}
},
},
});
} catch {
toast.error(t('navigationSync.updateFailed'));
}
},
[findApp, saveApp, t],
);
// ------------------------------------------------------------------
// Deleted
// ------------------------------------------------------------------
const syncPageDeleted = useCallback(
async (appName: string, pageName: string) => {
const app = findApp(appName);
if (!app) return;
const prev = app.navigation ?? [];
const updated = removeNavigationItems(prev, 'page', pageName);
if (navigationEqual(updated, prev)) return; // nothing changed
const updatedApp: AppSchema = { ...app, navigation: updated };
try {
await saveApp(appName, updatedApp);
toast.success(t('navigationSync.removedPage', { name: pageName }), {
action: {
label: t('navigationSync.undoLabel'),
onClick: async () => {
try {
await saveApp(appName, { ...app, navigation: prev });
toast.info(t('navigationSync.undone'));
} catch {
toast.error(t('navigationSync.undoFailed'));
}
},
},
});
} catch {
toast.error(t('navigationSync.updateFailed'));
}
},
[findApp, saveApp, t],
);
const syncDashboardDeleted = useCallback(
async (appName: string, dashboardName: string) => {
const app = findApp(appName);
if (!app) return;
const prev = app.navigation ?? [];
const updated = removeNavigationItems(prev, 'dashboard', dashboardName);
if (navigationEqual(updated, prev)) return;
const updatedApp: AppSchema = { ...app, navigation: updated };
try {
await saveApp(appName, updatedApp);
toast.success(t('navigationSync.removedDashboard', { name: dashboardName }), {
action: {
label: t('navigationSync.undoLabel'),
onClick: async () => {
try {
await saveApp(appName, { ...app, navigation: prev });
toast.info(t('navigationSync.undone'));
} catch {
toast.error(t('navigationSync.undoFailed'));
}
},
},
});
} catch {
toast.error(t('navigationSync.updateFailed'));
}
},
[findApp, saveApp, t],
);
// ------------------------------------------------------------------
// Renamed
// ------------------------------------------------------------------
const syncPageRenamed = useCallback(
async (appName: string, oldName: string, newName: string) => {
const app = findApp(appName);
if (!app) return;
const prev = app.navigation ?? [];
const updated = renameNavigationItems(prev, 'page', oldName, newName);
if (navigationEqual(updated, prev)) return;
const updatedApp: AppSchema = { ...app, navigation: updated };
try {
await saveApp(appName, updatedApp);
toast.success(t('navigationSync.renamedPage', { oldName, newName }), {
action: {
label: t('navigationSync.undoLabel'),
onClick: async () => {
try {
await saveApp(appName, { ...app, navigation: prev });
toast.info(t('navigationSync.undone'));
} catch {
toast.error(t('navigationSync.undoFailed'));
}
},
},
});
} catch {
toast.error(t('navigationSync.updateFailed'));
}
},
[findApp, saveApp, t],
);
const syncDashboardRenamed = useCallback(
async (appName: string, oldName: string, newName: string) => {
const app = findApp(appName);
if (!app) return;
const prev = app.navigation ?? [];
const updated = renameNavigationItems(prev, 'dashboard', oldName, newName);
if (navigationEqual(updated, prev)) return;
const updatedApp: AppSchema = { ...app, navigation: updated };
try {
await saveApp(appName, updatedApp);
toast.success(t('navigationSync.renamedDashboard', { oldName, newName }), {
action: {
label: t('navigationSync.undoLabel'),
onClick: async () => {
try {
await saveApp(appName, { ...app, navigation: prev });
toast.info(t('navigationSync.undone'));
} catch {
toast.error(t('navigationSync.undoFailed'));
}
},
},
});
} catch {
toast.error(t('navigationSync.updateFailed'));
}
},
[findApp, saveApp, t],
);
// ------------------------------------------------------------------
// All-Apps convenience methods
// ------------------------------------------------------------------
/** Safely extract the app name from a metadata entry. */
const getAppName = (app: unknown): string | undefined =>
app && typeof app === 'object' && 'name' in app ? (app as { name: string }).name : undefined;
/** Add a page nav item to ALL apps that don't already reference it. */
const syncPageCreatedAllApps = useCallback(
async (pageName: string, label?: string) => {
for (const app of apps) {
const name = getAppName(app);
if (!name || !isNavigationSyncableApp(app)) continue;
await syncPageCreated(name, pageName, label);
}
},
[apps, syncPageCreated],
);
/** Add a dashboard nav item to ALL apps that don't already reference it. */
const syncDashboardCreatedAllApps = useCallback(
async (dashboardName: string, label?: string) => {
for (const app of apps) {
const name = getAppName(app);
if (!name || !isNavigationSyncableApp(app)) continue;
await syncDashboardCreated(name, dashboardName, label);
}
},
[apps, syncDashboardCreated],
);
/** Remove a page from navigation across ALL apps. */
const syncPageDeletedAllApps = useCallback(
async (pageName: string) => {
for (const app of apps) {
const name = getAppName(app);
if (!name || !isNavigationSyncableApp(app)) continue;
await syncPageDeleted(name, pageName);
}
},
[apps, syncPageDeleted],
);
/** Remove a dashboard from navigation across ALL apps. */
const syncDashboardDeletedAllApps = useCallback(
async (dashboardName: string) => {
for (const app of apps) {
const name = getAppName(app);
if (!name || !isNavigationSyncableApp(app)) continue;
await syncDashboardDeleted(name, dashboardName);
}
},
[apps, syncDashboardDeleted],
);
/** Rename page references across ALL apps. */
const syncPageRenamedAllApps = useCallback(
async (oldName: string, newName: string) => {
for (const app of apps) {
const name = getAppName(app);
if (!name || !isNavigationSyncableApp(app)) continue;
await syncPageRenamed(name, oldName, newName);
}
},
[apps, syncPageRenamed],
);
/** Rename dashboard references across ALL apps. */
const syncDashboardRenamedAllApps = useCallback(
async (oldName: string, newName: string) => {
for (const app of apps) {
const name = getAppName(app);
if (!name || !isNavigationSyncableApp(app)) continue;
await syncDashboardRenamed(name, oldName, newName);
}
},
[apps, syncDashboardRenamed],
);
return {
syncPageCreated,
syncDashboardCreated,
syncPageDeleted,
syncDashboardDeleted,
syncPageRenamed,
syncDashboardRenamed,
syncPageCreatedAllApps,
syncDashboardCreatedAllApps,
syncPageDeletedAllApps,
syncDashboardDeletedAllApps,
syncPageRenamedAllApps,
syncDashboardRenamedAllApps,
};
}
// ============================================================================
// NavigationSyncEffect — auto-detect page/dashboard metadata changes
// ============================================================================
/**
* Headless component that watches the `pages` and `dashboards` metadata
* arrays. When items are added or removed it automatically calls the
* matching navigation-sync methods for every app.
*
* Mount once inside the component tree (e.g. inside `AppContent`) where
* both `useMetadata` and `useAdapter` are available.
*
* > **Rename detection** is not possible with a simple diff — callers
* > should invoke `syncPageRenamed` / `syncDashboardRenamed` explicitly.
*/
export function NavigationSyncEffect(): null {
const { pages, dashboards, apps, getTypeStatus } = useMetadata();
const adapter = useAdapter();
const adapterRef = useRef(adapter);
adapterRef.current = adapter;
// ADR-0037 — preview is read-only by design. Entering/leaving
// `?preview=draft` swaps the entire metadata source, so the page/dashboard
// sets legitimately DIFFER from the previous render; diffing across that
// swap would misread draft-only (or published-only) items as user
// creations/deletions and WRITE navigation changes back to the real app
// metadata from inside a preview. Disabled for the whole preview session.
const previewDrafts = usePreviewDrafts();
const {
syncPageCreated,
syncDashboardCreated,
syncPageDeleted,
syncDashboardDeleted,
} = useNavigationSync();
// Track previous page/dashboard name sets
const prevPageNamesRef = useRef<Set<string> | null>(null);
const prevDashNamesRef = useRef<Set<string> | null>(null);
// Guard against circular refreshes and concurrent sync operations.
// Incremented on each effect invocation; the async closure captures
// its own version and bails out if a newer run has started.
const syncVersionRef = useRef(0);
const syncingRef = useRef(false);
/** Safely extract the app name from a metadata entry. */
const getAppName = (app: unknown): string | undefined =>
app && typeof app === 'object' && 'name' in app ? (app as { name: string }).name : undefined;
useEffect(() => {
if (syncingRef.current) return;
if (previewDrafts) {
// Drop the baseline so leaving preview re-seeds instead of diffing the
// published world against the draft world it just stopped rendering.
prevPageNamesRef.current = null;
prevDashNamesRef.current = null;
return;
}
// `page` and `dashboard` are lazily-loaded metadata types: their arrays
// are empty (or stale) until the fetch lands, and `invalidate()` empties
// them again mid-session (e.g. the New-record flow reloads meta). Diffing
// a not-ready snapshot misreads that dip as mass deletion / re-creation —
// notably flagging system pages as "user added" once the full list
// arrives. Only seed the baseline and diff while both types are 'ready'.
// (Contexts without getTypeStatus — hand-rolled test values — are
// treated as always ready.)
if (
getTypeStatus &&
(getTypeStatus('page') !== 'ready' || getTypeStatus('dashboard') !== 'ready')
) {
return;
}
// Platform/package artifacts never participate in the diff: they ship
// with the platform or arrive via package install, not from user CRUD,
// so their appearance must not write them into app navigation (nor
// their disappearance delete nav entries). See isPlatformArtifact.
const userArtifactNames = (items: any[]): Set<string> =>
new Set(
items
.filter((it: any) => !isPlatformArtifact(it))
.map((it: any) => it?.name)
.filter((n: any): n is string => typeof n === 'string' && n.length > 0),
);
const currentPageNames = userArtifactNames(pages ?? []);
const currentDashNames = userArtifactNames(dashboards ?? []);
const prevPages = prevPageNamesRef.current;
const prevDash = prevDashNamesRef.current;
// First render — seed refs and exit without syncing
if (prevPages === null || prevDash === null) {
prevPageNamesRef.current = currentPageNames;
prevDashNamesRef.current = currentDashNames;
return;
}
// Compute diff
const addedPages = [...currentPageNames].filter((n) => !prevPages.has(n));
const removedPages = [...prevPages].filter((n) => !currentPageNames.has(n));
const addedDash = [...currentDashNames].filter((n) => !prevDash.has(n));
const removedDash = [...prevDash].filter((n) => !currentDashNames.has(n));
if (
addedPages.length === 0 &&
removedPages.length === 0 &&
addedDash.length === 0 &&
removedDash.length === 0
) {
// Nothing changed — update refs and exit
prevPageNamesRef.current = currentPageNames;
prevDashNamesRef.current = currentDashNames;
return;
}
// Sync navigation across all apps
const version = ++syncVersionRef.current;
syncingRef.current = true;
let cancelled = false;
(async () => {
try {
for (const app of apps) {
if (cancelled || syncVersionRef.current !== version) break;
const appName = getAppName(app);
// Write-protected apps (ADR-0010 `_lock`) would 403 every PUT —
// skip them instead of spraying failure toasts.
if (!appName || !isNavigationSyncableApp(app)) continue;
for (const pageName of addedPages) {
if (cancelled) break;
await syncPageCreated(appName, pageName);
}
for (const pageName of removedPages) {
if (cancelled) break;
await syncPageDeleted(appName, pageName);
}
for (const dashName of addedDash) {
if (cancelled) break;
await syncDashboardCreated(appName, dashName);
}
for (const dashName of removedDash) {
if (cancelled) break;
await syncDashboardDeleted(appName, dashName);
}
}
} finally {
// Only update refs if this is still the latest sync version
if (syncVersionRef.current === version) {
prevPageNamesRef.current = currentPageNames;
prevDashNamesRef.current = currentDashNames;
syncingRef.current = false;
}
}
})();
return () => {
cancelled = true;
};
}, [pages, dashboards, apps, previewDrafts, getTypeStatus, syncPageCreated, syncDashboardCreated, syncPageDeleted, syncDashboardDeleted]);
return null;
}