Files
MajoorWaldi-ComfyUI-Majoor-…/ui/vue/composables/useGridLoader.ts
T

1690 lines
67 KiB
TypeScript

import { nextTick } from "vue";
import { get } from "../../api/client.js";
import { EVENTS } from "../../app/events.js";
import { buildListURL } from "../../api/endpoints.js";
import { APP_CONFIG } from "../../app/config.js";
import { loadMajoorSettings } from "../../app/settings.js";
import { setFileBadgeCollision } from "../../components/Badges.js";
import { pickRootId } from "../../utils/ids.js";
import { isGridHostVisible } from "./gridVisibility.js";
import { consumeEarlyFetch, peekEarlyFetchKey } from "../../features/runtime/earlyFetch.js";
import { mjrDbg } from "../../utils/logging.js";
import {
appendAssets as cardAppendAssets,
rebuildAssetRendererState,
} from "../../features/grid/AssetCardRenderer.js";
import { getStackAwareAssetKey, ensureDupStackCard, disposeStackGroupCards } from "../../features/grid/StackGroupCards.js";
import {
gridListQueryKey,
isDefaultOutputBrowseQuery,
readGridQueryFromDataset,
readGridQueryText,
readGridSortKey,
} from "../grid/useGridQuery.js";
import {
buildGridSnapshotKey,
compactSnapshotAsset,
flushGridSnapshotsPersist,
getGridSnapshot,
hasGridSnapshot,
rememberGridSnapshot,
} from "../grid/useGridSnapshotCache.js";
import { removeAssetsFromState, syncAssetCollectionState } from "../grid/useAssetCollection.js";
import { usePagedAssets } from "../grid/usePagedAssets.js";
import {
compareAssets,
fetchPage as fetchGridPage,
flushUpsertBatch,
getUpsertBatchState,
upsertAsset as queueUpsertAsset,
} from "./useVirtualGrid.js";
import {
findAssetCardById,
findSelectedAssetCard,
readGridShownCount,
} from "../components/grid/gridDomBridge.js";
const UPSERT_BATCH_DEBOUNCE_MS = 200;
const UPSERT_BATCH_MAX_SIZE = 50;
const UPSERT_BATCH_STATE = new WeakMap();
const IMMEDIATE_RESET_REASONS = new Set([
"collection",
"filter",
"initial",
"scope",
"search",
"sort",
]);
// 0 = no artificial skeleton hold. The skeleton naturally lasts the duration
// of the network/render work; an additional minimum delay only made the first
// paint feel sluggish on cold opens. Keep at 0 unless flicker is reintroduced.
const MIN_LOADING_SKELETON_MS = 0;
function waitMs(ms: any) {
return new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0)));
}
function getStableRealtimeAssetKey(asset: any) {
if (!asset || typeof asset !== "object") return "";
if (asset.id != null && String(asset.id).trim()) {
return `id:${String(asset.id).trim()}`;
}
const type = String(asset?.type || asset?.source || "output")
.trim()
.toLowerCase();
const rootId = String(pickRootId(asset) || "")
.trim()
.toLowerCase();
const subfolder = String(asset?.subfolder || "")
.trim()
.toLowerCase();
const filename = String(asset?.filename || "")
.trim()
.toLowerCase();
if (filename) return `${type}|${rootId}|${subfolder}|${filename}`;
const filepath = String(
asset?.filepath || asset?.path || asset?.fullpath || asset?.full_path || "",
)
.trim()
.toLowerCase();
return filepath ? `${type}|${rootId}|path|${filepath}` : "";
}
function sanitizeQuery(query: any) {
if (query == null) return query;
const raw = String(query);
if (raw.trim() === "") return raw;
if (raw.trim() === "*") return "*";
const cleaned = raw
.replace(/[\u0000-\u001f\u007f]+/g, " ")
.replace(/[<>]/g, " ")
.replace(/\s+/g, " ")
.trim();
return cleaned || raw;
}
function coerceQueryText(query: any) {
if (typeof query === "string") return query;
if (query && typeof query === "object") {
if (typeof query.value === "string") return query.value;
if (typeof query.target?.value === "string") return query.target.value;
try {
if (typeof query.querySelector === "function") {
const nested =
query.querySelector("#mjr-search-input") ||
query.querySelector("input.mjr-input") ||
query.querySelector("input[type='text']") ||
query.querySelector("textarea");
if (nested && typeof nested.value === "string") return nested.value;
}
} catch (e) {
console.debug?.(e);
}
}
return String(query || "");
}
function resolveElement(maybeRef: any) {
if (!maybeRef) return null;
if (typeof maybeRef === "object" && "value" in maybeRef) {
return maybeRef.value || null;
}
return maybeRef;
}
async function waitForLayout() {
// PERF FIX #3: single nextTick instead of nextTick + double rAF.
// The double rAF was costing ~33ms per call for negligible benefit;
// the virtualizer + ResizeObserver settle on their own without it.
await nextTick();
}
function formatLoadErrorMessage(prefix: any, err: any) {
const message = String(prefix || "Failed to load");
try {
const raw = String(err?.message || err || "").trim();
const low = raw.toLowerCase();
if (!raw) return message;
if (
low.includes("database is locked") ||
low.includes("database table is locked") ||
low.includes("database schema is locked")
) {
return `${message}: the database is temporarily locked. Please retry in a few seconds.`;
}
return `${message}: ${raw}`;
} catch {
return message;
}
}
function getAdaptivePageLimit(baseLimit: any, emptyAppendBatches: any) {
const safeBaseLimit = Math.max(1, Math.min(APP_CONFIG.MAX_PAGE_SIZE, Number(baseLimit) || 1));
const growthStep = Math.max(0, Number(emptyAppendBatches) || 0);
if (growthStep <= 0) return safeBaseLimit;
const multiplier = 2 ** Math.min(growthStep, 5);
return Math.max(1, Math.min(APP_CONFIG.MAX_PAGE_SIZE, safeBaseLimit * multiplier));
}
function isConstrainedBrowserRuntime() {
try {
const nav: any = typeof navigator !== "undefined" ? navigator : null;
const deviceMemory = Number(nav?.deviceMemory || 0) || 0;
if (deviceMemory > 0 && deviceMemory <= 4) return true;
if (nav?.connection?.saveData === true) return true;
} catch (e) {
console.debug?.(e);
}
return false;
}
function shouldPrefetchNextPage() {
if (!APP_CONFIG.PREFETCH_NEXT_PAGE) return false;
return !isConstrainedBrowserRuntime();
}
function resetAssetCollectionsState(state: any, options: Record<string, any> = {}) {
const assets = Array.isArray(state.assets) ? state.assets : [];
if (options.rebuildFromVisible && assets.length) {
rebuildAssetRendererState(state, assets, {
assetKey: options.assetKey || state.assetKeyFn,
preserveHiddenCount: !!options.preserveHiddenCount,
});
return;
}
rebuildAssetRendererState(state, [], {
assetKey: options.assetKey || state.assetKeyFn,
preserveHiddenCount: false,
});
}
export function useGridLoader({
gridContainerRef,
state,
setLoadingMessage,
clearLoadingMessage,
setStatusMessage,
clearStatusMessage,
resetAssets,
setSelection,
reconcileSelection,
readScrollElement = () => null,
readRenderedCards = () => [],
scrollToAssetId = () => {},
canLoadMore = null,
}: Record<string, any> = {}) {
let deferVisualResetUntilNextPage = false;
let deferredExecutionReload: any = null;
let prefetchTimer: any = null;
const metrics = {
pagesRequested: 0,
assetsReceived: 0,
visibleAssetsAdded: 0,
hiddenOrDedupedAssets: 0,
apiTimeMs: 0,
renderTimeMs: 0,
resetCount: 0,
blockedImmediateResetCount: 0,
scrollRestoreCount: 0,
lastReloadReason: "",
lastResetReason: "",
lastAppendReason: "",
lastOperation: "",
};
function nowMs() {
try {
return typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now();
} catch {
return Date.now();
}
}
function recordOperation(name: any, detail: Record<string, any> = {}) {
metrics.lastOperation = String(name || "");
if (detail.reloadReason) metrics.lastReloadReason = String(detail.reloadReason || "");
if (detail.appendReason) metrics.lastAppendReason = String(detail.appendReason || "");
}
function getGridContainer() {
return resolveElement(gridContainerRef);
}
function getContextState() {
return buildCurrentSnapshotParts();
}
function getCanonicalState() {
const scrollElement = readScrollElement();
return {
assets: Array.isArray(state.assets) ? state.assets : [],
pagination: {
query: String(state.query || "*"),
offset: Number(state.offset || 0) || 0,
total: state.total == null ? null : Number(state.total) || 0,
loading: !!state.loading,
done: !!state.done,
requestId: Number(state.requestId || 0) || 0,
},
selection: {
selectedIds: Array.isArray(state.selectedIds)
? state.selectedIds.map(String).filter(Boolean)
: [],
activeId: String(state.activeId || ""),
anchorId: String(state.selectionAnchorId || ""),
},
viewport: {
scrollTop: Number(scrollElement?.scrollTop || 0) || 0,
clientHeight: Number(scrollElement?.clientHeight || 0) || 0,
scrollHeight: Number(scrollElement?.scrollHeight || 0) || 0,
},
context: getContextState(),
};
}
function getDebugSnapshot() {
const canonical = getCanonicalState();
return {
...canonical,
counts: {
loaded: canonical.assets.length,
visible: readGridShownCount(getGridContainer(), canonical.assets.length),
total: canonical.pagination.total,
},
metrics: { ...metrics },
};
}
function buildCurrentSnapshotParts() {
const gridContainer = getGridContainer();
return readGridQueryFromDataset(gridContainer?.dataset || {}, {
q: readGridQueryText(gridContainer, state.query || "*"),
resolutionCompare: gridContainer?.dataset?.mjrFilterResolutionCompare || "",
});
}
function getCurrentResetContext(query = state.query || "*") {
return {
...buildCurrentSnapshotParts(),
query: String(query || "*"),
};
}
function classifyContextResetReason(nextContext: any) {
const previous = state._mjrLastGridContext || null;
if (!previous) {
return Array.isArray(state.assets) && state.assets.length ? "unknown" : "initial";
}
if (String(previous.collectionId || "") !== String(nextContext.collectionId || "")) {
return "collection";
}
if (
String(previous.scope || "") !== String(nextContext.scope || "") ||
String(previous.customRootId || "") !== String(nextContext.customRootId || "") ||
String(previous.subfolder || "") !== String(nextContext.subfolder || "") ||
String(previous.viewScope || "") !== String(nextContext.viewScope || "")
) {
return "scope";
}
if (String(previous.sort || "") !== String(nextContext.sort || "")) {
return "sort";
}
const filterKeys = [
"kind",
"workflowOnly",
"minRating",
"minSizeMB",
"maxSizeMB",
"resolutionCompare",
"minWidth",
"minHeight",
"maxWidth",
"maxHeight",
"workflowType",
"workflowId",
"dateRange",
"dateExact",
"semanticMode",
];
if (filterKeys.some((key) => String(previous[key] || "") !== String(nextContext[key] || ""))) {
return "filter";
}
if (String(previous.query || "*") !== String(nextContext.query || "*")) {
return "search";
}
return "refresh";
}
function resolveResetReason(query: any, options: Record<string, any> = {}) {
const explicit = String(options?.resetReason || options?.reason || "").trim().toLowerCase();
const nextContext = getCurrentResetContext(query);
const reason = explicit || classifyContextResetReason(nextContext);
return { reason, nextContext };
}
function canResetImmediately(reason: any) {
return IMMEDIATE_RESET_REASONS.has(String(reason || "").toLowerCase());
}
function rememberCurrentGridContext(context: any = null) {
try {
state._mjrLastGridContext = context || getCurrentResetContext(state.query || "*");
} catch (e) {
console.debug?.(e);
}
}
function shouldPreserveVisibleOnReset({ reset = true, preserveVisibleUntilReady = true } = {}) {
if (!reset || preserveVisibleUntilReady === false) return false;
return Array.isArray(state.assets) && state.assets.length > 0;
}
function canLoadFromHost() {
try {
if (typeof canLoadMore === "function") {
return !!canLoadMore();
}
} catch (e) {
console.debug?.(e);
}
return isGridHostVisible(getGridContainer(), readScrollElement());
}
function isExecutionBusy() {
if (!APP_CONFIG.DEFER_GRID_FETCH_DURING_EXECUTION) {
return false;
}
// PERF FIX #2: never defer the very first load (empty grid).
// The user expects assets to appear immediately when the panel opens,
// even if a generation is already in progress.
try {
if (!Array.isArray(state.assets) || state.assets.length === 0) {
return false;
}
} catch (e) {
console.debug?.(e);
}
try {
return !!String((window as any).__MJR_EXECUTION_RUNTIME__?.active_prompt_id || "").trim();
} catch (e) {
console.debug?.(e);
return false;
}
}
function clearDeferredExecutionReload() {
try {
if (deferredExecutionReload?.timer) {
clearTimeout(deferredExecutionReload.timer);
}
} catch (e) {
console.debug?.(e);
}
try {
if (deferredExecutionReload?.listener) {
window.removeEventListener(EVENTS.RUNTIME_STATUS, deferredExecutionReload.listener);
}
} catch (e) {
console.debug?.(e);
}
deferredExecutionReload = null;
}
function clearPrefetchTimer() {
try {
if (prefetchTimer) {
clearTimeout(prefetchTimer);
}
} catch (e) {
console.debug?.(e);
}
prefetchTimer = null;
}
function scheduleNextPagePrefetch(requestId: any, delayMs = APP_CONFIG.PREFETCH_NEXT_PAGE_DELAY_MS) {
if (!shouldPrefetchNextPage()) {
return;
}
clearPrefetchTimer();
const resolvedDelayMs = Math.max(0, Number(delayMs) || 0);
prefetchTimer = setTimeout(() => {
prefetchTimer = null;
if (state.requestId !== requestId || state.done || state.loading) {
return;
}
loadNextPage().catch((error) => {
console.debug?.("[AssetsManager][GridLoader] Delayed prefetch failed", error);
});
}, resolvedDelayMs);
}
function scheduleDeferredExecutionReload(query: any, options = {}) {
clearDeferredExecutionReload();
// Capture the snapshot context at schedule time so we can detect a
// user-initiated scope/sort/filter switch during execution. If the
// user navigates away (e.g. starts a generation on Output, switches
// to Custom, generation finishes) the deferred reload must NOT fire
// - otherwise it would clobber the new context's grid state.
const scheduledScopeKey = (() => {
try {
// Ensure we capture to correct snapshot parts even during rapid clicks.
return buildGridSnapshotKey(buildCurrentSnapshotParts());
} catch (e) {
console.debug?.(e);
return null;
}
})();
const retry = () => {
if (isExecutionBusy()) {
try {
deferredExecutionReload = {
...deferredExecutionReload,
timer: setTimeout(retry, 1250),
};
} catch (e) {
console.debug?.(e);
}
return;
}
clearDeferredExecutionReload();
// Re-read the current query from the grid container at retry time
// instead of using the stale captured value. The user may have
// changed the search query while execution was in progress.
const gridContainer = getGridContainer();
// Bail if the grid context (scope/sort/filters) changed during
// execution - the user is now looking at a different view and a
// reload would corrupt it.
if (scheduledScopeKey) {
let currentKey: any = null;
try {
currentKey = buildGridSnapshotKey(buildCurrentSnapshotParts());
} catch (e) {
console.debug?.(e);
}
if (currentKey && currentKey !== scheduledScopeKey) {
mjrDbg(
"[Grid] Deferred execution reload skipped - scope/filter changed during execution",
);
return;
}
}
const freshQuery = readGridQueryText(gridContainer, query);
Promise.resolve()
.then(() =>
loadAssets(freshQuery, {
...(options || {}),
reset: true,
preserveVisibleUntilReady: true,
}),
)
.catch((e) => console.debug?.(e));
};
const listener = (event: any) => {
const activePromptId = String(event?.detail?.active_prompt_id || "").trim();
if (!activePromptId) {
retry();
}
};
deferredExecutionReload = {
listener,
timer: setTimeout(retry, 1250),
};
try {
window.addEventListener(EVENTS.RUNTIME_STATUS, listener);
} catch (e) {
console.debug?.(e);
}
}
function assetKey(asset: any, gridContainer = null) {
if (!asset || typeof asset !== "object") return "";
const fallback =
asset.id != null
? `id:${asset.id}`
: `${asset.type || ""}|${pickRootId(asset)}|${asset.filepath || ""}|${asset.subfolder || ""}|${asset.filename || ""}`;
try {
const grid = gridContainer || getGridContainer() || (globalThis as any)?.__MJR_LAST_ASSETKEY_GRID__;
return getStackAwareAssetKey(grid, asset, fallback);
} catch (e) {
console.debug?.(e);
return fallback;
}
}
function canUseSnapshotCache() {
try {
const settings = loadMajoorSettings?.() || {};
const grid = (settings as Record<string, any>).grid || {};
return !!(
grid.snapshotCacheEnabled === true ||
grid.enableSnapshotCache === true ||
(settings as Record<string, any>).snapshotCacheEnabled === true ||
(settings as Record<string, any>).enableSnapshotCache === true
);
} catch (e) {
console.debug?.(e);
return false;
}
}
function rememberSnapshot(title = "") {
try {
if (!canUseSnapshotCache()) return;
const gridContainer = getGridContainer();
const assets = Array.isArray(state.assets) ? state.assets : [];
if (!gridContainer || !assets.length) return;
const snapshotAssets = assets
.map(compactSnapshotAsset)
.filter(Boolean);
if (!snapshotAssets.length) return;
const wasTruncated = snapshotAssets.length < assets.length;
const rawOffset = Number(state.offset || snapshotAssets.length) || snapshotAssets.length;
const rawTotal = Number(state.total ?? snapshotAssets.length);
const snapshotParts = readGridQueryFromDataset(gridContainer.dataset || {}, {
q: readGridQueryText(gridContainer, state.query || "*"),
resolutionCompare: gridContainer.dataset?.mjrFilterResolutionCompare || "",
});
const key = buildGridSnapshotKey(snapshotParts);
if (!key) return;
rememberGridSnapshot(key, {
assets: snapshotAssets,
title: String(title || "").trim(),
query: String(state.query || snapshotParts.query || "*").trim() || "*",
total: Number.isFinite(rawTotal) ? rawTotal : snapshotAssets.length,
offset: wasTruncated
? snapshotAssets.length
: Math.max(snapshotAssets.length, rawOffset),
done: wasTruncated ? false : !!state.done,
});
} catch (e) {
console.debug?.(e);
}
}
function clearPendingUpserts() {
const gridContainer = getGridContainer();
if (!gridContainer) return;
try {
const batchState = UPSERT_BATCH_STATE.get(gridContainer);
if (!batchState) return;
if (batchState.timer) {
clearTimeout(batchState.timer);
batchState.timer = null;
}
batchState.pending.clear();
batchState.flushing = false;
} catch (e) {
console.debug?.(e);
}
}
function appendAssets(gridContainer: any, assets: any) {
const startedAt = nowMs();
const receivedCount = Array.isArray(assets) ? assets.length : 0;
const addedCount: any = cardAppendAssets(gridContainer, assets, state, {
loadMajoorSettings,
clearGridMessage: () => {
clearStatusMessage();
},
ensureVirtualGrid: () => state.virtualGrid,
setFileBadgeCollision,
assetKey: (asset: any) => assetKey(asset, gridContainer),
ensureDupStackCard: (gc: any, card: any, asset: any) => ensureDupStackCard(gc, card, asset),
});
metrics.renderTimeMs += Math.max(0, nowMs() - startedAt);
metrics.assetsReceived += receivedCount;
metrics.visibleAssetsAdded += Number(addedCount || 0) || 0;
metrics.hiddenOrDedupedAssets += Math.max(0, receivedCount - (Number(addedCount || 0) || 0));
return addedCount;
}
/**
* Build a key fingerprint that matches `_buildEarlyFetchKey` in
* features/runtime/earlyFetch.js. The two MUST stay in sync - when they
* diverge the grid silently falls back to a second /list round-trip,
* which is precisely what the early fetch was meant to avoid.
*/
function buildEarlyFetchMatchKey(gridContainer: any, query: any) {
const normalizedQuery = String(query || "*").trim() || "*";
return gridListQueryKey(readGridQueryFromDataset(gridContainer?.dataset || {}, {
q: normalizedQuery,
}));
}
function isDefaultOutputBrowseContext(query: any) {
const context = buildCurrentSnapshotParts();
const safeQuery = sanitizeQuery(coerceQueryText(query).trim() || "*") || "*";
return isDefaultOutputBrowseQuery({ ...context, q: safeQuery });
}
function normalizePageForPagination(page: any, { query = "*", limit = 0, offset = 0 } = {}) {
if (!page?.ok || page.total == null || !isDefaultOutputBrowseContext(query)) {
return page;
}
const total = Number(page.total);
const count = Number(page.count ?? page.assets?.length ?? 0) || 0;
const requestedLimit = Math.max(1, Number(limit) || 1);
const currentOffset = Math.max(0, Number(offset) || 0);
const pageEnd = currentOffset + count;
const totalLooksLikeReturnedWindow =
Number.isFinite(total) &&
total > 0 &&
total <= pageEnd &&
count > 0 &&
count < requestedLimit;
if (!totalLooksLikeReturnedWindow) return page;
return { ...page, total: null };
}
async function fetchPage(query: any, limit: any, offset: any, { requestId = 0, signal = null as AbortSignal | null } = {}) {
const gridContainer = getGridContainer();
if (!gridContainer) {
return { ok: false, error: "Grid unavailable" };
}
// Try to use early-fetched data for the first page of default browse context.
// This significantly reduces perceived load time when opening the panel.
if (offset === 0) {
// Match against the FULL filter fingerprint, not just scope+sort.
// The early-fetch URL is built with the persisted filters too, so
// a workflowOnly/minRating/etc match here means the prefetched
// payload is the right one and we skip a redundant /list call.
const matchKey = buildEarlyFetchMatchKey(gridContainer, query);
const earlyKey = peekEarlyFetchKey();
if (earlyKey && earlyKey === matchKey) {
const earlyFetchPromise = consumeEarlyFetch(matchKey);
if (earlyFetchPromise) {
try {
const earlyResult = await earlyFetchPromise;
// Discard early fetch if the request has been superseded
// (e.g. scope switch while the promise was pending).
if (Number(state.requestId) !== Number(requestId)) {
return { ok: false, stale: true, error: "Stale early fetch" };
}
if (signal?.aborted) {
return { ok: false, aborted: true, error: "Aborted" };
}
// API returns { ok, data: { assets: [...], total, ... } }
const earlyAssets = earlyResult?.data?.assets;
if (earlyResult?.ok && Array.isArray(earlyAssets)) {
mjrDbg("[Grid] Using early-fetched data:", earlyAssets.length, "assets");
// Use null when the backend did not return a total (includeTotal=false).
// Falling back to earlyAssets.length would incorrectly set state.total
// to the page size (e.g. 80), causing state.done=true after the first
// page and blocking all subsequent pagination / infinite scroll.
const rawTotal = earlyResult.data?.total ?? earlyResult.meta?.total ?? null;
return normalizePageForPagination({
ok: true,
assets: earlyAssets,
total: rawTotal != null ? Number(rawTotal) || 0 : null,
count: earlyAssets.length,
limit: limit,
offset: 0,
nextCursor: earlyResult.data?.next_cursor || earlyResult.data?.nextCursor || null,
}, { query, limit, offset });
}
} catch (e) {
mjrDbg("[Grid] Early fetch failed, falling back to normal fetch", e);
}
}
}
}
const page = await fetchGridPage(
gridContainer,
query,
limit,
offset,
{
sanitizeQuery,
buildListURL,
get,
getGridState: () => state,
},
{ requestId, signal: (signal ?? undefined) as undefined, cursor: state.cursor || null },
);
return normalizePageForPagination(page, { query, limit, offset });
}
const pagedAssets = usePagedAssets({
query: {
get value() {
return state.query || "*";
},
set value(value) {
state.query = String(value || "*") || "*";
},
},
collection: {
append(assets = []) {
const gridContainer = getGridContainer();
return gridContainer && assets.length
? Number((appendAssets(gridContainer, assets) as any) || 0) || 0
: 0;
},
reset() {},
},
fetchPage: async ({ query, limit, offset, requestId }: { query: any; limit: any; offset: any; requestId: any }) => {
const pageStartedAt = nowMs();
metrics.pagesRequested += 1;
const page = await fetchPage(query, limit, offset, {
requestId,
signal: state.abortController?.signal || null,
});
metrics.apiTimeMs += Math.max(0, nowMs() - pageStartedAt);
return page;
},
pageSize: APP_CONFIG.DEFAULT_PAGE_SIZE,
});
function syncPagedStateFromLegacy() {
pagedAssets.setPageState({
offset: state.offset,
cursor: state.cursor,
total: state.total,
done: state.done,
requestId: state.requestId,
loading: false,
error: null,
});
}
function setLegacyPageState(nextState: Record<string, any> = {}, { syncPaged = true }: Record<string, any> = {}) {
if (!nextState || typeof nextState !== "object") return;
if (Object.prototype.hasOwnProperty.call(nextState, "offset")) {
state.offset = Math.max(0, Number(nextState.offset || 0) || 0);
}
if (Object.prototype.hasOwnProperty.call(nextState, "cursor")) {
state.cursor = nextState.cursor || null;
}
if (Object.prototype.hasOwnProperty.call(nextState, "total")) {
state.total = nextState.total == null ? null : Math.max(0, Number(nextState.total) || 0);
}
if (Object.prototype.hasOwnProperty.call(nextState, "done")) {
state.done = !!nextState.done;
}
if (syncPaged) syncPagedStateFromLegacy();
}
function syncLegacyStateFromPaged() {
const pageState = pagedAssets.getPageState();
setLegacyPageState(pageState, { syncPaged: false });
}
function repairOffsetFromLoadedAssets() {
const loadedCount = Array.isArray(state.assets) ? state.assets.length : 0;
if (loadedCount <= 0 || state.done) return;
if (Number(state.offset || 0) >= loadedCount) return;
setLegacyPageState({ offset: loadedCount }, { syncPaged: true });
}
function finalizeLoad({ title = "", showEmptyMessage = true }: Record<string, any> = {}) {
clearLoadingMessage();
if (!(Array.isArray(state.assets) && state.assets.length)) {
if (showEmptyMessage && (!state.statusMessage || !state.statusError)) {
setStatusMessage("No assets found");
}
} else {
clearStatusMessage();
rememberSnapshot(title || state.query);
}
const visibleIds = (Array.isArray(state.assets) ? state.assets : [])
.map((asset: any) => String(asset?.id || ""))
.filter(Boolean);
const resultIsComplete =
!!state.done ||
(Number.isFinite(Number(state.total)) &&
Number(state.total || 0) <= visibleIds.length);
if (resultIsComplete) {
reconcileSelection(visibleIds, { activeId: state.activeId });
}
}
// Append invariant: pagination may advance offset and append/patch assets,
// but it must not visually reset the grid, move scroll, or prune selection.
async function loadNextPage() {
const gridContainer = getGridContainer();
if (!gridContainer || state.loading || state.done) return { ok: true, skipped: true };
const scrollElement = readScrollElement();
const scrollTopBefore = Number(scrollElement?.scrollTop || 0) || 0;
let userScrolledDuringLoad = false;
const markUserScrollDuringLoad = () => {
userScrolledDuringLoad = true;
};
try {
scrollElement?.addEventListener?.("scroll", markUserScrollDuringLoad, {
passive: true,
once: true,
});
} catch (e) {
console.debug?.(e);
}
const selectionBefore = Array.isArray(state.selectedIds) ? state.selectedIds.join("|") : "";
const activeBefore = String(state.activeId || "");
recordOperation("appendNextPage", { appendReason: "pagination" });
if (!canLoadFromHost()) {
// When the grid is empty (initial load or post-reattach) and the host
// isn't measured yet, schedule a single retry so the first page loads
// once the layout settles - prevents a permanent blank grid.
if (!state.assets.length && !state.loading && shouldPrefetchNextPage()) {
scheduleNextPagePrefetch(state.requestId, 400);
}
return { ok: true, skipped: true, hidden: true };
}
if (isExecutionBusy()) {
return { ok: true, skipped: true, busy: true };
}
const baseLimit = Math.max(
1,
Math.min(APP_CONFIG.MAX_PAGE_SIZE, APP_CONFIG.DEFAULT_PAGE_SIZE),
);
const loadingStartedAt = Date.now();
state.loading = true;
clearStatusMessage();
try {
if (!state.done) {
if (!canLoadFromHost()) {
return { ok: true, skipped: true, hidden: true };
}
if (!deferVisualResetUntilNextPage) {
repairOffsetFromLoadedAssets();
}
syncPagedStateFromLegacy();
// Capture requestId at load start so beforeApplyPage can abort
// if a scope switch incremented it while the fetch was in flight.
const _loadReqId = state.requestId;
const pageResult = await pagedAssets.loadUntilVisible({
maxEmptyPages: 6,
canContinue: () => canLoadFromHost(),
getLimit: (emptyPageIndex: any) => getAdaptivePageLimit(baseLimit, emptyPageIndex),
beforeApplyPage: (page: any) => {
// Scope switch (or any requestId bump) happened while the
// fetch was in flight - discard the page to avoid applying
// stale data from the wrong offset / scope.
if (state.requestId !== _loadReqId) {
return { ok: false, stale: true };
}
const pageAssets = Array.isArray(page.assets) ? page.assets : [];
const responseHasItems = pageAssets.length > 0;
// Only commit a visual reset when the new page has
// something to render OR when the backend reports
// total=0. Transient empty pages must keep cached
// cards visible during scope switches.
if (!deferVisualResetUntilNextPage) return null;
const responseAssertsEmpty = page.total != null && Number(page.total) === 0;
if (responseHasItems || responseAssertsEmpty) {
resetAssets({ query: state.query || "*", total: null, done: false });
setLegacyPageState({ offset: 0, cursor: null, total: null, done: false });
resetAssetCollectionsState(state);
metrics.resetCount += 1;
deferVisualResetUntilNextPage = false;
return null;
}
if (Array.isArray(state.assets) && state.assets.length) {
mjrDbg(
"[Grid LoadPage] empty response with cached visible assets - preserving cached view",
{ offset: state.offset, query: state.query },
);
return {
ok: true,
count: 0,
total: state.total,
preservedCached: true,
};
}
return null;
},
onEmptyPage: (result: any) => {
if (result.emptyPageIndex <= 0) return;
mjrDbg(`[Grid LoadPage] fetched adaptive page with no visible additions: offset=${state.offset}, fetched=${result.count}, consumed=${result.advanced}, added=${result.added}, limit=${result.limit}, done=${state.done}, visibleCount=${state.assets.length}, total=${state.total}`);
},
});
syncLegacyStateFromPaged();
repairOffsetFromLoadedAssets();
if (!pageResult.ok) {
if (pageResult.aborted || pageResult.stale) {
return pageResult;
}
setLegacyPageState({ done: true });
setStatusMessage(
formatLoadErrorMessage("Failed to load assets", pageResult?.error || "Unknown error"),
{ error: true },
);
return pageResult;
}
if (pageResult.preservedCached) return pageResult;
if (state.done || pageResult.added > 0) {
rememberSnapshot(state.query || readGridQueryText(gridContainer, "*"));
return {
ok: true,
count: pageResult.count,
total: state.total,
};
}
const emptyResult = pageResult.lastResult || pageResult.firstResult || pageResult;
mjrDbg(`[Grid LoadPage] fetched one page with no visible additions: offset=${state.offset}, fetched=${pageResult.count}, consumed=${emptyResult.advanced}, added=${emptyResult.added}, limit=${emptyResult.limit || baseLimit}, done=${state.done}, visibleCount=${state.assets.length}, total=${state.total}`);
if (!state.done && shouldPrefetchNextPage()) {
scheduleNextPagePrefetch(
Number(state.requestId ?? 0) || 0,
Math.min(APP_CONFIG.PREFETCH_NEXT_PAGE_DELAY_MS ?? 700, 250),
);
}
return {
ok: true,
count: pageResult.count,
total: state.total,
skippedEmpty: true,
};
}
return {
ok: true,
count: 0,
total: state.total,
};
} catch (err) {
try {
if (String((err as any)?.name || "") === "AbortError") {
return { ok: false, aborted: true, error: "Aborted" };
}
} catch (e) {
console.debug?.(e);
}
setStatusMessage(formatLoadErrorMessage("Failed to load assets", err), {
error: true,
});
return { ok: false, error: (err as any)?.message || String(err) };
} finally {
try {
scrollElement?.removeEventListener?.("scroll", markUserScrollDuringLoad);
} catch (e) {
console.debug?.(e);
}
try {
if (
scrollElement &&
!userScrolledDuringLoad &&
Number(scrollElement.scrollTop || 0) !== scrollTopBefore
) {
scrollElement.scrollTop = scrollTopBefore;
metrics.scrollRestoreCount += 1;
}
} catch (e) {
console.debug?.(e);
}
try {
const selectionAfter = Array.isArray(state.selectedIds)
? state.selectedIds.join("|")
: "";
if (
(selectionAfter !== selectionBefore || String(state.activeId || "") !== activeBefore) &&
typeof setSelection === "function"
) {
setSelection(selectionBefore ? selectionBefore.split("|").filter(Boolean) : [], activeBefore, {
preserveAnchor: true,
});
}
} catch (e) {
console.debug?.(e);
}
try {
const elapsedMs = Date.now() - loadingStartedAt;
if (elapsedMs < MIN_LOADING_SKELETON_MS) {
await waitMs(MIN_LOADING_SKELETON_MS - elapsedMs);
}
} catch (e) {
console.debug?.(e);
}
state.loading = false;
clearLoadingMessage();
}
}
/**
* Background "head refresh" used after the snapshot fast-path returns
* cached:true. The snapshot may be stale (assets were generated while
* the user was on another scope, or the snapshot was restored from
* localStorage at page load), so we silently re-fetch page 0 and
* upsert any new items at their correct sort position. Existing cards
* stay put, scroll position is preserved, no flicker.
*
* Returns early when the request is superseded (state.requestId
* changed during the fetch) or when ComfyUI is generating.
*/
let _headRefreshTimer: any = null;
function scheduleSnapshotHeadRefresh(delayMs = 250) {
recordOperation("refreshHead", { appendReason: "snapshotHeadRefresh" });
try {
if (_headRefreshTimer) clearTimeout(_headRefreshTimer);
} catch (e) {
console.debug?.(e);
}
const requestId = state.requestId;
// Capture the active grid context (scope/sort/filters) at schedule
// time. If the user changes any filter or scope before the head
// refresh fires, we must abort - otherwise upsertAsset() would
// inject items that don't match the current filter into the grid.
let scheduledKey: any = null;
try {
scheduledKey = buildGridSnapshotKey(buildCurrentSnapshotParts());
} catch (e) {
console.debug?.(e);
}
_headRefreshTimer = setTimeout(async () => {
_headRefreshTimer = null;
if (Number(state.requestId) !== Number(requestId)) return;
if (isExecutionBusy()) return;
const gridContainer = getGridContainer();
if (!gridContainer) return;
// Filter/scope guard: bail if the user navigated away.
if (scheduledKey) {
let currentKey: any = null;
try {
currentKey = buildGridSnapshotKey(buildCurrentSnapshotParts());
} catch (e) {
console.debug?.(e);
}
if (currentKey && currentKey !== scheduledKey) {
mjrDbg("[Grid] Snapshot head refresh skipped - context changed");
return;
}
}
const headLimit = Math.max(
1,
Math.min(APP_CONFIG.MAX_PAGE_SIZE, APP_CONFIG.DEFAULT_PAGE_SIZE),
);
try {
const page = await fetchPage(state.query || "*", headLimit, 0, {
requestId,
signal: state.abortController?.signal || null,
});
if (Number(state.requestId) !== Number(requestId)) return;
// Re-check context after the await - the user may have
// switched scope while the fetch was in flight.
if (scheduledKey) {
let currentKey: any = null;
try {
currentKey = buildGridSnapshotKey(buildCurrentSnapshotParts());
} catch (e) {
console.debug?.(e);
}
if (currentKey && currentKey !== scheduledKey) {
mjrDbg("[Grid] Snapshot head refresh discarded - context changed mid-fetch");
return;
}
}
if (!page?.ok) return;
const pageAssets = Array.isArray(page.assets) ? page.assets : [];
if (page.total != null) setLegacyPageState({ total: page.total });
if (!pageAssets.length) return;
let inserted = 0;
for (const asset of pageAssets) {
if (asset && asset.id != null && upsertAsset(asset)) inserted += 1;
}
if (inserted > 0) {
rememberSnapshot(state.query || "*");
mjrDbg("[Grid] Snapshot head refresh: inserted", inserted, "new asset(s)");
}
} catch (e) {
console.debug?.("[Grid] Snapshot head refresh failed", e);
}
}, Math.max(0, Number(delayMs) || 0));
}
async function loadAssets(query = "*", options: Record<string, any> = {}) {
const startRequestId = state.requestId;
const { reset = true, preserveVisibleUntilReady = true } = options || {};
const gridContainer = getGridContainer();
if (!gridContainer) {
return { ok: false, error: "Grid unavailable" };
}
if (state.requestId !== startRequestId) {
console.debug?.("[Grid] loadAssets aborted early due to rapid scope switch");
return { ok: false, aborted: true };
}
try {
(globalThis as any).__MJR_LAST_ASSETKEY_GRID__ = gridContainer;
} catch (e) {
console.debug?.(e);
}
const requestedQuery = coerceQueryText(query).trim() || "*";
const normalizedRequestedQuery = /^\[object\s+HTML.*Element\]$/i.test(requestedQuery)
? "*"
: requestedQuery;
const safeQuery = sanitizeQuery(normalizedRequestedQuery) || normalizedRequestedQuery;
state.query = safeQuery;
const resetInfo = reset
? resolveResetReason(safeQuery, options)
: { reason: "append", nextContext: getCurrentResetContext(safeQuery) };
recordOperation("reload", { reloadReason: resetInfo.reason || "loadAssets" });
if (reset && isExecutionBusy()) {
// Try to hydrate from a cached snapshot before deferring so the
// grid never appears empty during a generation. Only the API
// refresh is deferred - the user still sees the previously loaded
// assets immediately. Falls back to the original "deferred" state
// when no snapshot is available.
let hydratedFromSnapshot = false;
try {
const snapshotParts = buildCurrentSnapshotParts();
(snapshotParts as Record<string, any>).query = safeQuery;
const snapshotKey = buildGridSnapshotKey(snapshotParts);
if (canUseSnapshotCache() && hasGridSnapshot(snapshotKey)) {
hydratedFromSnapshot = await hydrateFromSnapshot(snapshotParts, {
allowReplaceExisting: true,
});
if (state.requestId > startRequestId + (hydratedFromSnapshot ? 1 : 0)) {
console.debug?.("[Grid] loadAssets aborted during deferral due to scope switch");
return { ok: false, aborted: true };
}
}
} catch (e) {
console.debug?.(e);
}
if (hydratedFromSnapshot) {
clearLoadingMessage();
state.loading = false;
} else {
setLoadingMessage("Grid refresh deferred while ComfyUI is generating...");
}
scheduleDeferredExecutionReload(safeQuery, options || {});
return {
ok: true,
deferred: true,
cached: hydratedFromSnapshot,
count: Number(state.offset || 0) || 0,
total: Number(state.total || 0) || 0,
};
}
if (reset) {
const resetReason = resetInfo.reason || "unknown";
metrics.lastResetReason = resetReason;
const allowImmediateReset = canResetImmediately(resetReason);
if (!allowImmediateReset && Array.isArray(state.assets) && state.assets.length) {
metrics.blockedImmediateResetCount += 1;
}
deferVisualResetUntilNextPage = !allowImmediateReset || shouldPreserveVisibleOnReset({
reset,
preserveVisibleUntilReady,
});
try {
state.abortController?.abort?.();
} catch (e) {
console.debug?.(e);
}
state.loading = false;
try {
state.abortController =
typeof AbortController !== "undefined" ? new AbortController() : null;
} catch {
state.abortController = null;
}
state.requestId = (Number(state.requestId) || 0) + 1;
clearPrefetchTimer();
clearPendingUpserts();
clearStatusMessage();
if (deferVisualResetUntilNextPage) {
state.query = safeQuery;
setLegacyPageState({ offset: 0, cursor: null, total: null, done: false });
resetAssetCollectionsState(state, {
rebuildFromVisible: true,
preserveHiddenCount: true,
assetKey: (asset: any) => assetKey(asset, gridContainer),
});
} else {
resetAssets({ query: safeQuery, total: null, done: false });
setLegacyPageState({ offset: 0, cursor: null, total: null, done: false });
resetAssetCollectionsState(state);
metrics.resetCount += 1;
}
setLoadingMessage(
safeQuery === "*" ? "Loading assets..." : `Searching for "${safeQuery}"...`,
);
}
const result = await loadNextPage();
repairOffsetFromLoadedAssets();
// Prefetch next page after the first render settles.
if (
shouldPrefetchNextPage() &&
result?.ok &&
!result?.skipped &&
!state.done &&
!result?.aborted
) {
scheduleNextPagePrefetch(state.requestId);
}
if (reset) {
const showEmptyMessage = !(
result?.skipped ||
result?.hidden ||
result?.aborted ||
result?.stale ||
result?.preservedCached ||
result?.busy
);
finalizeLoad({ title: safeQuery, showEmptyMessage });
rememberCurrentGridContext(resetInfo.nextContext);
}
return {
ok: !!result?.ok,
count: Number(state.offset || 0) || 0,
total: Number(state.total || 0) || 0,
error: result?.error,
aborted: !!result?.aborted,
};
}
async function loadAssetsFromList(assets: any, options: Record<string, any> = {}) {
const {
title = "Collection",
reset = true,
showLoading = true,
preserveVisibleUntilReady = true,
} = options || {};
recordOperation("reload", { reloadReason: options?.resetReason || options?.reason || "collection" });
const gridContainer = getGridContainer();
if (!gridContainer) {
return { ok: false, error: "Grid unavailable" };
}
const list = Array.isArray(assets) ? assets.slice() : [];
const loadingStartedAt = Date.now();
const preserveVisible = shouldPreserveVisibleOnReset({
reset,
preserveVisibleUntilReady,
});
if (reset) {
metrics.lastResetReason = String(options?.resetReason || options?.reason || "collection");
try {
state.abortController?.abort?.();
} catch (e) {
console.debug?.(e);
}
try {
state.abortController =
typeof AbortController !== "undefined" ? new AbortController() : null;
} catch {
state.abortController = null;
}
state.requestId = (Number(state.requestId) || 0) + 1;
clearPendingUpserts();
clearStatusMessage();
if (preserveVisible) {
state.query = String(title || "Collection");
setLegacyPageState({ offset: 0, cursor: null, total: list.length, done: true });
} else {
resetAssets({
query: String(title || "Collection"),
total: list.length,
done: true,
});
setLegacyPageState({ offset: 0, cursor: null, total: list.length, done: true });
resetAssetCollectionsState(state);
metrics.resetCount += 1;
}
if (showLoading) {
setLoadingMessage(list.length ? `Loading ${title}...` : `${title} is empty`);
state.loading = true;
} else {
clearLoadingMessage();
state.loading = false;
}
}
try {
const sortKey = readGridSortKey(gridContainer, "mtime_desc");
const sorted = list.sort((a, b) => compareAssets(a, b, sortKey));
if (reset && preserveVisible) {
resetAssets({
query: String(title || "Collection"),
total: list.length,
done: true,
});
resetAssetCollectionsState(state);
metrics.resetCount += 1;
}
appendAssets(gridContainer, sorted);
setLegacyPageState({ offset: sorted.length, cursor: null, total: sorted.length, done: true });
finalizeLoad({ title });
rememberCurrentGridContext(getCurrentResetContext(String(title || "Collection")));
return { ok: true, count: sorted.length, total: sorted.length };
} catch (err) {
setStatusMessage(formatLoadErrorMessage("Failed to load collection", err), {
error: true,
});
return { ok: false, error: (err as any)?.message || String(err) };
} finally {
if (showLoading) {
try {
const elapsedMs = Date.now() - loadingStartedAt;
if (elapsedMs < MIN_LOADING_SKELETON_MS) {
await waitMs(MIN_LOADING_SKELETON_MS - elapsedMs);
}
} catch (e) {
console.debug?.(e);
}
}
state.loading = false;
if (showLoading) {
clearLoadingMessage();
}
}
}
function prepareGridForScopeSwitch() {
recordOperation("reload", { reloadReason: "scopeSwitch" });
try {
disposeStackGroupCards(getGridContainer());
} catch (e) {
console.debug?.(e);
}
try {
state.abortController?.abort?.();
} catch (e) {
console.debug?.(e);
}
state.abortController = null;
state.requestId = (Number(state.requestId) || 0) + 1;
state.loading = true;
// Reset pagination state so that any maybeFillViewport call triggered
// by the visual reset cannot start a competing load before reloadGrid()
// hands the new request to loadAssets(reset).
setLegacyPageState({ offset: 0, cursor: null, total: null, done: false });
clearPrefetchTimer();
clearPendingUpserts();
clearLoadingMessage();
clearStatusMessage();
deferVisualResetUntilNextPage = true;
// void hydrateFromSnapshot(buildCurrentSnapshotParts(), { allowReplaceExisting: true }); // Disabled: prevents old images from flashing during tab switch
setSelection([], "");
}
function refreshGrid() {
state.virtualGrid?.setItems?.(state.assets || []);
}
function removeAssets(assetIds: any, { updateSelection = true } = {}) {
const removedCount = removeAssetsFromState(state, assetIds, {
assetKey: (asset) => assetKey(asset, getGridContainer()),
});
if (!removedCount) {
return { ok: false, removed: 0, selectedIds: state.selectedIds.slice() };
}
if (Number.isFinite(Number(state.total))) {
setLegacyPageState({ total: Math.max(0, Number(state.total || 0) - removedCount) });
}
if (updateSelection) {
const visibleIds = state.assets.map((asset: any) => String(asset?.id || "")).filter(Boolean);
reconcileSelection(visibleIds, { activeId: state.activeId });
}
if (!state.assets.length && !state.loading) {
setStatusMessage("No assets found");
}
return {
ok: true,
removed: removedCount,
selectedIds: state.selectedIds.slice(),
};
}
function captureAnchor() {
const gridContainer = getGridContainer();
const scrollElement = readScrollElement();
if (!gridContainer || !scrollElement) return null;
const isCardVisibleInHost = (card: any) => {
if (!card) return false;
try {
const rect = card.getBoundingClientRect();
const hostRect = scrollElement.getBoundingClientRect();
return rect.bottom >= hostRect.top && rect.top <= hostRect.bottom;
} catch {
return false;
}
};
const selectedElement = findSelectedAssetCard(gridContainer);
const visibleCards = readRenderedCards();
const firstVisible = visibleCards.find((card: any) => isCardVisibleInHost(card)) || visibleCards[0] || null;
const selectedVisible = isCardVisibleInHost(selectedElement);
const anchorEl = selectedVisible ? selectedElement : firstVisible;
if (!anchorEl) return null;
try {
const rect = anchorEl.getBoundingClientRect();
const hostRect = scrollElement.getBoundingClientRect();
return {
id: String(anchorEl.dataset?.mjrAssetId || ""),
top: rect.top - hostRect.top,
scrollTop: Number(scrollElement.scrollTop || 0) || 0,
};
} catch (e) {
console.debug?.(e);
return null;
}
}
async function restoreAnchor(anchor: any) {
const gridContainer = getGridContainer();
const scrollElement = readScrollElement();
if (!gridContainer || !scrollElement || !anchor) return;
await waitForLayout();
const id = String(anchor.id || "").trim();
if (!id) {
scrollElement.scrollTop = Number(anchor.scrollTop || 0) || 0;
metrics.scrollRestoreCount += 1;
return;
}
scrollToAssetId(id, { align: "start" });
await waitForLayout();
const target = findAssetCardById(gridContainer, id);
if (!target) {
scrollElement.scrollTop = Number(anchor.scrollTop || 0) || 0;
metrics.scrollRestoreCount += 1;
return;
}
try {
const rect = target.getBoundingClientRect();
const hostRect = scrollElement.getBoundingClientRect();
const delta = rect.top - hostRect.top - Number(anchor.top || 0);
scrollElement.scrollTop = (Number(scrollElement.scrollTop || 0) || 0) + delta;
metrics.scrollRestoreCount += 1;
} catch (e) {
console.debug?.(e);
scrollElement.scrollTop = Number(anchor.scrollTop || 0) || 0;
metrics.scrollRestoreCount += 1;
}
}
async function hydrateFromSnapshot(parts: Record<string, any> = {}, options: Record<string, any> = {}) {
const gridContainer = getGridContainer();
if (!gridContainer) return false;
if (!canUseSnapshotCache()) return false;
const key = buildGridSnapshotKey(parts);
const snapshot = getGridSnapshot(key);
if (!snapshot || !Array.isArray(snapshot.assets) || !snapshot.assets.length) {
return false;
}
if (!options.allowReplaceExisting && Array.isArray(state.assets) && state.assets.length) {
return false;
}
metrics.lastResetReason = "snapshot";
try {
state.abortController?.abort?.();
} catch (e) {
console.debug?.(e);
}
try {
state.abortController =
typeof AbortController !== "undefined" ? new AbortController() : null;
} catch {
state.abortController = null;
}
state.requestId = (Number(state.requestId) || 0) + 1;
clearPrefetchTimer();
clearPendingUpserts();
resetAssets({
query: snapshot.query || options.title || "Cached",
total: Number(snapshot.total ?? snapshot.assets.length) || snapshot.assets.length,
done: !!snapshot.done,
});
resetAssetCollectionsState(state);
metrics.resetCount += 1;
clearStatusMessage();
clearLoadingMessage();
state.loading = false;
appendAssets(gridContainer, snapshot.assets);
setLegacyPageState({
offset: Math.max(
Number(snapshot.assets.length || 0) || 0,
Number(snapshot.offset || snapshot.assets.length) || snapshot.assets.length,
),
cursor: (snapshot as any).cursor || null,
total: Number(snapshot.total ?? snapshot.assets.length) || snapshot.assets.length,
done: !!snapshot.done,
});
// Mark this hydrate so loadAssets fast-path can skip a redundant
// re-hydrate triggered immediately after by scopeController.
state._mjrLastHydrateKey = key;
state._mjrLastHydrateAt = Date.now();
rememberCurrentGridContext({ ...parts, query: snapshot.query || parts.query || "*" });
// The snapshot is now on screen - any subsequent prefetch must
// APPEND, not reset. Clear the defer flag set by
// prepareGridForScopeSwitch / loadAssets so loadNextPage doesn't
// wipe visible cards on the next response.
deferVisualResetUntilNextPage = false;
finalizeLoad({ title: snapshot.title || options.title || "Cached" });
return true;
}
function _buildUpsertDeps(gridContainer: any) {
return {
getOrCreateState: () => state,
ensureVirtualGrid: () => state.virtualGrid,
upsertState: UPSERT_BATCH_STATE,
maxBatchSize: UPSERT_BATCH_MAX_SIZE,
debounceMs: UPSERT_BATCH_DEBOUNCE_MS,
assetKey: (asset: any) => getStableRealtimeAssetKey(asset) || assetKey(asset, gridContainer),
loadMajoorSettings,
};
}
function upsertAsset(asset: any) {
recordOperation("upsertRealtime", { appendReason: "realtime" });
const gridContainer = getGridContainer();
if (!gridContainer || !asset || !asset.id) return false;
return queueUpsertAsset(gridContainer, asset, _buildUpsertDeps(gridContainer));
}
/**
* Upsert an asset and flush within the current microtask boundary.
*
* Multiple calls in the same event-loop tick are coalesced into a single
* flush via queueMicrotask. This avoids N separate flush+setItems calls
* when N WebSocket events arrive nearly simultaneously (e.g. 5 images
* generated in a batch), while still being much faster than the 200 ms
* debounce path.
*/
let _immediateFlushScheduled = false;
function upsertAssetNow(asset: any) {
recordOperation("upsertRealtime", { appendReason: "realtimeImmediate" });
const gridContainer = getGridContainer();
if (!gridContainer || !asset || !asset.id) return false;
const deps = _buildUpsertDeps(gridContainer);
const ok = queueUpsertAsset(gridContainer, asset, deps);
if (ok && !_immediateFlushScheduled) {
_immediateFlushScheduled = true;
queueMicrotask(() => {
_immediateFlushScheduled = false;
const gc = getGridContainer();
if (gc) flushUpsertBatch(gc, _buildUpsertDeps(gc));
});
}
return ok;
}
function dispose() {
rememberSnapshot(state.query || "Cached");
// Force any pending debounced storage write to flush before unload.
try { flushGridSnapshotsPersist(); } catch (e) { console.debug?.(e); }
clearDeferredExecutionReload();
clearPrefetchTimer();
try {
state.abortController?.abort?.();
} catch (e) {
console.debug?.(e);
}
const gridContainer = getGridContainer();
if (gridContainer) {
try {
const batchState = getUpsertBatchState(gridContainer, UPSERT_BATCH_STATE);
if (batchState?.timer) {
clearTimeout(batchState.timer);
batchState.timer = null;
}
batchState?.pending?.clear?.();
} catch (e) {
console.debug?.(e);
}
}
}
// Abort any in-flight requests before the page unloads so they don't
// appear as epoch-1 cancelled requests in the next session's network log.
try {
window.addEventListener("pagehide", dispose, { once: true });
} catch (e) {
console.debug?.(e);
}
return {
reload: loadAssets,
appendNextPage: loadNextPage,
refreshHead: scheduleSnapshotHeadRefresh,
upsertRealtime: upsertAssetNow,
loadAssets,
loadAssetsFromList,
loadNextPage,
prepareGridForScopeSwitch,
refreshGrid,
removeAssets,
captureAnchor,
restoreAnchor,
hydrateFromSnapshot,
upsertAsset,
upsertAssetNow,
getCanonicalState,
getDebugSnapshot,
dispose,
};
}