Defer media loading and strengthen persistence tests
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s
This commit is contained in:
parent
09a2d4b71a
commit
d0e1fd73b1
19 changed files with 459 additions and 71 deletions
|
|
@ -194,6 +194,9 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
|
|||
}
|
||||
const moduleContext = {
|
||||
getModuleData,
|
||||
ensureModuleData: actions.ensureModuleData,
|
||||
isModuleDataReady: actions.isModuleDataReady,
|
||||
storageStatus: siteContent.toolboxes.storageStatus,
|
||||
storageReady,
|
||||
moduleText,
|
||||
normalizeChecklistData,
|
||||
|
|
|
|||
28
website/src/features/toolboxes/modules/ModuleDataGate.jsx
Normal file
28
website/src/features/toolboxes/modules/ModuleDataGate.jsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// Rôle : attend les médias persistés avant de monter un outil éditable et propose une reprise locale en cas d'échec.
|
||||
import { useEffect, useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
|
||||
export function ModuleDataGate({ context, toolboxId, moduleId, children }) {
|
||||
const ready = context.isModuleDataReady?.(toolboxId, moduleId) ?? true;
|
||||
const load = context.ensureModuleData;
|
||||
const [error, setError] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
useEffect(() => {
|
||||
if (ready || !load) return;
|
||||
let cancelled = false;
|
||||
void load(toolboxId, moduleId).catch((error) => {
|
||||
if (!cancelled) setError(error?.message || context.storageStatus.errorTitle);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [ready, load, toolboxId, moduleId, attempt, context.storageStatus?.errorTitle]);
|
||||
if (ready) return children;
|
||||
return (
|
||||
<div className="empty nds-empty-state" aria-busy={!error}>
|
||||
<p role={error ? "alert" : "status"}>{error || context.storageStatus.mediaLoadingMessage}</p>
|
||||
{error && <button className="nds-button" data-size="sm" type="button" onClick={() => {
|
||||
setError("");
|
||||
setAttempt((value) => value + 1);
|
||||
}}><Icon name="refresh" />{context.storageStatus.retryLabel}</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
|||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||
import { supportsTextExchange } from "./textImport.js";
|
||||
import { MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
|
||||
import { ModuleDataGate } from "./ModuleDataGate.jsx";
|
||||
|
||||
const MODULE_COLUMN_IDS = ["left", "right"];
|
||||
|
||||
|
|
@ -264,7 +265,9 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
|
|||
</header>
|
||||
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
|
||||
<Suspense fallback={<ModuleLoadingFallback label={label} />}>
|
||||
<Component {...componentProps} />
|
||||
<ModuleDataGate key={`${toolbox.id}:${module.id}`} context={context} toolboxId={toolbox.id} moduleId={module.id}>
|
||||
<Component {...componentProps} />
|
||||
</ModuleDataGate>
|
||||
</Suspense>
|
||||
</div>
|
||||
</article>
|
||||
|
|
|
|||
29
website/src/features/toolboxes/storage/deferredModuleData.js
Normal file
29
website/src/features/toolboxes/storage/deferredModuleData.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Rôle : distingue les médias non lus des outils vides et fusionne les lectures sans ressusciter les suppressions.
|
||||
export const DEFERRED_MODULE_DATA = Symbol("deferred module data");
|
||||
|
||||
export function getDeferredModuleKeys(toolboxes) {
|
||||
return toolboxes.flatMap((toolbox) => (toolbox.modules || [])
|
||||
.filter((module) => module.type === "images" || module.type === "imageAnnotation")
|
||||
.map((module) => `${toolbox.id}:${module.id}`));
|
||||
}
|
||||
|
||||
export function splitModuleData(moduleData) {
|
||||
const stored = {};
|
||||
const deferred = [];
|
||||
for (const [key, value] of Object.entries(moduleData)) {
|
||||
if (value === DEFERRED_MODULE_DATA) deferred.push(key);
|
||||
else stored[key] = value;
|
||||
}
|
||||
return { stored, deferred };
|
||||
}
|
||||
|
||||
export function mergeLoadedModuleData(moduleData, keys, entries) {
|
||||
const next = { ...moduleData };
|
||||
const loaded = new Map(entries.map((entry) => [entry.key, entry.data]));
|
||||
for (const key of keys) {
|
||||
if (next[key] !== DEFERRED_MODULE_DATA) continue;
|
||||
if (loaded.has(key)) next[key] = loaded.get(key);
|
||||
else delete next[key];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
getAllModuleData as dbGetAllModuleData,
|
||||
getModuleDataEntries as dbGetModuleDataEntries,
|
||||
getLinks as dbGetLinks,
|
||||
getSetting as dbGetSetting,
|
||||
getStorageEstimate,
|
||||
|
|
@ -23,6 +24,7 @@ import {
|
|||
import { applyModuleDataValue, removeModuleDataKeys } from "./optimisticModuleData.js";
|
||||
import { createPersistenceQueue } from "./persistenceQueue.js";
|
||||
import { createQuotaRefresh } from "./quotaRefresh.js";
|
||||
import { DEFERRED_MODULE_DATA, getDeferredModuleKeys, mergeLoadedModuleData, splitModuleData } from "./deferredModuleData.js";
|
||||
|
||||
function normalizeToolboxes(toolboxes) {
|
||||
return compactToolboxesForStorage(toolboxes).map(normalizeToolbox).filter(Boolean);
|
||||
|
|
@ -33,6 +35,7 @@ export function useIndexedToolboxes(onError) {
|
|||
const [loadError, setLoadError] = useState("");
|
||||
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||
const writableRef = useRef(false);
|
||||
const pendingReadsRef = useRef(new Map());
|
||||
const [state, setState] = useState({ toolboxes: [], links: {}, moduleData: {}, toastPosition: "right" });
|
||||
const [queue] = useState(() => createPersistenceQueue(state, setState));
|
||||
const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 });
|
||||
|
|
@ -64,14 +67,20 @@ export function useIndexedToolboxes(onError) {
|
|||
let cancelled = false;
|
||||
async function loadStore() {
|
||||
try {
|
||||
const [toolboxes, links, modules, toastPosition] = await Promise.all([
|
||||
dbGetToolboxes(), dbGetLinks(), dbGetAllModuleData(), dbGetSetting("toastPosition", "right")
|
||||
const [toolboxes, links, toastPosition] = await Promise.all([
|
||||
dbGetToolboxes(), dbGetLinks(), dbGetSetting("toastPosition", "right")
|
||||
]);
|
||||
const normalizedToolboxes = (Array.isArray(toolboxes) ? toolboxes : []).map(normalizeToolbox).filter(Boolean);
|
||||
const deferredKeys = getDeferredModuleKeys(normalizedToolboxes);
|
||||
const modules = await dbGetAllModuleData(deferredKeys);
|
||||
if (cancelled) return;
|
||||
queue.initialize({
|
||||
toolboxes: (Array.isArray(toolboxes) ? toolboxes : []).map(normalizeToolbox).filter(Boolean),
|
||||
toolboxes: normalizedToolboxes,
|
||||
links: links && typeof links === "object" ? links : {},
|
||||
moduleData: Object.fromEntries((modules || []).map((entry) => [entry.key, entry.data])),
|
||||
moduleData: Object.fromEntries([
|
||||
...deferredKeys.map((key) => [key, DEFERRED_MODULE_DATA]),
|
||||
...(modules || []).map((entry) => [entry.key, entry.data])
|
||||
]),
|
||||
toastPosition: toastPosition === "left" ? "left" : "right"
|
||||
});
|
||||
writableRef.current = true;
|
||||
|
|
@ -125,6 +134,7 @@ export function useIndexedToolboxes(onError) {
|
|||
|
||||
function updateModuleData(toolboxId, moduleId, value, moduleType = "") {
|
||||
if (!writableRef.current) return false;
|
||||
if (queue.getCurrent().moduleData[moduleStorageKey(toolboxId, moduleId)] === DEFERRED_MODULE_DATA) return false;
|
||||
const compact = prepareModuleDataForStorage(queue.getCurrent().toolboxes, toolboxId, moduleId, value, moduleType);
|
||||
const key = moduleStorageKey(toolboxId, moduleId);
|
||||
const hasModule = (current) => current.toolboxes.some((toolbox) => toolbox.id === toolboxId && toolbox.modules.some((module) => module.id === moduleId));
|
||||
|
|
@ -160,7 +170,38 @@ export function useIndexedToolboxes(onError) {
|
|||
return enqueue((current) => {
|
||||
const next = updater(current);
|
||||
return { ...current, ...next, toolboxes: normalizeToolboxes(next.toolboxes) };
|
||||
}, (next) => dbSetToolboxSnapshot(compactToolboxesForStorage(next.toolboxes), next.links, next.moduleData));
|
||||
}, (next) => {
|
||||
const { stored, deferred } = splitModuleData(next.moduleData);
|
||||
return dbSetToolboxSnapshot(compactToolboxesForStorage(next.toolboxes), next.links, stored, deferred);
|
||||
});
|
||||
}
|
||||
|
||||
const ensureModuleData = useCallback((toolboxId, moduleId) => {
|
||||
const key = moduleStorageKey(toolboxId, moduleId);
|
||||
if (queue.getCurrent().moduleData[key] !== DEFERRED_MODULE_DATA) return Promise.resolve();
|
||||
if (pendingReadsRef.current.has(key)) return pendingReadsRef.current.get(key);
|
||||
const request = (async () => {
|
||||
const entries = await dbGetModuleDataEntries([key]);
|
||||
if (!writableRef.current) return;
|
||||
await queue.enqueue((current) => ({
|
||||
...current, moduleData: mergeLoadedModuleData(current.moduleData, [key], entries)
|
||||
}), () => {});
|
||||
})();
|
||||
pendingReadsRef.current.set(key, request);
|
||||
void request.then(() => pendingReadsRef.current.delete(key), () => pendingReadsRef.current.delete(key));
|
||||
return request;
|
||||
}, [queue]);
|
||||
|
||||
async function getExportSnapshot(toolboxId) {
|
||||
if (!writableRef.current) return null;
|
||||
let snapshot;
|
||||
const success = await queue.enqueue((current) => current, async (current) => {
|
||||
const { deferred } = splitModuleData(current.moduleData);
|
||||
const keys = deferred.filter((key) => !toolboxId || key.startsWith(`${toolboxId}:`));
|
||||
const entries = await dbGetModuleDataEntries(keys);
|
||||
snapshot = { ...current, moduleData: mergeLoadedModuleData(current.moduleData, keys, entries) };
|
||||
}, reportStorageError);
|
||||
return success ? snapshot : null;
|
||||
}
|
||||
|
||||
function setToastPosition(position) {
|
||||
|
|
@ -181,6 +222,9 @@ export function useIndexedToolboxes(onError) {
|
|||
setToolboxes: persistToolboxes,
|
||||
setLinks: persistLinks,
|
||||
getModuleData,
|
||||
ensureModuleData,
|
||||
isModuleDataReady: (toolboxId, moduleId) => state.moduleData[moduleStorageKey(toolboxId, moduleId)] !== DEFERRED_MODULE_DATA,
|
||||
getExportSnapshot,
|
||||
updateModuleData,
|
||||
removeModuleData,
|
||||
removeToolboxModuleData,
|
||||
|
|
|
|||
|
|
@ -234,6 +234,8 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
|||
setImage,
|
||||
createChecklistFromList,
|
||||
addImageFiles,
|
||||
ensureModuleData: store.ensureModuleData,
|
||||
isModuleDataReady: store.isModuleDataReady,
|
||||
notify,
|
||||
removeModuleData: store.removeModuleData,
|
||||
updateModuleData: store.updateModuleData,
|
||||
|
|
@ -275,16 +277,20 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
|||
return false;
|
||||
}
|
||||
},
|
||||
exportToolbox: (id) => {
|
||||
exportToolbox: async (id) => {
|
||||
if (!store.ready) return false;
|
||||
const toolbox = normalizeToolbox(store.toolboxes.find((item) => item.id === id));
|
||||
const snapshot = await store.getExportSnapshot(id);
|
||||
if (!snapshot) return false;
|
||||
const toolbox = normalizeToolbox(snapshot.toolboxes.find((item) => item.id === id));
|
||||
if (!toolbox) return;
|
||||
downloadJson(createToolboxExportPayload(toolbox, store.moduleData), `${toolbox.name.toLowerCase().replace(/[^a-z0-9]+/gi, "-")}.sokkog.json`);
|
||||
downloadJson(createToolboxExportPayload(toolbox, snapshot.moduleData), `${toolbox.name.toLowerCase().replace(/[^a-z0-9]+/gi, "-")}.sokkog.json`);
|
||||
notify("Export de la toolbox lancé.");
|
||||
},
|
||||
exportAllToolboxes: () => {
|
||||
exportAllToolboxes: async () => {
|
||||
if (!store.ready) return false;
|
||||
downloadJson(createGlobalExportPayload(store.toolboxes, store.links, store.moduleData), `sokkog-global-${new Date().toISOString().slice(0, 10)}.json`);
|
||||
const snapshot = await store.getExportSnapshot();
|
||||
if (!snapshot) return false;
|
||||
downloadJson(createGlobalExportPayload(snapshot.toolboxes, snapshot.links, snapshot.moduleData), `sokkog-global-${new Date().toISOString().slice(0, 10)}.json`);
|
||||
notify("Export global lancé.");
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue