From 99d916f6db9e2e6da578460d772cb87289ff0b64 Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Fri, 11 Sep 2026 21:09:29 +0200 Subject: [PATCH] Make app data loading safe under StrictMode --- .beads/interactions.jsonl | 1 + docs/FEATURE_CHECKLIST.md | 1 + e2e/app-data.spec.js | 119 ++++++++++++++++++++++++++ e2e/fixtures/app-data-probe.js | 44 ++++++++++ website/src/features/games/loaders.js | 20 ++--- website/src/hooks/useAppData.js | 54 +++++++----- 6 files changed, 207 insertions(+), 32 deletions(-) create mode 100644 e2e/app-data.spec.js create mode 100644 e2e/fixtures/app-data-probe.js diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 4ffa0f2..676b0c2 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -32,3 +32,4 @@ {"id":"int-f9e84a155b896c7f68c83ee2454f904d","kind":"field_change","created_at":"2026-09-11T11:56:43.680278965Z","actor":"Shinuwa","issue_id":"sokko-g-sc7","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Reproduced memo draft loss on notification, isolated per-memo drafts using useInlineEdit, preserved multiline/trim/delete behavior. Goal Tree reconciles selection and valid tabs before DOM commit with StrictMode regressions. npm run check:full passed: 100 Node tests and 21 E2E."}} {"id":"int-4b64d68baf132fa99cb3244d8487034c","kind":"field_change","created_at":"2026-09-11T12:05:04.580370366Z","actor":"Shinuwa","issue_id":"sokko-g-kqo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Shared keyed state initializes Notepad/ImageAnnotation temporary drawings and RNG history before DOM commit; ignores setters bound to a different active key. Storage keys and write/error paths preserved. npm run check:full passed: 100 Node tests and 23 E2E including StrictMode key switches and real Notepad canvas/page/reload checks."}} {"id":"int-80659dd55d71f2a33d1067b008bf2abd","kind":"field_change","created_at":"2026-09-11T17:30:09.794064421Z","actor":"Shinuwa","issue_id":"sokko-g-275","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed RNG draft/edit transitions and ImageViewer pre-commit resets; cover decoding now guards stale completion/errors and releases resources. Four new StrictMode E2E cases pass; npm run check:full passes with 27 E2E. No storage or style changes."}} +{"id":"int-6ad8b329d6c9580520514a4234fc5230","kind":"field_change","created_at":"2026-09-11T18:38:38.171744457Z","actor":"Shinuwa","issue_id":"sokko-g-rcs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed lazy game loading without synchronous effect state updates; AbortController reaches editorial, game and nested list requests. Stale outcomes ignored, navigation cache preserved. Three new StrictMode behavior tests; npm run check:full passes with 30 E2E; targeted set-state-in-effect lint passes."}} diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md index 43d4659..5510f99 100644 --- a/docs/FEATURE_CHECKLIST.md +++ b/docs/FEATURE_CHECKLIST.md @@ -36,6 +36,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page - Déclarer le jeu dans `website/public/data/site.json`. - Vérifier la page `/games`. - Vérifier la page `/games/`. +- Vérifier le chargement à la première visite, la conservation des données pendant la navigation et les erreurs réseau ; sous StrictMode, les requêtes abandonnées ne doivent pas remplacer les données actuelles ni afficher d'erreur d'annulation. - Vérifier le bouton toolbox et le panneau latéral. - Vérifier le breadcrumb. - Vérifier les textes avec retours à la ligne. diff --git a/e2e/app-data.spec.js b/e2e/app-data.spec.js new file mode 100644 index 0000000..9e893d9 --- /dev/null +++ b/e2e/app-data.spec.js @@ -0,0 +1,119 @@ +// Rôle : couvre chargement différé, cache de navigation, erreurs et annulations des données publiques. +/* global window, document */ +import { test, expect } from "@playwright/test"; +import { fileURLToPath, URL } from "node:url"; + +const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/app-data-probe.js", import.meta.url))}`; +const siteUrl = "/data/site.json"; +const gamesUrl = "/data/games.json"; +const diabloUrl = "/data/diablo4/affixes_types.json"; +const mhwBase = "/data/mhwilds/"; + +async function setup(page, route = "/") { + await page.route("**/__app-data-probe", (request) => request.fulfill({ + contentType: "text/html", body: '
' + })); + await page.goto("/__app-data-probe"); + await page.evaluate(async ({ url, route }) => { + const { mountAppDataProbe } = await import(url); + window.dataProbe = mountAppDataProbe(document.getElementById("probe"), route); + }, { url: probeUrl, route }); +} + +async function state(page) { + return JSON.parse(await page.locator("#data-state").textContent()); +} + +async function respond(page, url, payload, status = 200, aborted = false) { + await page.evaluate(({ url, payload, status, aborted }) => { + window.dataProbe.respond(url, payload, status, aborted); + }, { url, payload, status, aborted }); +} + +test("game data loads only on demand and remains cached through navigation", async ({ page }) => { + await setup(page); + expect((await state(page)).mhwilds.loading).toBe(false); + expect(await page.evaluate(() => window.dataProbe.requests().map((request) => request.url))) + .toEqual([siteUrl, gamesUrl, siteUrl, gamesUrl]); + await respond(page, siteUrl, { title: "Current" }); + await respond(page, gamesUrl, { games: [{ id: "mhwilds" }] }); + await expect.poll(async () => (await state(page)).siteContent?.title).toBe("Current"); + await respond(page, siteUrl, { title: "Obsolete" }, 200, true); + await respond(page, gamesUrl, {}, 503, true); + + const snapshots = await page.evaluate(() => { + window.dataProbe.navigate("/games/mhwilds"); + return window.dataProbe.snapshots; + }); + expect(snapshots.length).toBeGreaterThan(0); + for (const snapshot of snapshots) { + expect(snapshot.mhwilds.loading).toBe(true); + expect(snapshot.mhwilds.loaded).toBe(false); + expect(snapshot.siteContent.title).toBe("Current"); + expect(snapshot.gamesError).toBe(""); + } + await respond(page, `${mhwBase}monsters.json`, { monsters: [{ id: "rey-dau" }] }); + await respond(page, `${mhwBase}endemic_life.json`, {}); + await respond(page, `${mhwBase}lists/index.json`, { lists: [{ id: "farm", file: "farm.json" }] }); + await respond(page, `${mhwBase}i18n/fr.json`, { hello: "Bonjour" }); + await expect.poll(() => page.evaluate(() => window.dataProbe.requests().some((request) => request.url.endsWith("/farm.json")))).toBe(true); + await page.evaluate(() => { + window.dataProbe.navigate("/games/diablo4"); + window.dataProbe.navigate("/"); + }); + await respond(page, `${mhwBase}lists/farm.json`, { titre: "Farm", items: [{ nom: "Potion", quantite: 2 }] }); + await respond(page, diabloUrl, { affixes: [{ id: "damage" }], categories: [{ id: "attack" }] }); + await expect.poll(async () => (await state(page)).mhwilds.loaded).toBe(true); + await expect.poll(async () => (await state(page)).diablo4.loaded).toBe(true); + const loaded = await state(page); + expect(loaded.mhwilds.lists[0].categories[0].items[0].quantity).toBe(2); + expect(loaded.diablo4.categoryMap.attack).toEqual({ id: "attack" }); + const count = await page.evaluate(() => window.dataProbe.requests().length); + await page.evaluate(() => { + window.dataProbe.navigate("/games/mhwilds/monsters"); + window.dataProbe.navigate("/games/diablo4"); + }); + expect(await page.evaluate(() => window.dataProbe.requests().length)).toBe(count); + expect((await state(page)).mhwilds.loading).toBe(false); + await page.evaluate(() => window.dataProbe.unmount()); + expect(await page.evaluate(() => window.dataProbe.requests().every((request) => request.aborted))).toBe(true); +}); + +test("StrictMode discards obsolete game failures and preserves current editorial errors", async ({ page }) => { + await setup(page, "/games/diablo4"); + const requests = await page.evaluate(() => window.dataProbe.requests().filter((request) => request.url.includes("diablo4"))); + expect(requests.map((request) => request.aborted)).toEqual([true, false]); + expect((await state(page)).diablo4.loading).toBe(true); + await respond(page, diabloUrl, { affixes: [{ id: "current" }] }); + await expect.poll(async () => (await state(page)).diablo4.loaded).toBe(true); + await respond(page, diabloUrl, {}, 503, true); + await respond(page, siteUrl, {}, 503); + await respond(page, gamesUrl, { games: [] }); + await expect.poll(async () => (await state(page)).siteContentError).toBe("Impossible de charger le contenu du site."); + expect((await state(page)).diablo4.error).toBe(""); + expect((await state(page)).diablo4.affixes).toEqual([{ id: "current" }]); + await page.evaluate(() => window.dataProbe.unmount()); +}); + +test("failed game loading stops and pending list requests are aborted on unmount", async ({ page }) => { + await setup(page, "/games/diablo4"); + await respond(page, diabloUrl, {}, 503); + await expect.poll(async () => (await state(page)).diablo4.error).toBe("Impossible de charger les données Diablo IV."); + expect((await state(page)).diablo4).toMatchObject({ loaded: true, loading: false }); + await page.evaluate(() => { + window.dataProbe.navigate("/"); + window.dataProbe.navigate("/games/diablo4"); + window.dataProbe.navigate("/games/mhwilds"); + }); + expect(await page.evaluate(() => window.dataProbe.requests().filter((request) => request.url.includes("diablo4")).length)).toBe(2); + await respond(page, `${mhwBase}monsters.json`, {}); + await respond(page, `${mhwBase}endemic_life.json`, {}); + await respond(page, `${mhwBase}lists/index.json`, { lists: [{ file: "pending.json" }] }); + await respond(page, `${mhwBase}i18n/fr.json`, {}); + await expect.poll(() => page.evaluate(() => window.dataProbe.requests().some((request) => request.url.endsWith("/pending.json")))).toBe(true); + await page.evaluate(() => window.dataProbe.unmount()); + expect(await page.evaluate(() => window.dataProbe.requests().every((request) => request.aborted))).toBe(true); + await respond(page, `${mhwBase}lists/pending.json`, {}, 503, true); + await respond(page, siteUrl, {}, 503, true); + await respond(page, gamesUrl, {}, 503, true); +}); diff --git a/e2e/fixtures/app-data-probe.js b/e2e/fixtures/app-data-probe.js new file mode 100644 index 0000000..a1a15d1 --- /dev/null +++ b/e2e/fixtures/app-data-probe.js @@ -0,0 +1,44 @@ +// Rôle : observe le chargement réel de l'application avec des réponses réseau contrôlées sous StrictMode. +import { createElement, StrictMode, useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { useAppData } from "../../website/src/hooks/useAppData.js"; + +export function mountAppDataProbe(element, initialRoute) { + const originalFetch = globalThis.fetch; + const requests = []; + const snapshots = []; + const root = createRoot(element); + globalThis.fetch = (url, { signal } = {}) => new Promise((resolve) => { + // Le double ignore volontairement abort pour tester aussi les réponses déjà en cours. + requests.push({ url, signal, resolve, settled: false }); + }); + + function Probe({ route }) { + const data = useAppData(route); + useLayoutEffect(() => { snapshots.push(data); }); + return createElement("pre", { id: "data-state" }, JSON.stringify(data)); + } + + function navigate(route) { + snapshots.length = 0; + flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe, { route })))); + } + navigate(initialRoute); + return { + snapshots, + requests: () => requests.map(({ url, signal, settled }) => ({ url, aborted: signal?.aborted, settled })), + respond(url, payload, status = 200, aborted = false) { + for (const request of requests) { + if (request.url !== url || request.settled || request.signal?.aborted !== aborted) continue; + request.settled = true; + request.resolve({ ok: status >= 200 && status < 300, json: async () => payload }); + } + }, + navigate, + unmount() { + flushSync(() => root.unmount()); + globalThis.fetch = originalFetch; + } + }; +} diff --git a/website/src/features/games/loaders.js b/website/src/features/games/loaders.js index 2e5faf9..534f55d 100644 --- a/website/src/features/games/loaders.js +++ b/website/src/features/games/loaders.js @@ -21,12 +21,12 @@ export const INITIAL_DIABLO4_STATE = { filterOptionKeys: { affixes: "" } }; -export async function loadMhwildsData() { +export async function loadMhwildsData(signal) { const [monstersResponse, endemicResponse, listIndexResponse, translationsResponse] = await Promise.all([ - fetch("/data/mhwilds/monsters.json"), - fetch("/data/mhwilds/endemic_life.json"), - fetch("/data/mhwilds/lists/index.json"), - fetch("/data/mhwilds/i18n/fr.json") + fetch("/data/mhwilds/monsters.json", { signal }), + fetch("/data/mhwilds/endemic_life.json", { signal }), + fetch("/data/mhwilds/lists/index.json", { signal }), + fetch("/data/mhwilds/i18n/fr.json", { signal }) ]); if (!monstersResponse.ok || !endemicResponse.ok || !listIndexResponse.ok || !translationsResponse.ok) { @@ -39,7 +39,7 @@ export async function loadMhwildsData() { listIndexResponse.json(), translationsResponse.json() ]); - const lists = await loadGameLists("mhwilds", listIndexJson); + const lists = await loadGameLists("mhwilds", listIndexJson, signal); const monsterFilterKey = Object.keys(monstersJson).find((key) => key !== "monsters") || ""; const endemicFilterKey = Object.keys(endemicJson).find((key) => key !== "endemicLife" && key !== "aquaticLife") || ""; @@ -62,10 +62,10 @@ export async function loadMhwildsData() { }; } -async function loadGameLists(gameId, indexPayload) { +async function loadGameLists(gameId, indexPayload, signal) { const entries = Array.isArray(indexPayload?.lists) ? indexPayload.lists : []; const results = await Promise.all(entries.map(async (entry) => { - const response = await fetch(`/data/${gameId}/lists/${entry.file}`); + const response = await fetch(`/data/${gameId}/lists/${entry.file}`, { signal }); if (!response.ok) throw new Error(`Impossible de charger la liste ${entry.file}.`); const payload = await response.json(); return normalizeGameListFile(entry, payload); @@ -107,8 +107,8 @@ function formatListTitle(value) { .replace(/\b\p{L}/gu, (letter) => letter.toUpperCase()); } -export async function loadDiablo4Data() { - const response = await fetch("/data/diablo4/affixes_types.json"); +export async function loadDiablo4Data(signal) { + const response = await fetch("/data/diablo4/affixes_types.json", { signal }); if (!response.ok) throw new Error("Impossible de charger les données Diablo IV."); const payload = await response.json(); diff --git a/website/src/hooks/useAppData.js b/website/src/hooks/useAppData.js index 84a4e37..66f21ec 100644 --- a/website/src/hooks/useAppData.js +++ b/website/src/hooks/useAppData.js @@ -2,52 +2,62 @@ import { useEffect, useState } from "react"; import { INITIAL_DIABLO4_STATE, INITIAL_MHWILDS_STATE, loadDiablo4Data, loadMhwildsData } from "../features/games/loaders.js"; +function useGameData(active, initialState, load) { + const [requested, setRequested] = useState(active); + const [data, setData] = useState(initialState); + if (active && !requested) setRequested(true); + + useEffect(() => { + if (!requested) return undefined; + const controller = new AbortController(); + load(controller.signal) + .then((result) => { + if (!controller.signal.aborted) setData(result); + }) + .catch((error) => { + if (!controller.signal.aborted) setData({ ...initialState, loaded: true, error: error.message }); + }); + return () => controller.abort(); + }, [requested, initialState, load]); + + // Une visite déclenche un seul chargement, conservé même si la route change. + return requested && !data.loaded ? { ...data, loading: true } : data; +} + export function useAppData(route) { const [siteContent, setSiteContent] = useState(null); const [siteContentError, setSiteContentError] = useState(""); const [games, setGames] = useState([]); const [gamesError, setGamesError] = useState(""); - const [mhwilds, setMhwilds] = useState(INITIAL_MHWILDS_STATE); - const [diablo4, setDiablo4] = useState(INITIAL_DIABLO4_STATE); + const mhwilds = useGameData(route.startsWith("/games/mhwilds"), INITIAL_MHWILDS_STATE, loadMhwildsData); + const diablo4 = useGameData(route.startsWith("/games/diablo4"), INITIAL_DIABLO4_STATE, loadDiablo4Data); useEffect(() => { + const controller = new AbortController(); + const { signal } = controller; Promise.all([ - fetch("/data/site.json").then((response) => { + fetch("/data/site.json", { signal }).then((response) => { if (!response.ok) throw new Error("Impossible de charger le contenu du site."); return response.json(); }).catch((error) => { - setSiteContentError(error.message); + if (!signal.aborted) setSiteContentError(error.message); return null; }), - fetch("/data/games.json").then((response) => { + fetch("/data/games.json", { signal }).then((response) => { if (!response.ok) throw new Error("Impossible de charger la liste des jeux."); return response.json(); }).catch((error) => { - setGamesError(error.message); + if (!signal.aborted) setGamesError(error.message); return { games: [] }; }) ]).then(([content, gamesPayload]) => { + if (signal.aborted) return; if (content) setSiteContent(content); setGames(Array.isArray(gamesPayload.games) ? gamesPayload.games : []); }); + return () => controller.abort(); }, []); - useEffect(() => { - if (!route.startsWith("/games/mhwilds") || mhwilds.loaded || mhwilds.loading) return; - setMhwilds((state) => ({ ...state, loading: true, error: "" })); - loadMhwildsData() - .then(setMhwilds) - .catch((error) => setMhwilds({ ...INITIAL_MHWILDS_STATE, loaded: true, error: error.message })); - }, [route, mhwilds.loaded, mhwilds.loading]); - - useEffect(() => { - if (!route.startsWith("/games/diablo4") || diablo4.loaded || diablo4.loading) return; - setDiablo4((state) => ({ ...state, loading: true, error: "" })); - loadDiablo4Data() - .then(setDiablo4) - .catch((error) => setDiablo4({ ...INITIAL_DIABLO4_STATE, loaded: true, error: error.message })); - }, [route, diablo4.loaded, diablo4.loading]); - return { siteContent, siteContentError,