mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-28 23:57:17 +09:00
New resources can fail to show data in the **New variable** dialog because a page-plan refresh cancels their preview request before it finishes. This change keeps explicitly requested previews alive until the dialog releases them. Changing inputs, changing resource type, or closing the dialog cancels obsolete work and prevents old data from showing. Unused page resources remain lazy; a preview still makes an on-demand request when **Load data** is clicked. The dialog stays usable while unrelated page resources are loading. Follow-up to #6030. ### Checklist - [x] Reproduce the cancellation with a regression test that fails before the fix. - [x] Keep dialog previews independent of page resource discovery, using the existing loader, cache, and request versions. - [x] Clear old previews on input changes and release requests on dialog close. - [x] Cover new Current date, Sitemap, HTTP, and existing unbound resources, plus repeated loads and stale responses. - [x] Keep tests for obsolete page-request cancellation and async resource dependencies passing. - [x] Test the unsaved dialog with an unrelated page request pending and a page-plan recalculation. - [x] Clear the old preview when switching resource types or starting an asynchronous body edit. - [x] Review documentation impact: no update needed; this restores the documented preview behavior. - [ ] Confirm GitHub CI passes. ### Verification - The dialog loading, type-switch, and body-edit regression tests failed before their fixes and passed afterward. - 82 focused tests and the settings-panel suite (588 tests) passed. - Builder typecheck and repository lint passed. - With preview retention temporarily disabled, all nine new loader regression cases failed as expected.
655 lines
19 KiB
TypeScript
655 lines
19 KiB
TypeScript
import { atom, computed } from "nanostores";
|
|
import {
|
|
getResourceDependencyIds,
|
|
type DataSource,
|
|
type DataSources,
|
|
type Resource,
|
|
type ResourceRequest,
|
|
type Resources,
|
|
} from "@webstudio-is/sdk";
|
|
import {
|
|
isAssetsResourceRequest,
|
|
resourceLoadConcurrency,
|
|
} from "@webstudio-is/sdk/runtime";
|
|
import { restResourcesLoader } from "./router-utils";
|
|
import {
|
|
computeExpression,
|
|
type ResolveExpressionDataSource,
|
|
} from "@webstudio-is/project-build/runtime";
|
|
import { fetch } from "./fetch.client";
|
|
import { getResourceKey } from "./resource-utils";
|
|
import { type AssetQueryPreviewDiagnostics } from "@webstudio-is/content-engine";
|
|
import {
|
|
createResourceDiagnosticsResponseError,
|
|
separateResourceDiagnostics,
|
|
type ResourcePerformance,
|
|
} from "./resource-diagnostics";
|
|
|
|
type InFlightResourceBatch = {
|
|
controller: AbortController;
|
|
versions: Map<string, number>;
|
|
};
|
|
|
|
type InFlightResourceDiagnostics = {
|
|
controller: AbortController;
|
|
promise: Promise<void>;
|
|
};
|
|
|
|
export { getResourceKey };
|
|
|
|
const queue = new Map<string, ResourceRequest>();
|
|
const pending = new Map<string, InFlightResourceBatch>();
|
|
const cache = new Map<string, unknown>();
|
|
const diagnosticsCache = new Map<string, AssetQueryPreviewDiagnostics>();
|
|
const diagnosticsErrorCache = new Map<string, unknown>();
|
|
const performanceCache = new Map<string, ResourcePerformance>();
|
|
const pendingDiagnostics = new Map<string, InFlightResourceDiagnostics>();
|
|
const knownRequests = new Map<string, ResourceRequest>();
|
|
const pageRequestKeys = new Set<string>();
|
|
const previewRequests = new Map<string, Set<symbol>>();
|
|
const resourceVersions = new Map<string, number>();
|
|
const inFlightBatches = new Set<InFlightResourceBatch>();
|
|
|
|
export const $resourcesCache = atom(cache);
|
|
export const $resourceDiagnosticsCache = atom(diagnosticsCache);
|
|
export const $resourceDiagnosticsErrorCache = atom(diagnosticsErrorCache);
|
|
export const $resourcePerformanceCache = atom(performanceCache);
|
|
|
|
const updateMetadataCache = () => {
|
|
$resourceDiagnosticsCache.set(new Map(diagnosticsCache));
|
|
$resourceDiagnosticsErrorCache.set(new Map(diagnosticsErrorCache));
|
|
$resourcePerformanceCache.set(new Map(performanceCache));
|
|
};
|
|
|
|
const updateCache = () => {
|
|
$resourcesCache.set(new Map(cache));
|
|
updateMetadataCache();
|
|
};
|
|
|
|
const cacheResourceMetadata = ({
|
|
key,
|
|
request,
|
|
result,
|
|
loaderDurationMs,
|
|
}: {
|
|
key: string;
|
|
request: ResourceRequest;
|
|
result: unknown;
|
|
loaderDurationMs: number;
|
|
}) => {
|
|
const separated = separateResourceDiagnostics({ request, result });
|
|
performanceCache.set(key, {
|
|
...separated.performance,
|
|
loaderDurationMs: Math.max(0, loaderDurationMs),
|
|
});
|
|
if (separated.diagnostics === undefined) {
|
|
diagnosticsCache.delete(key);
|
|
} else {
|
|
diagnosticsCache.set(key, separated.diagnostics);
|
|
}
|
|
if (separated.diagnosticsError !== undefined) {
|
|
diagnosticsErrorCache.set(key, separated.diagnosticsError);
|
|
}
|
|
return separated;
|
|
};
|
|
|
|
export const $pendingResourceKeys = atom<ReadonlySet<string>>(new Set());
|
|
export const $resourcesState = atom<"pending" | "settled">("pending");
|
|
|
|
const updatePending = () => {
|
|
$pendingResourceKeys.set(new Set([...queue.keys(), ...pending.keys()]));
|
|
$resourcesState.set(
|
|
queue.size === 0 && pending.size === 0 ? "settled" : "pending"
|
|
);
|
|
};
|
|
|
|
export const $hasPendingResources = computed(
|
|
$pendingResourceKeys,
|
|
(pendingResourceKeys) => pendingResourceKeys.size > 0
|
|
);
|
|
|
|
const getInFlightResourceCount = () => {
|
|
let count = 0;
|
|
for (const batch of inFlightBatches) {
|
|
count += batch.versions.size;
|
|
}
|
|
return count;
|
|
};
|
|
|
|
const loadResources = async (requestFetch: typeof fetch = fetch) => {
|
|
const availableSlots = resourceLoadConcurrency - getInFlightResourceCount();
|
|
if (availableSlots <= 0) {
|
|
return;
|
|
}
|
|
const list = Array.from(queue.values()).slice(0, availableSlots);
|
|
if (list.length === 0) {
|
|
return;
|
|
}
|
|
const dispatched = new Map<string, ResourceRequest>();
|
|
const controller = new AbortController();
|
|
const batch = { controller, versions: new Map<string, number>() };
|
|
for (const resource of list) {
|
|
const key = getResourceKey(resource);
|
|
const version = resourceVersions.get(key) ?? 0;
|
|
queue.delete(key);
|
|
pending.set(key, batch);
|
|
dispatched.set(key, resource);
|
|
batch.versions.set(key, version);
|
|
}
|
|
inFlightBatches.add(batch);
|
|
updatePending();
|
|
|
|
try {
|
|
const startedAt = performance.now();
|
|
const response = await requestFetch(restResourcesLoader(), {
|
|
method: "POST",
|
|
body: JSON.stringify(list),
|
|
signal: controller.signal,
|
|
});
|
|
if (response.ok === false) {
|
|
return;
|
|
}
|
|
const results = new Map<string, unknown>(await response.json());
|
|
const loaderDurationMs = performance.now() - startedAt;
|
|
for (const [key, result] of results) {
|
|
const request = dispatched.get(key);
|
|
if (
|
|
request === undefined ||
|
|
pending.get(key) !== batch ||
|
|
knownRequests.has(key) === false ||
|
|
resourceVersions.get(key) !== batch.versions.get(key)
|
|
) {
|
|
continue;
|
|
}
|
|
const separated = cacheResourceMetadata({
|
|
key,
|
|
request,
|
|
result,
|
|
loaderDurationMs,
|
|
});
|
|
cache.set(key, separated.result);
|
|
}
|
|
} catch {
|
|
if (controller.signal.aborted === false) {
|
|
console.error("Resource batch request failed");
|
|
}
|
|
} finally {
|
|
inFlightBatches.delete(batch);
|
|
for (const key of dispatched.keys()) {
|
|
if (pending.get(key) === batch) {
|
|
pending.delete(key);
|
|
}
|
|
}
|
|
updateCache();
|
|
updatePending();
|
|
// Drain any resources that did not fit in this batch.
|
|
if (controller.signal.aborted === false) {
|
|
startLoading(requestFetch);
|
|
}
|
|
}
|
|
};
|
|
|
|
const startLoading = (requestFetch: typeof fetch = fetch) => {
|
|
if (
|
|
getInFlightResourceCount() >= resourceLoadConcurrency ||
|
|
queue.size === 0
|
|
) {
|
|
return;
|
|
}
|
|
void loadResources(requestFetch);
|
|
};
|
|
|
|
const abortObsoleteBatches = () => {
|
|
for (const batch of inFlightBatches) {
|
|
const isObsolete = Array.from(batch.versions).every(
|
|
([key, version]) =>
|
|
knownRequests.has(key) === false ||
|
|
resourceVersions.get(key) !== version
|
|
);
|
|
if (isObsolete) {
|
|
batch.controller.abort();
|
|
}
|
|
}
|
|
};
|
|
|
|
const preloadResource = (resource: ResourceRequest) => {
|
|
const key = getResourceKey(resource);
|
|
knownRequests.set(key, resource);
|
|
if (resourceVersions.has(key) === false) {
|
|
resourceVersions.set(key, 0);
|
|
}
|
|
if (queue.has(key) || pending.has(key) || cache.has(key)) {
|
|
return;
|
|
}
|
|
// deduplicate resources in queue
|
|
queue.set(key, resource);
|
|
updatePending();
|
|
};
|
|
|
|
const invalidateRequestState = (key: string) => {
|
|
diagnosticsCache.delete(key);
|
|
diagnosticsErrorCache.delete(key);
|
|
performanceCache.delete(key);
|
|
pendingDiagnostics.get(key)?.controller.abort();
|
|
pendingDiagnostics.delete(key);
|
|
resourceVersions.set(key, (resourceVersions.get(key) ?? 0) + 1);
|
|
};
|
|
|
|
const removeObsoleteRequests = () => {
|
|
let pendingChanged = false;
|
|
let diagnosticsChanged = false;
|
|
for (const key of knownRequests.keys()) {
|
|
if (pageRequestKeys.has(key) || previewRequests.has(key)) {
|
|
continue;
|
|
}
|
|
knownRequests.delete(key);
|
|
invalidateRequestState(key);
|
|
diagnosticsChanged = true;
|
|
pendingChanged = queue.delete(key) || pendingChanged;
|
|
pendingChanged = pending.delete(key) || pendingChanged;
|
|
}
|
|
abortObsoleteBatches();
|
|
if (diagnosticsChanged) {
|
|
updateMetadataCache();
|
|
}
|
|
if (pendingChanged) {
|
|
updatePending();
|
|
}
|
|
};
|
|
|
|
const queueResources = (resources: readonly ResourceRequest[]) => {
|
|
pageRequestKeys.clear();
|
|
for (const resource of resources) {
|
|
pageRequestKeys.add(getResourceKey(resource));
|
|
}
|
|
removeObsoleteRequests();
|
|
for (const resource of resources) {
|
|
preloadResource(resource);
|
|
}
|
|
// Marks an empty or fully cached request set as settled too.
|
|
updatePending();
|
|
};
|
|
|
|
export const preloadResources = (resources: readonly ResourceRequest[]) => {
|
|
queueResources(resources);
|
|
startLoading();
|
|
};
|
|
|
|
const invalidateAndQueueResource = (resource: ResourceRequest) => {
|
|
const key = getResourceKey(resource);
|
|
// Asset saves refresh collection bindings without unmounting their content.
|
|
// Keep the last result until the replacement arrives, including an empty result.
|
|
if (isAssetsResourceRequest(resource) === false) {
|
|
cache.delete(key);
|
|
}
|
|
invalidateRequestState(key);
|
|
pending.delete(key);
|
|
knownRequests.set(key, resource);
|
|
queue.set(key, resource);
|
|
};
|
|
|
|
const queueInvalidatedResource = (resource: ResourceRequest) => {
|
|
invalidateAndQueueResource(resource);
|
|
abortObsoleteBatches();
|
|
updateCache();
|
|
updatePending();
|
|
};
|
|
|
|
/** Keep an explicitly requested preview alive until its dialog releases it. */
|
|
export const loadResourcePreview = (
|
|
resource: ResourceRequest,
|
|
requestFetch: typeof fetch = fetch
|
|
) => {
|
|
const key = getResourceKey(resource);
|
|
const lease = Symbol();
|
|
const leases = previewRequests.get(key) ?? new Set<symbol>();
|
|
leases.add(lease);
|
|
previewRequests.set(key, leases);
|
|
queueInvalidatedResource(resource);
|
|
startLoading(requestFetch);
|
|
return () => {
|
|
const activeLeases = previewRequests.get(key);
|
|
if (activeLeases?.delete(lease) !== true) {
|
|
return;
|
|
}
|
|
if (activeLeases.size === 0) {
|
|
previewRequests.delete(key);
|
|
removeObsoleteRequests();
|
|
}
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Fetches the diagnostics intentionally omitted from ordinary canvas loads.
|
|
* Called lazily when the Diagnostics tab is opened.
|
|
*/
|
|
export const loadResourceDiagnostics = (
|
|
resource: ResourceRequest,
|
|
requestFetch: typeof fetch = fetch
|
|
) => {
|
|
const key = getResourceKey(resource);
|
|
const existing = pendingDiagnostics.get(key);
|
|
if (existing !== undefined) {
|
|
return existing.promise;
|
|
}
|
|
const version = resourceVersions.get(key) ?? 0;
|
|
const controller = new AbortController();
|
|
const promise = Promise.resolve().then(async () => {
|
|
try {
|
|
if (controller.signal.aborted) {
|
|
return;
|
|
}
|
|
const startedAt = performance.now();
|
|
const response = await requestFetch(
|
|
restResourcesLoader({ diagnostics: true }),
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify([resource]),
|
|
signal: controller.signal,
|
|
}
|
|
);
|
|
if (response.ok === false) {
|
|
let data: unknown;
|
|
try {
|
|
data = await response.json();
|
|
} catch {
|
|
data = undefined;
|
|
}
|
|
if (
|
|
controller.signal.aborted === false &&
|
|
pendingDiagnostics.get(key)?.controller === controller &&
|
|
(resourceVersions.get(key) ?? 0) === version
|
|
) {
|
|
diagnosticsErrorCache.set(key, {
|
|
ok: false,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
data,
|
|
});
|
|
updateMetadataCache();
|
|
}
|
|
return;
|
|
}
|
|
const result = new Map<string, unknown>(await response.json()).get(key);
|
|
const loaderDurationMs = performance.now() - startedAt;
|
|
if (
|
|
controller.signal.aborted ||
|
|
pendingDiagnostics.get(key)?.controller !== controller ||
|
|
(resourceVersions.get(key) ?? 0) !== version
|
|
) {
|
|
return;
|
|
}
|
|
const separated = cacheResourceMetadata({
|
|
key,
|
|
request: resource,
|
|
result,
|
|
loaderDurationMs,
|
|
});
|
|
if (result === undefined) {
|
|
diagnosticsErrorCache.set(key, {
|
|
ok: false,
|
|
data: {
|
|
error: {
|
|
message: "Resource diagnostics response is missing",
|
|
},
|
|
},
|
|
});
|
|
} else if (
|
|
typeof result === "object" &&
|
|
result !== null &&
|
|
((result as { ok?: unknown }).ok === false ||
|
|
(typeof (result as { status?: unknown }).status === "number" &&
|
|
((result as { status: number }).status ?? 0) >= 400))
|
|
) {
|
|
diagnosticsErrorCache.set(key, result);
|
|
} else if (separated.diagnosticsError !== undefined) {
|
|
diagnosticsErrorCache.set(key, separated.diagnosticsError);
|
|
} else if (separated.diagnostics === undefined) {
|
|
diagnosticsErrorCache.set(
|
|
key,
|
|
createResourceDiagnosticsResponseError({
|
|
message: "Resource diagnostics response is missing",
|
|
issues: [
|
|
{
|
|
code: "missing_diagnostics",
|
|
path: ["__diagnostics__"],
|
|
message:
|
|
"The resource response does not contain diagnostics data",
|
|
},
|
|
],
|
|
})
|
|
);
|
|
} else {
|
|
diagnosticsErrorCache.delete(key);
|
|
}
|
|
updateMetadataCache();
|
|
} catch (error) {
|
|
if (controller.signal.aborted === false) {
|
|
console.error("Resource diagnostics request failed");
|
|
if (
|
|
pendingDiagnostics.get(key)?.controller === controller &&
|
|
(resourceVersions.get(key) ?? 0) === version
|
|
) {
|
|
diagnosticsErrorCache.set(key, {
|
|
ok: false,
|
|
data: {
|
|
error: {
|
|
message:
|
|
error instanceof Error
|
|
? error.message
|
|
: "Resource diagnostics request failed",
|
|
},
|
|
},
|
|
});
|
|
updateMetadataCache();
|
|
}
|
|
}
|
|
} finally {
|
|
if (pendingDiagnostics.get(key)?.controller === controller) {
|
|
pendingDiagnostics.delete(key);
|
|
}
|
|
}
|
|
});
|
|
pendingDiagnostics.set(key, { controller, promise });
|
|
return promise;
|
|
};
|
|
|
|
/**
|
|
* Invalidate the assets system resource.
|
|
* Call this when assets are uploaded, deleted, or modified to refresh expressions using assets.
|
|
*/
|
|
export const invalidateAssets = (requestFetch: typeof fetch = fetch) => {
|
|
for (const request of knownRequests.values()) {
|
|
if (isAssetsResourceRequest(request) === false) {
|
|
continue;
|
|
}
|
|
invalidateAndQueueResource(request);
|
|
}
|
|
abortObsoleteBatches();
|
|
updateCache();
|
|
updatePending();
|
|
startLoading(requestFetch);
|
|
};
|
|
|
|
export const computeResourceRequest = async (
|
|
resource: Resource,
|
|
values: ReadonlyMap<DataSource["id"], unknown>,
|
|
resolveDataSource?: ResolveExpressionDataSource
|
|
): Promise<ResourceRequest> => {
|
|
const resolvedDataSources = new Map<DataSource["id"], unknown>();
|
|
const resolve = (dataSourceId: DataSource["id"], value: unknown) => {
|
|
if (values.has(dataSourceId)) {
|
|
return values.get(dataSourceId);
|
|
}
|
|
if (resolvedDataSources.has(dataSourceId)) {
|
|
return resolvedDataSources.get(dataSourceId);
|
|
}
|
|
const resolved =
|
|
resolveDataSource === undefined
|
|
? value
|
|
: resolveDataSource(dataSourceId, value);
|
|
resolvedDataSources.set(dataSourceId, resolved);
|
|
return resolved;
|
|
};
|
|
const [url, searchParams, headers, body] = await Promise.all([
|
|
computeExpression(resource.url, values, resolve),
|
|
Promise.all(
|
|
(resource.searchParams ?? []).map(async ({ name, value }) => ({
|
|
name,
|
|
value: await computeExpression(value, values, resolve),
|
|
}))
|
|
),
|
|
Promise.all(
|
|
resource.headers.map(async ({ name, value }) => ({
|
|
name,
|
|
value: await computeExpression(value, values, resolve),
|
|
}))
|
|
),
|
|
resource.body === undefined
|
|
? undefined
|
|
: computeExpression(resource.body, values, resolve),
|
|
]);
|
|
const request: ResourceRequest = {
|
|
name: resource.name,
|
|
method: resource.method,
|
|
url,
|
|
searchParams,
|
|
headers,
|
|
};
|
|
if (resource.body !== undefined) {
|
|
request.body = body;
|
|
}
|
|
return request;
|
|
};
|
|
|
|
const getDataSourcesByResourceId = (dataSources: DataSources) => {
|
|
const dataSourcesByResourceId = new Map<Resource["id"], DataSource[]>();
|
|
for (const dataSource of dataSources.values()) {
|
|
if (dataSource.type !== "resource") {
|
|
continue;
|
|
}
|
|
const entries = dataSourcesByResourceId.get(dataSource.resourceId) ?? [];
|
|
entries.push(dataSource);
|
|
dataSourcesByResourceId.set(dataSource.resourceId, entries);
|
|
}
|
|
return dataSourcesByResourceId;
|
|
};
|
|
|
|
export type ResourceRequestPlan = Readonly<{
|
|
requests: readonly ResourceRequest[];
|
|
documents: ReadonlyMap<Resource["id"], unknown>;
|
|
}>;
|
|
|
|
export const computeResourceRequestPlan = async ({
|
|
rootResourceIds,
|
|
resources,
|
|
dataSources,
|
|
values,
|
|
resourceCache,
|
|
}: {
|
|
rootResourceIds: Iterable<Resource["id"]>;
|
|
resources: Resources;
|
|
dataSources: DataSources;
|
|
values: ReadonlyMap<DataSource["id"], unknown>;
|
|
resourceCache: ReadonlyMap<string, unknown>;
|
|
}): Promise<ResourceRequestPlan> => {
|
|
const resolvedValues = new Map(values);
|
|
const documents = new Map<Resource["id"], unknown>();
|
|
const requests = new Map<Resource["id"], ResourceRequest>();
|
|
const pending = new Map<Resource["id"], Promise<boolean>>();
|
|
const dataSourcesByResourceId = getDataSourcesByResourceId(dataSources);
|
|
|
|
const visit = (
|
|
resourceId: Resource["id"],
|
|
ancestors: ReadonlySet<Resource["id"]> = new Set()
|
|
): Promise<boolean> => {
|
|
if (ancestors.has(resourceId)) {
|
|
return Promise.resolve(false);
|
|
}
|
|
const previous = pending.get(resourceId);
|
|
if (previous !== undefined) {
|
|
return previous;
|
|
}
|
|
const nextAncestors = new Set(ancestors).add(resourceId);
|
|
// Walk dependencies before publishing the pending promise so back edges
|
|
// encounter their ancestor instead of awaiting a promise in the same cycle.
|
|
const resolution = (async () => {
|
|
const resource = resources.get(resourceId);
|
|
if (resource === undefined) {
|
|
return false;
|
|
}
|
|
const dependencyIds = getResourceDependencyIds({
|
|
resource,
|
|
dataSources,
|
|
});
|
|
const dependenciesResolved = await Promise.all(
|
|
[...dependencyIds].map((dependencyId) =>
|
|
visit(dependencyId, nextAncestors)
|
|
)
|
|
);
|
|
if (dependenciesResolved.some((resolved) => resolved === false)) {
|
|
return false;
|
|
}
|
|
const request = await computeResourceRequest(resource, resolvedValues);
|
|
requests.set(resourceId, request);
|
|
const key = getResourceKey(request);
|
|
if (resourceCache.has(key) === false) {
|
|
return false;
|
|
}
|
|
const document = resourceCache.get(key);
|
|
documents.set(resourceId, document);
|
|
for (const dataSource of dataSourcesByResourceId.get(resourceId) ?? []) {
|
|
resolvedValues.set(dataSource.id, document);
|
|
}
|
|
return true;
|
|
})();
|
|
pending.set(resourceId, resolution);
|
|
return resolution;
|
|
};
|
|
|
|
await Promise.all(
|
|
[...new Set(rootResourceIds)].map((resourceId) => visit(resourceId))
|
|
);
|
|
return {
|
|
requests: Array.from(requests.values()),
|
|
documents,
|
|
};
|
|
};
|
|
|
|
const reset = () => {
|
|
for (const batch of inFlightBatches) {
|
|
batch.controller.abort();
|
|
}
|
|
inFlightBatches.clear();
|
|
queue.clear();
|
|
pending.clear();
|
|
cache.clear();
|
|
diagnosticsCache.clear();
|
|
diagnosticsErrorCache.clear();
|
|
performanceCache.clear();
|
|
for (const { controller } of pendingDiagnostics.values()) {
|
|
controller.abort();
|
|
}
|
|
pendingDiagnostics.clear();
|
|
knownRequests.clear();
|
|
pageRequestKeys.clear();
|
|
previewRequests.clear();
|
|
resourceVersions.clear();
|
|
updateCache();
|
|
updatePending();
|
|
};
|
|
|
|
const getLoaderState = () => ({
|
|
queueSize: queue.size,
|
|
pendingSize: pending.size,
|
|
});
|
|
|
|
export const __testing__ = {
|
|
getLoaderState,
|
|
loadResources,
|
|
queueInvalidatedResource,
|
|
queueResources,
|
|
reset,
|
|
startLoading,
|
|
};
|