Fix toolbox tutorial state across navigation
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s

This commit is contained in:
Shinuwa 2026-09-12 09:08:10 +02:00
parent 75d4d99a87
commit 898fd5286f
5 changed files with 111 additions and 11 deletions

View file

@ -34,3 +34,4 @@
{"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-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."}} {"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."}}
{"id":"int-9cb5a663c6e65593625066b25976439c","kind":"field_change","created_at":"2026-09-11T19:15:51.529868089Z","actor":"Shinuwa","issue_id":"sokko-g-5i3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Library category now derives from anchor navigation; same-page hash/history navigation works; malformed anchors handled and obsolete RAF/scroll timers cleaned up. Existing library coverage and two new behavioral E2E pass. npm run check:full passes with 32 E2E; targeted set-state-in-effect lint passes."}} {"id":"int-9cb5a663c6e65593625066b25976439c","kind":"field_change","created_at":"2026-09-11T19:15:51.529868089Z","actor":"Shinuwa","issue_id":"sokko-g-5i3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Library category now derives from anchor navigation; same-page hash/history navigation works; malformed anchors handled and obsolete RAF/scroll timers cleaned up. Existing library coverage and two new behavioral E2E pass. npm run check:full passes with 32 E2E; targeted set-state-in-effect lint passes."}}
{"id":"int-4ece504edad6e21c4829485c33215835","kind":"field_change","created_at":"2026-09-12T07:06:41.579702638Z","actor":"Shinuwa","issue_id":"sokko-g-739","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Tutorial visibility now follows toolbox identity and request transitions before commit; tour keyed by toolbox resets step and start latch. Two StrictMode behavioral tests cover identity changes, one-time flag consumption, dismissal and pending requests. check:full passes with 34 E2E; targeted set-state-in-effect lint passes."}}

View file

@ -18,6 +18,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Si l'outil porte un état de progression, vérifier que l'import texte repart de l'état initial attendu. - Si l'outil porte un état de progression, vérifier que l'import texte repart de l'état initial attendu.
- Vérifier l'affichage dans la page toolbox complète. - Vérifier l'affichage dans la page toolbox complète.
- Vérifier l'affichage dans le panneau latéral. - Vérifier l'affichage dans le panneau latéral.
- Pour le tutoriel toolbox, vérifier le démarrage unique, la fermeture et le changement de toolbox en cours de visite ; une autre toolbox doit démarrer à la première étape et ne pas consommer les indicateurs de la précédente.
- 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 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 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 le simulateur de chance, vérifier que les brouillons suivent le taux et l'outil actifs, restent intacts lors d'un rendu sans rapport et se valident au blur selon le champ. - Pour le simulateur de chance, vérifier que les brouillons suivent le taux et l'outil actifs, restent intacts lors d'un rendu sans rapport et se valident au blur selon le champ.

View file

@ -0,0 +1,39 @@
// Rôle : monte la page toolbox sous StrictMode pour contrôler les demandes de tutoriel et les changements d'identité.
import { createElement, StrictMode, useState } from "react";
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { ToolboxPage } from "../../website/src/features/toolboxes/ToolboxPages.jsx";
export function mountToolboxTutorialProbe(element, siteContent) {
const root = createRoot(element);
const writes = [];
const pendingChanges = [];
let setInputs;
function Probe() {
const [inputs, updateInputs] = useState({
toolboxId: "a", pendingTutorialToolboxId: "",
toolboxes: ["a", "b", "c"].map((id) => ({ id, name: id, modules: [], tutorial: id !== "c" }))
});
setInputs = updateInputs;
return createElement(ToolboxPage, {
...inputs, siteContent, actions: {},
getToolboxGame: () => null,
getModuleData: (_toolbox, _module, fallback) => fallback,
updateModuleData: () => {},
updateToolbox: (toolbox) => {
writes.push(toolbox);
updateInputs((current) => ({ ...current, toolboxes: current.toolboxes.map((item) => item.id === toolbox.id ? toolbox : item) }));
},
setPendingTutorialToolboxId: (id) => {
pendingChanges.push(id);
updateInputs((current) => ({ ...current, pendingTutorialToolboxId: id }));
}
});
}
flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe))));
return {
writes, pendingChanges,
update: (patch) => flushSync(() => setInputs((current) => ({ ...current, ...patch }))),
unmount: () => flushSync(() => root.unmount())
};
}

View file

