Initialize session state by page and module identity
Some checks failed
Deploy Sokko G / deploy (push) Failing after 3s
Some checks failed
Deploy Sokko G / deploy (push) Failing after 3s
This commit is contained in:
parent
4f644ca116
commit
7c0b72f914
8 changed files with 177 additions and 19 deletions
|
|
@ -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."}}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
38
e2e/fixtures/keyed-state-probe.js
Normal file
38
e2e/fixtures/keyed-state-probe.js
Normal 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
92
e2e/session-state.spec.js
Normal 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([]);
|
||||
});
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
21
website/src/hooks/useKeyedState.js
Normal file
21
website/src/hooks/useKeyedState.js
Normal 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];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue