Defer media loading and strengthen persistence tests
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-12 23:28:05 +02:00
parent 09a2d4b71a
commit d0e1fd73b1
19 changed files with 459 additions and 71 deletions

View file

@ -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,

View 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>
);
}

View file

@ -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>

View 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;
}

View file

@ -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,

View file

@ -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é.");
}
};