From b9cc77a4d1af28fb0cbb8563663fb2dbdda1b8f1 Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Fri, 11 Sep 2026 09:17:45 +0200 Subject: [PATCH] Restore React purity checks and streamline inline drafts --- .beads/interactions.jsonl | 1 + e2e/fixtures/inline-edit-probe.js | 34 +++++++++++++ e2e/inline-edit.spec.js | 51 +++++++++++++++++++ e2e/toolbox.spec.js | 13 +++++ eslint.config.js | 1 - .../modules/randomizer/RandomizerTeams.jsx | 9 ++-- website/src/hooks/useInlineEdit.js | 8 ++- website/src/hooks/useToolboxAlerts.js | 4 +- 8 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 e2e/fixtures/inline-edit-probe.js create mode 100644 e2e/inline-edit.spec.js diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index b0c7ca0..b9426a7 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -28,3 +28,4 @@ {"id":"int-df984435537f78d51a7a309feda1109c","kind":"field_change","created_at":"2026-09-10T17:21:18.79044889Z","actor":"Shinuwa","issue_id":"sokko-g-ckq","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented quota coalescing and shared helpers. npm run check and all 15 E2E tests with --workers=2 pass; default concurrency timeouts tracked separately."}} {"id":"int-300128a7d6ed7fa43c5f43c7cb0b9dad","kind":"field_change","created_at":"2026-09-11T06:50:23.200542022Z","actor":"Shinuwa","issue_id":"sokko-g-v02","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-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."}} diff --git a/e2e/fixtures/inline-edit-probe.js b/e2e/fixtures/inline-edit-probe.js new file mode 100644 index 0000000..8dffba9 --- /dev/null +++ b/e2e/fixtures/inline-edit-probe.js @@ -0,0 +1,34 @@ +// Rôle : monte le hook d'édition réel sous StrictMode pour observer ses commits DOM. +import { createElement, StrictMode, useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { useInlineEdit } from "../../website/src/hooks/useInlineEdit.js"; + +export function mountInlineEditProbe(element, contentEditable) { + const commits = []; + const renders = []; + const root = createRoot(element); + + function Probe({ value }) { + const edit = useInlineEdit({ + value, + onCommit: (next) => commits.push(next), + blurOnEscape: true + }); + useLayoutEffect(() => { + renders.push({ value, draft: edit.draft }); + }); + return createElement("section", null, + contentEditable + ? createElement("div", edit.getContentEditableProps({ role: "textbox", "aria-label": "Edit" })) + : createElement("input", edit.getInputProps({ type: "text", "aria-label": "Edit" })), + createElement("button", { type: "button" }, "Outside") + ); + } + + function update(value) { + flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe, { value })))); + } + update("Initial"); + return { update, commits, renders, unmount: () => root.unmount() }; +} diff --git a/e2e/inline-edit.spec.js b/e2e/inline-edit.spec.js new file mode 100644 index 0000000..c9e89b0 --- /dev/null +++ b/e2e/inline-edit.spec.js @@ -0,0 +1,51 @@ +// Rôle : teste la synchronisation, la validation et l'annulation des éditions inline. +/* global window, document */ +import { test, expect } from "@playwright/test"; +import { fileURLToPath, URL } from "node:url"; + +const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/inline-edit-probe.js", import.meta.url))}`; + +for (const contentEditable of [false, true]) { + test(`inline ${contentEditable ? "contentEditable" : "input"} synchronizes and cancels under StrictMode`, async ({ page }) => { + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/"); + await page.evaluate(async ({ probeUrl, contentEditable }) => { + const { mountInlineEditProbe } = await import(probeUrl); + const element = document.createElement("div"); + element.id = "inline-probe"; + document.body.prepend(element); + window.inlineProbe = mountInlineEditProbe(element, contentEditable); + }, { probeUrl, contentEditable }); + const editor = page.locator("#inline-probe").getByRole("textbox", { name: "Edit" }); + const expectValue = (value) => contentEditable ? expect(editor).toHaveText(value) : expect(editor).toHaveValue(value); + await expectValue("Initial"); + await editor.fill("Uncommitted"); + await page.evaluate(() => window.inlineProbe.update("Initial")); + await expectValue("Uncommitted"); + await editor.press("Escape"); + await expectValue("Initial"); + expect(await page.evaluate(() => window.inlineProbe.commits)).toEqual([]); + + await page.evaluate(() => window.inlineProbe.update("Updated")); + await expectValue("Updated"); + const sourceRenders = await page.evaluate(() => window.inlineProbe.renders.filter((entry) => entry.value === "Updated")); + expect(sourceRenders.length).toBeGreaterThan(0); + expect(sourceRenders.every((entry) => entry.draft === "Updated")).toBe(true); + + await editor.fill(" Saved "); + await editor.press("Enter"); + expect(await page.evaluate(() => window.inlineProbe.commits)).toEqual(["Saved"]); + await page.evaluate(() => window.inlineProbe.update("Saved")); + await expectValue("Saved"); + await editor.fill("Discard"); + await editor.press("Escape"); + await expectValue("Saved"); + expect(await page.evaluate(() => window.inlineProbe.commits)).toEqual(["Saved"]); + + await page.evaluate(() => window.inlineProbe.update("Initial")); + await expectValue("Initial"); + await page.evaluate(() => window.inlineProbe.unmount()); + expect(errors).toEqual([]); + }); +} diff --git a/e2e/toolbox.spec.js b/e2e/toolbox.spec.js index 7ab975e..2654868 100644 --- a/e2e/toolbox.spec.js +++ b/e2e/toolbox.spec.js @@ -160,6 +160,19 @@ test("loads randomizer tabs after module split", async ({ page }) => { await randomizer.getByRole("tab", { name: "Équipes" }).click(); await expect(randomizer.getByLabel("Liste de noms")).toBeVisible(); await expect(randomizer.getByRole("button", { name: "Créer les équipes" })).toBeVisible(); + await randomizer.getByRole("combobox", { name: "Répartition", exact: true }).selectOption("count"); + const count = randomizer.getByRole("spinbutton", { name: "Nombre", exact: true }); + await count.fill("5"); + await count.blur(); + await expect(count).toHaveValue("5"); + await randomizer.getByRole("spinbutton", { name: "Équipes", exact: true }).fill("6"); + await expect(count).toHaveValue("6"); + await count.fill(""); + await count.blur(); + await expect(count).toHaveValue("6"); + await randomizer.getByRole("tab", { name: "Dé", exact: true }).click(); + await randomizer.getByRole("tab", { name: "Équipes", exact: true }).click(); + await expect(count).toHaveValue("6"); }); test("creates a game-linked toolbox from the drawer", async ({ page }) => { diff --git a/eslint.config.js b/eslint.config.js index c9c9357..08dc48a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -34,7 +34,6 @@ export default [ }, rules: { ...reactHooks.configs.recommended.rules, - "react-hooks/purity": "off", "react-hooks/set-state-in-effect": "off", "no-unused-vars": ["error", { argsIgnorePattern: "^_", diff --git a/website/src/features/toolboxes/modules/randomizer/RandomizerTeams.jsx b/website/src/features/toolboxes/modules/randomizer/RandomizerTeams.jsx index 6ec555e..a7ee093 100644 --- a/website/src/features/toolboxes/modules/randomizer/RandomizerTeams.jsx +++ b/website/src/features/toolboxes/modules/randomizer/RandomizerTeams.jsx @@ -1,8 +1,9 @@ // Rôle : gère l'onglet équipes équilibrées du Randomizer et ses exclusions. -import { useEffect, useState } from "react"; +import { useState } from "react"; import { CompactDropdown } from "../../../../components/CompactDropdown.jsx"; import { Icon } from "../../../../components/Icon.jsx"; import { useDraftForm } from "../../../../hooks/useDraftForm.js"; +import { useInlineEdit } from "../../../../hooks/useInlineEdit.js"; import { RANDOMIZER_LIMITS, addRandomizerHistory, clampNumber, createBalancedTeams, filterExclusionsForPlayers } from "../randomizerUtils.js"; import { RandomizerHistory } from "./RandomizerShared.jsx"; import { DEFAULT_EXCLUSION_DRAFT, TEAM_REEL_DELAYS, hasReducedMotion, pickRandomItems } from "./randomizerUiUtils.js"; @@ -19,15 +20,11 @@ export function TeamsRandomizer({ data, textContent, onChange, context }) { const { queueTimer, clearTimers: clearTeamTimers } = useTimeoutQueue(); const activePlayers = data.players.filter((player) => player.active); const storedSelectionCount = clampNumber(data.playerSelectionCount, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, data.teamCount); - const [selectionCountDraft, setSelectionCountDraft] = useState(String(storedSelectionCount)); + const { draft: selectionCountDraft, setDraft: setSelectionCountDraft } = useInlineEdit({ value: String(storedSelectionCount) }); const selectionCount = clampNumber(selectionCountDraft, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, storedSelectionCount); const displayedTeams = animating ? animationTeams : data.teams; const exclusionMode = exclusionDraft.values.mode === "source" ? "source" : "group"; - useEffect(() => { - setSelectionCountDraft(String(storedSelectionCount)); - }, [storedSelectionCount]); - function commitSelectionCount(value = selectionCountDraft) { const nextSelectionCount = clampNumber(value, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, storedSelectionCount); setSelectionCountDraft(String(nextSelectionCount)); diff --git a/website/src/hooks/useInlineEdit.js b/website/src/hooks/useInlineEdit.js index b73dfa3..1ed3a06 100644 --- a/website/src/hooks/useInlineEdit.js +++ b/website/src/hooks/useInlineEdit.js @@ -22,6 +22,13 @@ export function useInlineEdit({ const inputRef = useRef(null); const skipCommitRef = useRef(false); const [draft, setDraft] = useState(value); + const [previousValue, setPreviousValue] = useState(value); + + // Réinitialise avant le commit DOM, uniquement si la valeur source a changé. + if (!Object.is(previousValue, value)) { + setPreviousValue(value); + setDraft(value); + } function syncContentEditable(nextValue, force = false) { if (inputRef.current?.isContentEditable && (force || document.activeElement !== inputRef.current)) { @@ -30,7 +37,6 @@ export function useInlineEdit({ } useEffect(() => { - setDraft(value); syncContentEditable(value); }, [value]); diff --git a/website/src/hooks/useToolboxAlerts.js b/website/src/hooks/useToolboxAlerts.js index 5260fbb..a5067d1 100644 --- a/website/src/hooks/useToolboxAlerts.js +++ b/website/src/hooks/useToolboxAlerts.js @@ -28,7 +28,7 @@ function formatAlertMessage(template, toolbox, label) { export function useToolboxAlerts({ toolboxes, moduleData, links, route, drawerGameId, normalizeTimerData, normalizeCalendarData, textContent, notify }) { const previousTargetsRef = useRef(new Map()); const resumedCalendarEventsRef = useRef(new Set()); - const lastTickRef = useRef(Date.now()); + const lastTickRef = useRef(null); useEffect(() => { const visibleToolboxIds = getVisibleToolboxIds(route, drawerGameId, links); @@ -38,7 +38,7 @@ export function useToolboxAlerts({ toolboxes, moduleData, links, route, drawerGa function tick() { const nowMs = Date.now(); - const lastTickMs = lastTickRef.current; + const lastTickMs = lastTickRef.current ?? nowMs; const nextTargets = new Map(); toolboxes.forEach((toolbox) => {