Fix library anchor navigation and scroll cleanup
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s

This commit is contained in:
Shinuwa 2026-09-12 08:54:09 +02:00
parent 99d916f6db
commit 75d4d99a87
4 changed files with 88 additions and 22 deletions

View file

@ -33,3 +33,4 @@
{"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-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-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."}}

View file

@ -7,6 +7,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Ajouter ou modifier le composant dans `website/src/features/toolboxes/modules/`. - Ajouter ou modifier le composant dans `website/src/features/toolboxes/modules/`.
- Déclarer ses métadonnées et son chargeur dans `website/src/features/toolboxes/toolboxModuleRegistry.js`. Le registre React `modules/toolboxModuleComponents.jsx` est partagé par la toolbox et la Librairie ; `modules/index.jsx` expose le shell. - Déclarer ses métadonnées et son chargeur dans `website/src/features/toolboxes/toolboxModuleRegistry.js`. Le registre React `modules/toolboxModuleComponents.jsx` est partagé par la toolbox et la Librairie ; `modules/index.jsx` expose le shell.
- Vérifier le chargement à la demande en toolbox et dans chaque catégorie de la Librairie. - Vérifier le chargement à la demande en toolbox et dans chaque catégorie de la Librairie.
- Dans la Librairie, vérifier les liens directs vers un outil, les changements d'ancre et le retour navigateur ; une nouvelle sélection ou une sortie de page doit annuler les défilements différés précédents.
- Ajouter les textes nécessaires dans `website/public/data/site.json`. - Ajouter les textes nécessaires dans `website/public/data/site.json`.
- Mettre à jour la validation de `site.json` dans `tests/helpers/data-validation.mjs`. - Mettre à jour la validation de `site.json` dans `tests/helpers/data-validation.mjs`.
- Mettre à jour la normalisation et le stockage compact dans `website/src/features/toolboxes/storage/modules/<outil>.js`, puis vérifier l'export public dans `website/src/features/toolboxes/storage/toolboxStorage.js`. - Mettre à jour la normalisation et le stockage compact dans `website/src/features/toolboxes/storage/modules/<outil>.js`, puis vérifier l'export public dans `website/src/features/toolboxes/storage/toolboxStorage.js`.

View file

@ -1,4 +1,5 @@
// Rôle : vérifie le chargement des outils de chaque catégorie de la librairie. // Rôle : vérifie le chargement, la navigation par ancres et le nettoyage des défilements de la librairie.
/* global window, document */
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { LIBRARY_CATEGORY_DEFINITIONS } from "../website/src/features/toolboxes/toolboxModuleRegistry.js"; import { LIBRARY_CATEGORY_DEFINITIONS } from "../website/src/features/toolboxes/toolboxModuleRegistry.js";
@ -22,3 +23,54 @@ test("loads every library tool through the shared component registry", async ({
} }
expect(errors).toEqual([]); expect(errors).toEqual([]);
}); });
test("library follows deep links, same-page hashes and browser history", async ({ page }) => {
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/#/library#library-tool-m6");
await expect(page.locator('[data-module-type="combos"]')).toBeVisible();
await expect(page.locator("#library-category-buildsCommands")).toBeVisible();
await page.evaluate(() => { window.location.hash = "/library#library-tool-m8"; });
await expect(page.locator('[data-module-type="table"]')).toBeVisible();
await expect(page.locator("#library-category-calculationData")).toBeVisible();
await page.goBack();
await expect(page.locator('[data-module-type="combos"]')).toBeVisible();
await page.evaluate(() => { window.location.hash = "/library#%E0%A4%A"; });
await expect(page.locator("#library-category-notesTracking")).toBeVisible();
expect(errors).toEqual([]);
});
test("library cancels obsolete scroll retries on selection and unmount", async ({ page }) => {
await page.goto("/#/library");
await expect(page.locator('[data-module-type="notepad"]')).toBeVisible();
await page.clock.install({ time: new Date("2026-09-11T12:00:00Z") });
await page.clock.pauseAt(new Date("2026-09-11T12:00:01Z"));
await page.evaluate(() => {
window.libraryScrolls = [];
window.scrollTo = (options) => window.libraryScrolls.push(options);
});
const nav = page.locator(".library-doc-nav");
await nav.locator('a[href="#/library#library-tool-m0"]').dispatchEvent("click");
await page.clock.runFor(40);
expect(await page.evaluate(() => window.libraryScrolls.length)).toBeGreaterThan(0);
await nav.locator('a[href="#/library#library-tool-m1"]').dispatchEvent("click");
await page.evaluate(() => { window.libraryScrolls = []; });
await page.clock.runFor(700);
const scrolls = await page.evaluate(() => window.libraryScrolls);
expect(scrolls).toHaveLength(4);
expect(scrolls.map((scroll) => scroll.behavior)).toEqual(["smooth", "auto", "auto", "auto"]);
await nav.locator('a[href="#/library#library-tool-m2"]').dispatchEvent("click");
await page.clock.runFor(40);
await page.evaluate(() => { window.location.hash = "/"; });
await expect(page.locator(".library-page")).toHaveCount(0);
// Garder une cible homonyme permet de détecter un ancien timer après démontage.
await page.evaluate(() => {
const target = document.createElement("div");
target.id = "library-tool-m2";
document.body.append(target);
window.libraryScrolls = [];
});
await page.clock.runFor(700);
expect(await page.evaluate(() => window.libraryScrolls)).toEqual([]);
});

