You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
success/packages/excalidraw/data/library.ts

917 lines
29 KiB
TypeScript

import { loadLibraryFromBlob } from "./blob";
import type {
LibraryItems,
LibraryItem,
ExcalidrawImperativeAPI,
LibraryItemsSource,
LibraryItems_anyVersion,
} from "../types";
import { restoreLibraryItems } from "./restore";
import type App from "../components/App";
import { atom } from "jotai";
import { jotaiStore } from "../jotai";
import type { ExcalidrawElement } from "../element/types";
import { getCommonBoundingBox } from "../element/bounds";
import { AbortError } from "../errors";
import { t } from "../i18n";
import { useEffect, useRef } from "react";
import {
URL_HASH_KEYS,
URL_QUERY_KEYS,
APP_NAME,
EVENT,
DEFAULT_SIDEBAR,
LIBRARY_SIDEBAR_TAB,
} from "../constants";
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
import {
arrayToMap,
cloneJSON,
preventUnload,
promiseTry,
resolvablePromise,
} from "../utils";
import type { MaybePromise } from "../utility-types";
import { Emitter } from "../emitter";
import { Queue } from "../queue";
import { hashElementsVersion, hashString } from "../element";
type LibraryUpdate = {
/** deleted library items since last onLibraryChange event */
deletedItems: Map<LibraryItem["id"], LibraryItem>;
/** newly added items in the library */
addedItems: Map<LibraryItem["id"], LibraryItem>;
};
// an object so that we can later add more properties to it without breaking,
// such as schema version
export type LibraryPersistedData = { libraryItems: LibraryItems };
const onLibraryUpdateEmitter = new Emitter<
[update: LibraryUpdate, libraryItems: LibraryItems]
>();
export type LibraryAdatapterSource = "load" | "save";
export interface LibraryPersistenceAdapter {
/**
* Should load data that were previously saved into the database using the
* `save` method. Should throw if saving fails.
*
* Will be used internally in multiple places, such as during save to
* in order to reconcile changes with latest store data.
*/
load(metadata: {
/**
* Indicates whether we're loading data for save purposes, or reading
* purposes, in which case host app can implement more aggressive caching.
*/
source: LibraryAdatapterSource;
}): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>;
/** Should persist to the database as is (do no change the data structure). */
save(libraryData: LibraryPersistedData): MaybePromise<void>;
}
export interface LibraryMigrationAdapter {
/**
* loads data from legacy data source. Returns `null` if no data is
* to be migrated.
*/
load(): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>;
/** clears entire storage afterwards */
clear(): MaybePromise<void>;
}
export const libraryItemsAtom = atom<{
status: "loading" | "loaded";
/** indicates whether library is initialized with library items (has gone
* through at least one update). Used in UI. Specific to this atom only. */
isInitialized: boolean;
libraryItems: LibraryItems;
}>({ status: "loaded", isInitialized: false, libraryItems: [] });
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
cloneJSON(libraryItems);
/**
* checks if library item does not exist already in current library
*/
const isUniqueItem = (
existingLibraryItems: LibraryItems,
targetLibraryItem: LibraryItem,
) => {
return !existingLibraryItems.find((libraryItem) => {
if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
return false;
}
// detect z-index difference by checking the excalidraw elements
// are in order
return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
return (
libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
libItemExcalidrawItem.versionNonce ===
targetLibraryItem.elements[idx].versionNonce
);
});
});
};
/** Merges otherItems into localItems. Unique items in otherItems array are
sorted first. */
export const mergeLibraryItems = (
localItems: LibraryItems,
otherItems: LibraryItems,
): LibraryItems => {
const newItems = [];
for (const item of otherItems) {
if (isUniqueItem(localItems, item)) {
newItems.push(item);
}
}
return [...newItems, ...localItems];
};
/**
* Returns { deletedItems, addedItems } maps of all added and deleted items
* since last onLibraryChange event.
*
* Host apps are recommended to diff with the latest state they have.
*/
const createLibraryUpdate = (
prevLibraryItems: LibraryItems,
nextLibraryItems: LibraryItems,
): LibraryUpdate => {
const nextItemsMap = arrayToMap(nextLibraryItems);
const update: LibraryUpdate = {
deletedItems: new Map<LibraryItem["id"], LibraryItem>(),
addedItems: new Map<LibraryItem["id"], LibraryItem>(),
};
for (const item of prevLibraryItems) {
if (!nextItemsMap.has(item.id)) {
update.deletedItems.set(item.id, item);
}
}
const prevItemsMap = arrayToMap(prevLibraryItems);
for (const item of nextLibraryItems) {
if (!prevItemsMap.has(item.id)) {
update.addedItems.set(item.id, item);
}
}
return update;
};
class Library {
/** latest libraryItems */
private currLibraryItems: LibraryItems = [];
/** snapshot of library items since last onLibraryChange call */
private prevLibraryItems = cloneLibraryItems(this.currLibraryItems);
private app: App;
constructor(app: App) {
this.app = app;
}
private updateQueue: Promise<LibraryItems>[] = [];
private getLastUpdateTask = (): Promise<LibraryItems> | undefined => {
return this.updateQueue[this.updateQueue.length - 1];
};
private notifyListeners = () => {
if (this.updateQueue.length > 0) {
jotaiStore.set(libraryItemsAtom, (s) => ({
status: "loading",
libraryItems: this.currLibraryItems,
isInitialized: s.isInitialized,
}));
} else {
jotaiStore.set(libraryItemsAtom, {
status: "loaded",
libraryItems: this.currLibraryItems,
isInitialized: true,
});
try {
const prevLibraryItems = this.prevLibraryItems;
this.prevLibraryItems = cloneLibraryItems(this.currLibraryItems);
const nextLibraryItems = cloneLibraryItems(this.currLibraryItems);
this.app.props.onLibraryChange?.(nextLibraryItems);
// for internal use in `useHandleLibrary` hook
onLibraryUpdateEmitter.trigger(
createLibraryUpdate(prevLibraryItems, nextLibraryItems),
nextLibraryItems,
);
} catch (error) {
console.error(error);
}
}
};
/** call on excalidraw instance unmount */
destroy = () => {
this.updateQueue = [];
this.currLibraryItems = [];
jotaiStore.set(libraryItemSvgsCache, new Map());
// TODO uncomment after/if we make jotai store scoped to each excal instance
// jotaiStore.set(libraryItemsAtom, {
// status: "loading",
// isInitialized: false,
// libraryItems: [],
// });
};
resetLibrary = () => {
return this.setLibrary([]);
};
/**
* @returns latest cloned libraryItems. Awaits all in-progress updates first.
*/
getLatestLibrary = (): Promise<LibraryItems> => {
return new Promise(async (resolve) => {
try {
const libraryItems = await (this.getLastUpdateTask() ||
this.currLibraryItems);
if (this.updateQueue.length > 0) {
resolve(this.getLatestLibrary());
} else {
resolve(cloneLibraryItems(libraryItems));
}
} catch (error) {
return resolve(this.currLibraryItems);
}
});
};
// NOTE this is a high-level public API (exposed on ExcalidrawAPI) with
// a slight overhead (always restoring library items). For internal use
// where merging isn't needed, use `library.setLibrary()` directly.
updateLibrary = async ({
libraryItems,
prompt = false,
merge = false,
openLibraryMenu = false,
defaultStatus = "unpublished",
}: {
libraryItems: LibraryItemsSource;
merge?: boolean;
prompt?: boolean;
openLibraryMenu?: boolean;
defaultStatus?: "unpublished" | "published";
}): Promise<LibraryItems> => {
if (openLibraryMenu) {
this.app.setState({
openSidebar: { name: DEFAULT_SIDEBAR.name, tab: LIBRARY_SIDEBAR_TAB },
});
}
return this.setLibrary(() => {
return new Promise<LibraryItems>(async (resolve, reject) => {
try {
const source = await (typeof libraryItems === "function" &&
!(libraryItems instanceof Blob)
? libraryItems(this.currLibraryItems)
: libraryItems);
let nextItems;
if (source instanceof Blob) {
nextItems = await loadLibraryFromBlob(source, defaultStatus);
} else {
nextItems = restoreLibraryItems(source, defaultStatus);
}
if (
!prompt ||
window.confirm(
t("alerts.confirmAddLibrary", {
numShapes: nextItems.length,
}),
)
) {
if (prompt) {
// focus container if we've prompted. We focus conditionally
// lest `props.autoFocus` is disabled (in which case we should
// focus only on user action such as prompt confirm)
this.app.focusContainer();
}
if (merge) {
resolve(mergeLibraryItems(this.currLibraryItems, nextItems));
} else {
resolve(nextItems);
}
} else {
reject(new AbortError());
}
} catch (error: any) {
reject(error);
}
});
});
};
setLibrary = (
/**
* LibraryItems that will replace current items. Can be a function which
* will be invoked after all previous tasks are resolved
* (this is the prefered way to update the library to avoid race conditions,
* but you'll want to manually merge the library items in the callback
* - which is what we're doing in Library.importLibrary()).
*
* If supplied promise is rejected with AbortError, we swallow it and
* do not update the library.
*/
libraryItems:
| LibraryItems
| Promise<LibraryItems>
| ((
latestLibraryItems: LibraryItems,
) => LibraryItems | Promise<LibraryItems>),
): Promise<LibraryItems> => {
const task = new Promise<LibraryItems>(async (resolve, reject) => {
try {
await this.getLastUpdateTask();
if (typeof libraryItems === "function") {
libraryItems = libraryItems(this.currLibraryItems);
}
this.currLibraryItems = cloneLibraryItems(await libraryItems);
resolve(this.currLibraryItems);
} catch (error: any) {
reject(error);
}
})
.catch((error) => {
if (error.name === "AbortError") {
console.warn("Library update aborted by user");
return this.currLibraryItems;
}
throw error;
})
.finally(() => {
this.updateQueue = this.updateQueue.filter((_task) => _task !== task);
this.notifyListeners();
});
this.updateQueue.push(task);
this.notifyListeners();
return task;
};
}
export default Library;
export const distributeLibraryItemsOnSquareGrid = (
libraryItems: LibraryItems,
) => {
const PADDING = 50;
const ITEMS_PER_ROW = Math.ceil(Math.sqrt(libraryItems.length));
const resElements: ExcalidrawElement[] = [];
const getMaxHeightPerRow = (row: number) => {
const maxHeight = libraryItems
.slice(row * ITEMS_PER_ROW, row * ITEMS_PER_ROW + ITEMS_PER_ROW)
.reduce((acc, item) => {
const { height } = getCommonBoundingBox(item.elements);
return Math.max(acc, height);
}, 0);
return maxHeight;
};
const getMaxWidthPerCol = (targetCol: number) => {
let index = 0;
let currCol = 0;
let maxWidth = 0;
for (const item of libraryItems) {
if (index % ITEMS_PER_ROW === 0) {
currCol = 0;
}
if (currCol === targetCol) {
const { width } = getCommonBoundingBox(item.elements);
maxWidth = Math.max(maxWidth, width);
}
index++;
currCol++;
}
return maxWidth;
};
let colOffsetX = 0;
let rowOffsetY = 0;
let maxHeightCurrRow = 0;
let maxWidthCurrCol = 0;
let index = 0;
let col = 0;
let row = 0;
for (const item of libraryItems) {
if (index && index % ITEMS_PER_ROW === 0) {
rowOffsetY += maxHeightCurrRow + PADDING;
colOffsetX = 0;
col = 0;
row++;
}
if (col === 0) {
maxHeightCurrRow = getMaxHeightPerRow(row);
}
maxWidthCurrCol = getMaxWidthPerCol(col);
const { minX, minY, width, height } = getCommonBoundingBox(item.elements);
const offsetCenterX = (maxWidthCurrCol - width) / 2;
const offsetCenterY = (maxHeightCurrRow - height) / 2;
resElements.push(
// eslint-disable-next-line no-loop-func
...item.elements.map((element) => ({
...element,
x:
element.x +
// offset for column
colOffsetX +
// offset to center in given square grid
offsetCenterX -
// subtract minX so that given item starts at 0 coord
minX,
y:
element.y +
// offset for row
rowOffsetY +
// offset to center in given square grid
offsetCenterY -
// subtract minY so that given item starts at 0 coord
minY,
})),
);
colOffsetX += maxWidthCurrCol + PADDING;
index++;
col++;
}
return resElements;
};
export const parseLibraryTokensFromUrl = () => {
const libraryUrl =
// current
new URLSearchParams(window.location.hash.slice(1)).get(
URL_HASH_KEYS.addLibrary,
) ||
// legacy, kept for compat reasons
new URLSearchParams(window.location.search).get(URL_QUERY_KEYS.addLibrary);
const idToken = libraryUrl
? new URLSearchParams(window.location.hash.slice(1)).get("token")
: null;
return libraryUrl ? { libraryUrl, idToken } : null;
};
class AdapterTransaction {
static queue = new Queue();
static async getLibraryItems(
adapter: LibraryPersistenceAdapter,
source: LibraryAdatapterSource,
_queue = true,
): Promise<LibraryItems> {
const task = () =>
new Promise<LibraryItems>(async (resolve, reject) => {
try {
const data = await adapter.load({ source });
resolve(restoreLibraryItems(data?.libraryItems || [], "published"));
} catch (error: any) {
reject(error);
}
});
if (_queue) {
return AdapterTransaction.queue.push(task);
}
return task();
}
static run = async <T>(
adapter: LibraryPersistenceAdapter,
fn: (transaction: AdapterTransaction) => Promise<T>,
) => {
const transaction = new AdapterTransaction(adapter);
return AdapterTransaction.queue.push(() => fn(transaction));
};
// ------------------
private adapter: LibraryPersistenceAdapter;
constructor(adapter: LibraryPersistenceAdapter) {
this.adapter = adapter;
}
getLibraryItems(source: LibraryAdatapterSource) {
return AdapterTransaction.getLibraryItems(this.adapter, source, false);
}
}
let lastSavedLibraryItemsHash = 0;
let librarySaveCounter = 0;
export const getLibraryItemsHash = (items: LibraryItems) => {
return hashString(
items
.map((item) => {
return `${item.id}:${hashElementsVersion(item.elements)}`;
})
.sort()
.join(),
);
};
const persistLibraryUpdate = async (
adapter: LibraryPersistenceAdapter,
update: LibraryUpdate,
): Promise<LibraryItems> => {
try {
librarySaveCounter++;
return await AdapterTransaction.run(adapter, async (transaction) => {
const nextLibraryItemsMap = arrayToMap(
await transaction.getLibraryItems("save"),
);
for (const [id] of update.deletedItems) {
nextLibraryItemsMap.delete(id);
}
const addedItems: LibraryItem[] = [];
// we want to merge current library items with the ones stored in the
// DB so that we don't lose any elements that for some reason aren't
// in the current editor library, which could happen when:
//
// 1. we haven't received an update deleting some elements
// (in which case it's still better to keep them in the DB lest
// it was due to a different reason)
// 2. we keep a single DB for all active editors, but the editors'
// libraries aren't synced or there's a race conditions during
// syncing
// 3. some other race condition, e.g. during init where emit updates
// for partial updates (e.g. you install a 3rd party library and
// init from DB only after — we emit events for both updates)
for (const [id, item] of update.addedItems) {
if (nextLibraryItemsMap.has(id)) {
// replace item with latest version
// TODO we could prefer the newer item instead
nextLibraryItemsMap.set(id, item);
} else {
// we want to prepend the new items with the ones that are already
// in DB to preserve the ordering we do in editor (newly added
// items are added to the beginning)
addedItems.push(item);
}
}
const nextLibraryItems = addedItems.concat(
Array.from(nextLibraryItemsMap.values()),
);
const version = getLibraryItemsHash(nextLibraryItems);
if (version !== lastSavedLibraryItemsHash) {
await adapter.save({ libraryItems: nextLibraryItems });
}
lastSavedLibraryItemsHash = version;
return nextLibraryItems;
});
} finally {
librarySaveCounter--;
}
};
export const useHandleLibrary = (
opts: {
excalidrawAPI: ExcalidrawImperativeAPI | null;
} & (
| {
/** @deprecated we recommend using `opts.adapter` instead */
getInitialLibraryItems?: () => MaybePromise<LibraryItemsSource>;
}
| {
adapter: LibraryPersistenceAdapter;
/**
* Adapter that takes care of loading data from legacy data store.
* Supply this if you want to migrate data on initial load from legacy
* data store.
*
* Can be a different LibraryPersistenceAdapter.
*/
migrationAdapter?: LibraryMigrationAdapter;
}
),
) => {
const { excalidrawAPI } = opts;
const optsRef = useRef(opts);
optsRef.current = opts;
const isLibraryLoadedRef = useRef(false);
useEffect(() => {
if (!excalidrawAPI) {
return;
}
// reset on editor remount (excalidrawAPI changed)
isLibraryLoadedRef.current = false;
const importLibraryFromURL = async ({
libraryUrl,
idToken,
}: {
libraryUrl: string;
idToken: string | null;
}) => {
const libraryPromise = new Promise<Blob>(async (resolve, reject) => {
try {
const request = await fetch(decodeURIComponent(libraryUrl));
const blob = await request.blob();
resolve(blob);
} catch (error: any) {
reject(error);
}
});
const shouldPrompt = idToken !== excalidrawAPI.id;
// wait for the tab to be focused before continuing in case we'll prompt
// for confirmation
await (shouldPrompt && document.hidden
? new Promise<void>((resolve) => {
window.addEventListener("focus", () => resolve(), {
once: true,
});
})
: null);
try {
await excalidrawAPI.updateLibrary({
libraryItems: libraryPromise,
prompt: shouldPrompt,
merge: true,
defaultStatus: "published",
openLibraryMenu: true,
});
} catch (error) {
throw error;
} finally {
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
const hash = new URLSearchParams(window.location.hash.slice(1));
hash.delete(URL_HASH_KEYS.addLibrary);
window.history.replaceState({}, APP_NAME, `#${hash.toString()}`);
} else if (window.location.search.includes(URL_QUERY_KEYS.addLibrary)) {
const query = new URLSearchParams(window.location.search);
query.delete(URL_QUERY_KEYS.addLibrary);
window.history.replaceState({}, APP_NAME, `?${query.toString()}`);
}
}
};
const onHashChange = (event: HashChangeEvent) => {
event.preventDefault();
const libraryUrlTokens = parseLibraryTokensFromUrl();
if (libraryUrlTokens) {
event.stopImmediatePropagation();
// If hash changed and it contains library url, import it and replace
// the url to its previous state (important in case of collaboration
// and similar).
// Using history API won't trigger another hashchange.
window.history.replaceState({}, "", event.oldURL);
importLibraryFromURL(libraryUrlTokens);
}
};
// -------------------------------------------------------------------------
// ---------------------------------- init ---------------------------------
// -------------------------------------------------------------------------
const libraryUrlTokens = parseLibraryTokensFromUrl();
if (libraryUrlTokens) {
importLibraryFromURL(libraryUrlTokens);
}
// ------ (A) init load (legacy) -------------------------------------------
if (
"getInitialLibraryItems" in optsRef.current &&
optsRef.current.getInitialLibraryItems
) {
console.warn(
"useHandleLibrar `opts.getInitialLibraryItems` is deprecated. Use `opts.adapter` instead.",
);
Promise.resolve(optsRef.current.getInitialLibraryItems())
.then((libraryItems) => {
excalidrawAPI.updateLibrary({
libraryItems,
// merge with current library items because we may have already
// populated it (e.g. by installing 3rd party library which can
// happen before the DB data is loaded)
merge: true,
});
})
.catch((error: any) => {
console.error(
`UseHandeLibrary getInitialLibraryItems failed: ${error?.message}`,
);
});
}
// -------------------------------------------------------------------------
// --------------------------------------------------------- init load -----
// -------------------------------------------------------------------------
// ------ (B) data source adapter ------------------------------------------
if ("adapter" in optsRef.current && optsRef.current.adapter) {
const adapter = optsRef.current.adapter;
const migrationAdapter = optsRef.current.migrationAdapter;
const initDataPromise = resolvablePromise<LibraryItems | null>();
// migrate from old data source if needed
// (note, if `migrate` function is defined, we always migrate even
// if the data has already been migrated. In that case it'll be a no-op,
// though with several unnecessary steps — we will still load latest
// DB data during the `persistLibraryChange()` step)
// -----------------------------------------------------------------------
if (migrationAdapter) {
initDataPromise.resolve(
promiseTry(migrationAdapter.load)
.then(async (libraryData) => {
let restoredData: LibraryItems | null = null;
try {
// if no library data to migrate, assume no migration needed
// and skip persisting to new data store, as well as well
// clearing the old store via `migrationAdapter.clear()`
if (!libraryData) {
return AdapterTransaction.getLibraryItems(adapter, "load");
}
restoredData = restoreLibraryItems(
libraryData.libraryItems || [],
"published",
);
// we don't queue this operation because it's running inside
// a promise that's running inside Library update queue itself
const nextItems = await persistLibraryUpdate(
adapter,
createLibraryUpdate([], restoredData),
);
try {
await migrationAdapter.clear();
} catch (error: any) {
console.error(
`couldn't delete legacy library data: ${error.message}`,
);
}
// migration suceeded, load migrated data
return nextItems;
} catch (error: any) {
console.error(
`couldn't migrate legacy library data: ${error.message}`,
);
// migration failed, load data from previous store, if any
return restoredData;
}
})
// errors caught during `migrationAdapter.load()`
.catch((error: any) => {
console.error(`error during library migration: ${error.message}`);
// as a default, load latest library from current data source
return AdapterTransaction.getLibraryItems(adapter, "load");
}),
);
} else {
initDataPromise.resolve(
promiseTry(AdapterTransaction.getLibraryItems, adapter, "load"),
);
}
// load initial (or migrated) library
excalidrawAPI
.updateLibrary({
libraryItems: initDataPromise.then((libraryItems) => {
const _libraryItems = libraryItems || [];
lastSavedLibraryItemsHash = getLibraryItemsHash(_libraryItems);
return _libraryItems;
}),
// merge with current library items because we may have already
// populated it (e.g. by installing 3rd party library which can
// happen before the DB data is loaded)
merge: true,
})
.finally(() => {
isLibraryLoadedRef.current = true;
});
}
// ---------------------------------------------- data source datapter -----
window.addEventListener(EVENT.HASHCHANGE, onHashChange);
return () => {
window.removeEventListener(EVENT.HASHCHANGE, onHashChange);
};
}, [
// important this useEffect only depends on excalidrawAPI so it only reruns
// on editor remounts (the excalidrawAPI changes)
excalidrawAPI,
]);
// This effect is run without excalidrawAPI dependency so that host apps
// can run this hook outside of an active editor instance and the library
// update queue/loop survives editor remounts
//
// This effect is still only meant to be run if host apps supply an persitence
// adapter. If we don't have access to it, it the update listener doesn't
// do anything.
useEffect(
() => {
// on update, merge with current library items and persist
// -----------------------------------------------------------------------
const unsubOnLibraryUpdate = onLibraryUpdateEmitter.on(
async (update, nextLibraryItems) => {
const isLoaded = isLibraryLoadedRef.current;
// we want to operate with the latest adapter, but we don't want this
// effect to rerun on every adapter change in case host apps' adapter
// isn't stable
const adapter =
("adapter" in optsRef.current && optsRef.current.adapter) || null;
try {
if (adapter) {
if (
// if nextLibraryItems hash identical to previously saved hash,
// exit early, even if actual upstream state ends up being
// different (e.g. has more data than we have locally), as it'd
// be low-impact scenario.
lastSavedLibraryItemsHash !==
getLibraryItemsHash(nextLibraryItems)
) {
await persistLibraryUpdate(adapter, update);
}
}
} catch (error: any) {
console.error(
`couldn't persist library update: ${error.message}`,
update,
);
// currently we only show error if an editor is loaded
if (isLoaded && optsRef.current.excalidrawAPI) {
optsRef.current.excalidrawAPI.updateScene({
appState: {
errorMessage: t("errors.saveLibraryError"),
},
});
}
}
},
);
const onUnload = (event: Event) => {
if (librarySaveCounter) {
preventUnload(event);
}
};
window.addEventListener(EVENT.BEFORE_UNLOAD, onUnload);
return () => {
window.removeEventListener(EVENT.BEFORE_UNLOAD, onUnload);
unsubOnLibraryUpdate();
lastSavedLibraryItemsHash = 0;
librarySaveCounter = 0;
};
},
[
// this effect must not have any deps so it doesn't rerun
],
);
};