diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index ac6b95f..adcb02a 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -30,3 +30,4 @@ {"id":"int-a54950bb4a7dbb177188a5ec02e9d9fd","kind":"field_change","created_at":"2026-09-11T06:50:28.175047619Z","actor":"Shinuwa","issue_id":"sokko-g-328","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Default E2E concurrency capped at two workers with failure traces; compact calendar titles kept in flow and sm padding corrected. Desktop/mobile geometry and editing regressions added. npm run check:full passed; second full E2E run passed all 17 tests without retries."}} {"id":"int-147c4573ce40577c21afec08775c2811","kind":"field_change","created_at":"2026-09-11T07:16:09.749977471Z","actor":"Shinuwa","issue_id":"sokko-g-55a","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Restored recommended purity lint; initialized alert clock in effect; removed post-commit draft synchronization from shared inline editing and reused it in Randomizer. 100 Node tests, lint, production build and all 19 E2E pass. StrictMode hook tests cover input/contentEditable, source updates, commit and Escape; broader C10 tracked in sokko-g-8po."}} {"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."}} diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md index d6c6bc5..2925eb7 100644 --- a/docs/FEATURE_CHECKLIST.md +++ b/docs/FEATURE_CHECKLIST.md @@ -18,6 +18,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page - Vérifier l'affichage dans la page toolbox complète. - Vérifier l'affichage dans le panneau latéral. - Pour le Bloc Note, vérifier la sélection, l'ajout, le renommage et la suppression de pages en page complète, panneau latéral et mobile. +- Pour les dessins temporaires et l'historique du simulateur de chance, vérifier l'isolation par clé de session lors des changements de page/outil et après rechargement ; une navigation seule ne doit pas écraser les données de l'autre clé. - Pour Mémos, vérifier l'édition courte, les couleurs, la réorganisation et la bande déroulante. - Vérifier qu'une notification ou une mise à jour sans rapport ne remplace pas un brouillon Mémos avant sa validation au blur. - Pour l'arbre d'objectifs, vérifier la sélection après suppression/import et le retour à Base quand le nœud sélectionné devient une étape sans onglet Contenu. diff --git a/e2e/fixtures/keyed-state-probe.js b/e2e/fixtures/keyed-state-probe.js new file mode 100644 index 0000000..d300276 --- /dev/null +++ b/e2e/fixtures/keyed-state-probe.js @@ -0,0 +1,38 @@ +// Rôle : observe les valeurs validées et les setters différés d'un état lié à une clé. +/* global sessionStorage */ +import { createElement, StrictMode, useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { useKeyedState } from "../../website/src/hooks/useKeyedState.js"; + +export function mountKeyedStateProbe(element) { + const root = createRoot(element); + const snapshots = []; + const setters = new Map(); + let reads = 0; + + function Probe({ identity }) { + const [value, setValue] = useKeyedState(identity, (key) => { + reads++; + return JSON.parse(sessionStorage.getItem(`keyed-probe:${key}`) || "[]"); + }); + useLayoutEffect(() => { + snapshots.push({ key: identity, value }); + setters.set(identity, setValue); + }); + return createElement("output", null, JSON.stringify(value)); + } + + function update(identity) { + snapshots.length = 0; + flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe, { identity })))); + } + update("a"); + return { + update, + snapshots, + get reads() { return reads; }, + append: (key, value) => flushSync(() => setters.get(key)((current) => [...current, value])), + unmount: () => root.unmount() + }; +} diff --git a/e2e/session-state.spec.js b/e2e/session-state.spec.js new file mode 100644 index 0000000..58a35c8 --- /dev/null +++ b/e2e/session-state.spec.js @@ -0,0 +1,92 @@ +// Rôle : vérifie l'isolation de l'état de session et des dessins entre pages du Bloc Note. +/* global window, document, sessionStorage */ +import { test, expect } from "@playwright/test"; +import { fileURLToPath, URL } from "node:url"; +import { resetSokkoStorage } from "./helpers/storage.js"; + +const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/keyed-state-probe.js", import.meta.url))}`; + +test("keyed state never commits another key's value under StrictMode", async ({ page }) => { + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/"); + await page.evaluate(async (url) => { + sessionStorage.setItem("keyed-probe:a", '["A"]'); + sessionStorage.setItem("keyed-probe:b", '["B"]'); + const { mountKeyedStateProbe } = await import(url); + const element = document.createElement("div"); + document.body.prepend(element); + window.keyedProbe = mountKeyedStateProbe(element); + }, probeUrl); + await page.evaluate(() => window.keyedProbe.append("a", "edit")); + const reads = await page.evaluate(() => window.keyedProbe.reads); + await page.evaluate(() => window.keyedProbe.update("a")); + expect(await page.evaluate(() => window.keyedProbe.reads)).toBe(reads); + expect(await page.evaluate(() => window.keyedProbe.snapshots.at(-1).value)).toEqual(["A", "edit"]); + + await page.evaluate(() => window.keyedProbe.update("b")); + const snapshots = await page.evaluate(() => window.keyedProbe.snapshots); + expect(snapshots.length).toBeGreaterThan(0); + for (const snapshot of snapshots) expect(snapshot).toEqual({ key: "b", value: ["B"] }); + await page.evaluate(() => window.keyedProbe.append("a", "stale")); + await page.evaluate(() => window.keyedProbe.append("b", "current")); + expect(await page.evaluate(() => window.keyedProbe.snapshots.at(-1).value)).toEqual(["B", "current"]); + await page.evaluate(() => window.keyedProbe.update("a")); + expect(await page.evaluate(() => window.keyedProbe.snapshots.at(-1).value)).toEqual(["A"]); + await page.evaluate(() => window.keyedProbe.unmount()); + expect(errors).toEqual([]); +}); + +test("notepad temporary drawings stay on their own pages and survive reload", async ({ page }) => { + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await resetSokkoStorage(page); + await page.evaluate(async () => { + const db = await import("/src/utils/indexedDbStorage.js"); + await db.setToolboxSnapshot([{ + id: "session-notes", name: "Session notes", modules: [{ id: "note", type: "notepad" }] + }], {}, { + "session-notes:note": { + activePageId: "a", + pages: ["a", "b", "c"].map((id) => ({ + id, title: `Page ${id}`, html: `

Content ${id}

`, drawingMode: "temporary" + })) + } + }); + for (const [id, y, color] of [["a", 20, "#f6c453"], ["b", 40, "#22d3ee"]]) { + sessionStorage.setItem(`sokkog:notepad-drawings:session-notes:note:${id}`, JSON.stringify([ + { id, color, width: 4, points: [{ x: 20, y }, { x: 100, y }] } + ])); + } + sessionStorage.setItem("sokkog:notepad-drawings:session-notes:note:c", "invalid json"); + }); + await page.goto("/#/toolbox/session-notes"); + await page.reload(); + const notepad = page.locator('[data-module-type="notepad"]'); + const canvas = notepad.locator("canvas"); + const alphaAt = (y) => canvas.evaluate((element, y) => { + const ratio = element.width / element.clientWidth; + return element.getContext("2d").getImageData(Math.round(50 * ratio), Math.round(y * ratio), 1, 1).data[3]; + }, y); + async function selectPage(id) { + await notepad.getByRole("button", { name: "Afficher les pages du Bloc Note", exact: true }).click(); + await page.getByRole("button", { name: `Page ${id}`, exact: true }).click(); + await expect(notepad.locator(".notepad-editor")).toHaveText(`Content ${id}`); + } + await expect(canvas).toBeVisible(); + await expect.poll(() => alphaAt(20)).toBeGreaterThan(0); + await selectPage("b"); + await expect.poll(() => alphaAt(40)).toBeGreaterThan(0); + await expect.poll(() => alphaAt(20)).toBe(0); + await notepad.getByRole("button", { name: "Dessiner", exact: true }).click(); + await notepad.getByRole("button", { name: "Effacer les dessins", exact: true }).click(); + await expect.poll(() => page.evaluate(() => JSON.parse(sessionStorage.getItem("sokkog:notepad-drawings:session-notes:note:b")))).toEqual([]); + await selectPage("a"); + await expect.poll(() => alphaAt(20)).toBeGreaterThan(0); + await selectPage("c"); + await expect.poll(() => alphaAt(20)).toBe(0); + await selectPage("a"); + await page.reload(); + await expect.poll(() => alphaAt(20)).toBeGreaterThan(0); + expect(errors).toEqual([]); +}); diff --git a/website/src/features/toolboxes/modules/ImageAnnotationModule.jsx b/website/src/features/toolboxes/modules/ImageAnnotationModule.jsx index 79696ff..1c8d0f7 100644 --- a/website/src/features/toolboxes/modules/ImageAnnotationModule.jsx +++ b/website/src/features/toolboxes/modules/ImageAnnotationModule.jsx @@ -4,6 +4,16 @@ import { Icon } from "../../../components/Icon.jsx"; import { Tooltip } from "../../../components/Tooltip.jsx"; import { DrawingOverlay } from "./DrawingOverlay.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx"; +import { useKeyedState } from "../../../hooks/useKeyedState.js"; + +function readTemporaryDrawings(sessionKey) { + try { + const stored = JSON.parse(sessionStorage.getItem(sessionKey) || "null"); + return Array.isArray(stored?.strokes) ? stored : { strokes: [] }; + } catch { + return { strokes: [] }; + } +} function markerLabel(index, marker, textContent) { return marker.label || `${textContent.markerPrefix || "Marqueur"} ${index + 1}`; @@ -19,7 +29,7 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing, t const sessionKey = `sokkog:image-annotation-drawings:${toolboxId}:${moduleId}`; const [dragOver, setDragOver] = useState(false); const [drawingMode, setDrawingMode] = useState("permanent"); - const [temporaryDrawings, setTemporaryDrawings] = useState({ strokes: [] }); + const [temporaryDrawings, setTemporaryDrawings] = useKeyedState(sessionKey, readTemporaryDrawings); const markers = data.markers; const drawings = drawingMode === "permanent" ? data.drawings || { strokes: [] } : temporaryDrawings; const pastePlaceholder = textContent.pastePlaceholder || "Coller une image ici"; @@ -31,12 +41,6 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing, t useEffect(() => { temporaryDrawingsDirtyRef.current = false; sessionStorageFailureRef.current = ""; - try { - const stored = JSON.parse(sessionStorage.getItem(sessionKey) || "{\"strokes\":[]}"); - setTemporaryDrawings(stored?.strokes ? stored : { strokes: [] }); - } catch { - setTemporaryDrawings({ strokes: [] }); - } }, [sessionKey]); useEffect(() => { diff --git a/website/src/features/toolboxes/modules/NotepadModule.jsx b/website/src/features/toolboxes/modules/NotepadModule.jsx index adc8d57..a62ca8d 100644 --- a/website/src/features/toolboxes/modules/NotepadModule.jsx +++ b/website/src/features/toolboxes/modules/NotepadModule.jsx @@ -7,6 +7,7 @@ import { DrawingOverlay } from "./DrawingOverlay.jsx"; import { NotepadToolbar } from "./NotepadToolbar.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { normalizeNotepadData } from "../storage/toolboxStorage.js"; +import { useKeyedState } from "../../../hooks/useKeyedState.js"; import { DRAWING_WIDTHS, HIGHLIGHT_COLORS, @@ -22,6 +23,15 @@ import { const EMPTY_NOTEPAD_DATA = { text: "" }; +function readTemporaryStrokes(sessionKey) { + try { + const stored = JSON.parse(sessionStorage.getItem(sessionKey) || "[]"); + return Array.isArray(stored) ? stored : []; + } catch { + return []; + } +} + export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }) { const storedData = context.getModuleData(toolboxId, moduleId, EMPTY_NOTEPAD_DATA); const normalizedData = useMemo(() => normalizeNotepadData(storedData), [storedData]); @@ -58,7 +68,7 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen } numberList: false }); const sessionKey = `sokkog:notepad-drawings:${toolboxId}:${moduleId}:${activePage.id}`; - const [temporaryStrokes, setTemporaryStrokes] = useState([]); + const [temporaryStrokes, setTemporaryStrokes] = useKeyedState(sessionKey, readTemporaryStrokes); useEffect(() => { notepadDataRef.current = normalizedData; @@ -122,12 +132,6 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen } useEffect(() => { temporaryStrokesDirtyRef.current = false; sessionStorageFailureRef.current = ""; - try { - const stored = JSON.parse(sessionStorage.getItem(sessionKey) || "[]"); - setTemporaryStrokes(Array.isArray(stored) ? stored : []); - } catch { - setTemporaryStrokes([]); - } }, [sessionKey]); useEffect(() => { diff --git a/website/src/features/toolboxes/modules/RngSimulatorModule.jsx b/website/src/features/toolboxes/modules/RngSimulatorModule.jsx index 918acb2..2515ea6 100644 --- a/website/src/features/toolboxes/modules/RngSimulatorModule.jsx +++ b/website/src/features/toolboxes/modules/RngSimulatorModule.jsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { Tabs } from "../../../components/Tabs.jsx"; import { useDraftForm } from "../../../hooks/useDraftForm.js"; +import { useKeyedState } from "../../../hooks/useKeyedState.js"; import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { RNG_SIMULATOR_DEFAULT_RATE, @@ -156,7 +157,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal ], [textContent.forecastsTabLabel, textContent.simulationTabLabel, textContent.trackingTabLabel]); const historyStorageKey = `rng-simulator:${toolboxId}:${moduleId}:history`; const [activeTab, setActiveTab] = useState("tracking"); - const [history, setHistory] = useState(() => readSimulationHistory(historyStorageKey)); + const [history, setHistory] = useKeyedState(historyStorageKey, readSimulationHistory); const [editingRateId, setEditingRateId] = useState(""); const [multiplierDraft, setMultiplierDraft] = useState(null); const [costDraft, setCostDraft] = useState(null); @@ -196,10 +197,6 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal const averageExceededDraws = Math.max(0, Math.round(averageDrawsDelta)); const averageDrawsTone = realDrawsSummary?.averageExceeded ? (averageExceededDraws > 0 ? "negative" : "") : "positive"; - useEffect(() => { - setHistory(readSimulationHistory(historyStorageKey)); - }, [historyStorageKey]); - useEffect(() => { const wasEditing = previousEditingRef.current; if (wasEditing && !editing) setEditingRateId(""); diff --git a/website/src/hooks/useKeyedState.js b/website/src/hooks/useKeyedState.js new file mode 100644 index 0000000..bb02142 --- /dev/null +++ b/website/src/hooks/useKeyedState.js @@ -0,0 +1,21 @@ +// Rôle : initialise un état local par clé et ignore les setters d'une identité remplacée. +import { useCallback, useState } from "react"; + +export function useKeyedState(key, initialize) { + const [state, setState] = useState(() => ({ key, value: initialize(key) })); + let current = state; + if (!Object.is(state.key, key)) { + current = { key, value: initialize(key) }; + setState(current); + } + + const setValue = useCallback((value) => { + setState((previous) => { + if (!Object.is(previous.key, key)) return previous; + const next = typeof value === "function" ? value(previous.value) : value; + return Object.is(previous.value, next) ? previous : { key, value: next }; + }); + }, [key]); + + return [current.value, setValue]; +}