@ -0,0 +1,62 @@
// Rôle : vérifie les étapes, la fermeture et l'isolation des tutoriels toolbox sous StrictMode.
/* global window, document, fetch */
import { test, expect } from "@playwright/test";
import { fileURLToPath, URL } from "node:url";
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/toolbox-tutorial-probe.js", import.meta.url))}`;
async function setup(page) {
await page.goto("/");
await page.evaluate(async (url) => {
const { mountToolboxTutorialProbe } = await import(url);
const siteContent = await fetch("/data/site.json").then((response) => response.json());
document.getElementById("app").style.display = "none";
const element = document.createElement("div");
document.body.prepend(element);
window.tutorialProbe = mountToolboxTutorialProbe(element, siteContent);
}, probeUrl);
}
test("tutorial restarts on another toolbox and consumes each start flag once", async ({ page }) => {
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await setup(page);
const tour = page.locator(".toolbox-tutorial-tooltip");
const count = tour.locator(".toolbox-tutorial-count");
await expect(count).toHaveText(/^1\//);
await tour.locator("footer button").click();
await expect(count).toHaveText(/^2\//);
await page.evaluate(() => window.tutorialProbe.update({}));
await expect(count).toHaveText(/^2\//);
await page.evaluate(() => window.tutorialProbe.update({ toolboxId: "b" }));
await expect(count).toHaveText(/^1\//);
expect(await page.evaluate(() => window.tutorialProbe.writes.map(({ id, tutorial }) => ({ id, tutorial })))).toEqual([
{ id: "a", tutorial: false }, { id: "b", tutorial: false }
]);
await page.evaluate(() => window.tutorialProbe.update({ toolboxId: "c" }));
await expect(tour).toHaveCount(0);
await page.evaluate(() => window.tutorialProbe.update({ toolboxId: "a" }));
await expect(tour).toHaveCount(0);
await page.evaluate(() => window.tutorialProbe.unmount());
expect(errors).toEqual([]);
});
test("pending tutorial survives unrelated toolboxes and stays dismissed after rerenders", async ({ page }) => {
await setup(page);
const tour = page.locator(".toolbox-tutorial-tooltip");
await expect(tour).toBeVisible();
await tour.locator(".toolbox-tutorial-close").click();
await page.evaluate(() => window.tutorialProbe.update({ pendingTutorialToolboxId: "c" }));
await expect(tour).toHaveCount(0);
expect(await page.evaluate(() => window.tutorialProbe.pendingChanges)).toEqual([]);
await page.evaluate(() => window.tutorialProbe.update({ toolboxId: "c" }));
await expect(tour.locator(".toolbox-tutorial-count")).toHaveText(/^1\//);
expect(await page.evaluate(() => window.tutorialProbe.pendingChanges)).toEqual([""]);
await tour.locator(".toolbox-tutorial-close").click();
await page.evaluate(() => window.tutorialProbe.update({}));
await expect(tour).toHaveCount(0);
await page.evaluate(() => window.tutorialProbe.update({ pendingTutorialToolboxId: "c" }));
await expect(tour.locator(".toolbox-tutorial-count")).toHaveText(/^1\//);
await page.evaluate(() => window.tutorialProbe.unmount());
await expect(tour).toHaveCount(0);
});

View file

@ -181,12 +181,17 @@ function shouldShowTutorial(toolbox, pendingTutorialToolboxId) {
function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles, pendingTutorialToolboxId = "", setPendingTutorialToolboxId }) { function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles, pendingTutorialToolboxId = "", setPendingTutorialToolboxId }) {
const toolboxId = toolbox.id; const toolboxId = toolbox.id;
const toolboxTutorial = toolbox.tutorial;
const moduleColumns = embedded || Number(toolbox.moduleColumns) === 1 ? 1 : 2; const moduleColumns = embedded || Number(toolbox.moduleColumns) === 1 ? 1 : 2;
const toolboxGameCover = getGameCardCover(toolboxGame); const toolboxGameCover = getGameCardCover(toolboxGame);
const moduleText = siteContent.toolboxes.modules; const moduleText = siteContent.toolboxes.modules;
const [showTutorial, setShowTutorial] = useState(() => shouldShowTutorial(toolbox, pendingTutorialToolboxId)); const [showTutorial, setShowTutorial] = useState(() => shouldShowTutorial(toolbox, pendingTutorialToolboxId));
const currentToolboxIdRef = useRef(toolboxId); const tutorialRequested = shouldShowTutorial(toolbox, pendingTutorialToolboxId);
const [previousTutorial, setPreviousTutorial] = useState({ toolboxId, requested: tutorialRequested });
// Consommer la demande de lancement ne doit pas interrompre la visite en cours.
if (previousTutorial.toolboxId !== toolboxId || previousTutorial.requested !== tutorialRequested) {
setPreviousTutorial({ toolboxId, requested: tutorialRequested });
if (previousTutorial.toolboxId !== toolboxId || tutorialRequested) setShowTutorial(tutorialRequested);
}
const moduleContext = { const moduleContext = {
getModuleData, getModuleData,
storageReady, storageReady,
@ -222,15 +227,6 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
refresh: () => {} refresh: () => {}
}; };
useEffect(() => {
if (currentToolboxIdRef.current === toolboxId) {
if (shouldShowTutorial(toolbox, pendingTutorialToolboxId)) setShowTutorial(true);
return;
}
currentToolboxIdRef.current = toolboxId;
setShowTutorial(shouldShowTutorial(toolbox, pendingTutorialToolboxId));
}, [pendingTutorialToolboxId, toolbox, toolboxId, toolboxTutorial]);
function addModule(type, initialData = null) { function addModule(type, initialData = null) {
if (!type) return; if (!type) return;
const module = { id: uid("mod"), type }; const module = { id: uid("mod"), type };
@ -285,6 +281,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
</section> </section>
{!embedded && showTutorial && ( {!embedded && showTutorial && (
<GuidedTooltipTour <GuidedTooltipTour
key={toolboxId}
steps={siteContent.toolboxes.tutorial?.items || []} steps={siteContent.toolboxes.tutorial?.items || []}
labels={{ labels={{
dismiss: siteContent.toolboxes.tutorial?.dismissLabel, dismiss: siteContent.toolboxes.tutorial?.dismissLabel,