Initialize session state by page and module identity
Some checks failed
Deploy Sokko G / deploy (push) Failing after 3s

This commit is contained in:
Shinuwa 2026-09-11 14:05:41 +02:00
parent 4f644ca116
commit 7c0b72f914
8 changed files with 177 additions and 19 deletions

View file

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

View file

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

View file

@ -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()
};
}

92
e2e/session-state.spec.js Normal file
View file

@ -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: `<p>Content ${id}</p>`, 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([]);
});

View file

@ -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(() => {

View file

@ -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(() => {

View file

@ -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("");

View file

@ -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];
}