View file

@ -1,5 +1,5 @@
// Rôle : affiche la librairie des outils avec des exemples locaux non persistés. // Rôle : affiche la librairie des outils avec des exemples locaux non persistés.
import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../components/Icon.jsx"; import { Icon } from "../components/Icon.jsx";
import { LIBRARY_CATEGORY_DEFINITIONS, getToolboxModuleDefinition } from "../features/toolboxes/toolboxModuleRegistry.js"; import { LIBRARY_CATEGORY_DEFINITIONS, getToolboxModuleDefinition } from "../features/toolboxes/toolboxModuleRegistry.js";
import { MODULE_COMPONENTS } from "../features/toolboxes/modules/toolboxModuleComponents.jsx"; import { MODULE_COMPONENTS } from "../features/toolboxes/modules/toolboxModuleComponents.jsx";
@ -62,7 +62,11 @@ function getLibraryCategoryAnchorId(category) {
function getCurrentLibraryAnchor() { function getCurrentLibraryAnchor() {
const match = location.hash.match(/^#\/library#(.+)$/); const match = location.hash.match(/^#\/library#(.+)$/);
return match ? decodeURIComponent(match[1]) : ""; try {
return match ? decodeURIComponent(match[1]) : "";
} catch {
return "";
}
} }
function getLibraryAnchorOffset() { function getLibraryAnchorOffset() {
@ -71,17 +75,20 @@ function getLibraryAnchorOffset() {
return topbarHeight + 16; return topbarHeight + 16;
} }
function scrollToLibraryTool(anchorId, replace = false, behavior = "smooth") { function scrollToLibraryTool(anchorId, behavior) {
if (!anchorId) return; let timers = [];
if (replace) history.replaceState(null, "", `#/library#${encodeURIComponent(anchorId)}`); const frame = requestAnimationFrame(() => {
LIBRARY_ANCHOR_RETRY_DELAYS.forEach((delay, index) => { timers = LIBRARY_ANCHOR_RETRY_DELAYS.map((delay, index) => window.setTimeout(() => {
window.setTimeout(() => {
const element = document.getElementById(anchorId); const element = document.getElementById(anchorId);
if (!element) return; if (!element) return;
const top = element.getBoundingClientRect().top + window.scrollY - getLibraryAnchorOffset(); const top = element.getBoundingClientRect().top + window.scrollY - getLibraryAnchorOffset();
window.scrollTo({ top: Math.max(0, top), behavior: index === 0 ? behavior : "auto" }); window.scrollTo({ top: Math.max(0, top), behavior: index === 0 ? behavior : "auto" });
}, delay); }, delay));
}); });
return () => {
cancelAnimationFrame(frame);
timers.forEach((timer) => window.clearTimeout(timer));
};
} }
function findCategoryForAnchor(categories, anchorId) { function findCategoryForAnchor(categories, anchorId) {
@ -114,9 +121,13 @@ export function LibraryPage({ siteContent, actions }) {
const [libraryPayload, setLibraryPayload] = useState(null); const [libraryPayload, setLibraryPayload] = useState(null);
const [libraryError, setLibraryError] = useState(""); const [libraryError, setLibraryError] = useState("");
const [moduleData, setModuleData] = useState({}); const [moduleData, setModuleData] = useState({});
const [activeCategoryKey, setActiveCategoryKey] = useState(""); const [navigation, setNavigation] = useState(() => ({ anchorId: getCurrentLibraryAnchor(), behavior: "auto" }));
const libraryCategories = useMemo(() => getLibraryCategories(libraryPayload?.toolbox?.modules || [], content), [content, libraryPayload]); const libraryCategories = useMemo(() => getLibraryCategories(libraryPayload?.toolbox?.modules || [], content), [content, libraryPayload]);
const activeCategory = libraryCategories.find((category) => category.key === activeCategoryKey) || libraryCategories[0] || null; const activeCategory = findCategoryForAnchor(libraryCategories, navigation.anchorId) || libraryCategories[0] || null;
const selectAnchor = useCallback((anchorId) => {
history.replaceState(null, "", `#/library#${encodeURIComponent(anchorId)}`);
setNavigation({ anchorId, behavior: "smooth" });
}, []);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -137,13 +148,15 @@ export function LibraryPage({ siteContent, actions }) {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!libraryPayload || !libraryCategories.length) return; const onHashChange = () => setNavigation({ anchorId: getCurrentLibraryAnchor(), behavior: "auto" });
const anchorId = getCurrentLibraryAnchor(); window.addEventListener("hashchange", onHashChange);
const category = findCategoryForAnchor(libraryCategories, anchorId) || libraryCategories[0]; return () => window.removeEventListener("hashchange", onHashChange);
setActiveCategoryKey(category.key); }, []);
if (!anchorId) return;
requestAnimationFrame(() => scrollToLibraryTool(anchorId, false, "auto")); useEffect(() => {
}, [libraryPayload, libraryCategories]); if (!activeCategory?.key || !navigation.anchorId) return undefined;
return scrollToLibraryTool(navigation.anchorId, navigation.behavior);
}, [activeCategory?.key, navigation]);
const moduleContext = useMemo(() => ({ const moduleContext = useMemo(() => ({
getModuleData: (toolboxId, moduleId, fallback) => moduleData[`${toolboxId}:${moduleId}`] || fallback, getModuleData: (toolboxId, moduleId, fallback) => moduleData[`${toolboxId}:${moduleId}`] || fallback,
@ -199,9 +212,9 @@ export function LibraryPage({ siteContent, actions }) {
...current, ...current,
[`${LIBRARY_TOOLBOX_ID}:${annotationModule.id}`]: { image: dataUrl, markers: [], drawings: { strokes: [] } } [`${LIBRARY_TOOLBOX_ID}:${annotationModule.id}`]: { image: dataUrl, markers: [], drawings: { strokes: [] } }
})); }));
requestAnimationFrame(() => scrollToLibraryTool(getLibraryAnchorId(annotationModule), true)); selectAnchor(getLibraryAnchorId(annotationModule));
} }
}), [actions, libraryPayload, moduleData, siteContent.toolboxes.modules]); }), [actions, libraryPayload, moduleData, selectAnchor, siteContent.toolboxes.modules]);
return ( return (
<div className="library-page"> <div className="library-page">
@ -235,8 +248,7 @@ export function LibraryPage({ siteContent, actions }) {
categories={libraryCategories} categories={libraryCategories}
activeCategoryKey={activeCategory?.key || ""} activeCategoryKey={activeCategory?.key || ""}
onSelectCategory={(category, anchorId = getLibraryCategoryAnchorId(category)) => { onSelectCategory={(category, anchorId = getLibraryCategoryAnchorId(category)) => {
setActiveCategoryKey(category.key); selectAnchor(anchorId);
requestAnimationFrame(() => scrollToLibraryTool(anchorId, true));
}} }}
/> />
<section className="library-tools" aria-label={content.toolsLabel}> <section className="library-tools" aria-label={content.toolsLabel}>