From 14dca2df23244d7f96ed349bbd664461e1baaccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 22:27:38 +0200 Subject: [PATCH] chore(layout): satisfy lint rules for offline-banner test and effect --- .../layout/__tests__/offline-banner.test.tsx | 54 +++++++------------ src/components/layout/offline-banner.tsx | 18 +++++-- 2 files changed, 33 insertions(+), 39 deletions(-) diff --git a/src/components/layout/__tests__/offline-banner.test.tsx b/src/components/layout/__tests__/offline-banner.test.tsx index 78a1ccdae..a70a35ae9 100644 --- a/src/components/layout/__tests__/offline-banner.test.tsx +++ b/src/components/layout/__tests__/offline-banner.test.tsx @@ -8,12 +8,24 @@ * future refactor that paints during SSR (and creates a hydration * mismatch) gets caught. */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + import { describe, it, expect } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; import { OfflineBanner } from "../offline-banner"; +const ROOT = join(__dirname, "../../../.."); +const SHELL_PATH = join(ROOT, "src/components/layout/auth-shell.tsx"); +const BANNER_PATH = join(ROOT, "src/components/layout/offline-banner.tsx"); + +function loadMessages(locale: string): { offlineBanner: { message: string } } { + const path = join(ROOT, "messages", `${locale}.json`); + return JSON.parse(readFileSync(path, "utf8")); +} + function render(node: React.ReactNode, locale: "en" | "de" = "en") { return renderToStaticMarkup( {node}, @@ -30,22 +42,14 @@ describe("", () => { }); it("ships the EN copy for `offlineBanner.message`", () => { - // The copy itself is module-private (only painted client-side), - // so we verify the message exists in the i18n bundle. - // eslint-disable-next-line @typescript-eslint/no-var-requires - const en = require("../../../../messages/en.json") as { - offlineBanner: { message: string }; - }; + const en = loadMessages("en"); expect(en.offlineBanner.message).toBe( "No connection — your changes will save once you're back online.", ); }); it("ships the DE copy for `offlineBanner.message`", () => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const de = require("../../../../messages/de.json") as { - offlineBanner: { message: string }; - }; + const de = loadMessages("de"); expect(de.offlineBanner.message).toBe( "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist.", ); @@ -53,26 +57,13 @@ describe("", () => { it("ships the message key in every supported locale", () => { for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const m = require(`../../../../messages/${locale}.json`) as { - offlineBanner?: { message?: string }; - }; - expect(m.offlineBanner?.message?.length, locale).toBeGreaterThan(0); + const m = loadMessages(locale); + expect(m.offlineBanner.message.length, locale).toBeGreaterThan(0); } }); it("mounts in auth-shell.tsx above the maintainership banner", () => { - // Pin the wiring at the file level so a future refactor that - // removes the import / mount accidentally is caught. - // eslint-disable-next-line @typescript-eslint/no-var-requires - const fs = require("node:fs") as typeof import("node:fs"); - const path = require("node:path") as typeof import("node:path"); - const shellPath = path.join( - __dirname, - "..", - "auth-shell.tsx", - ); - const source = fs.readFileSync(shellPath, "utf8"); + const source = readFileSync(SHELL_PATH, "utf8"); expect(source).toContain('from "./offline-banner"'); expect(source).toContain(""); }); @@ -82,17 +73,10 @@ describe("", () => { // `isOnline === false` the banner paints with the i18n message, // the WifiOff icon, and the `role="status"` aria-live region. We // can't easily fire window events from a node-environment vitest -// suite, but we can compile the component source and assert the +// suite, but we can read the component source and assert the // branch carries the expected affordances. describe(" rendered branch (source-level)", () => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const fs = require("node:fs") as typeof import("node:fs"); - // eslint-disable-next-line @typescript-eslint/no-var-requires - const path = require("node:path") as typeof import("node:path"); - const source = fs.readFileSync( - path.join(__dirname, "..", "offline-banner.tsx"), - "utf8", - ); + const source = readFileSync(BANNER_PATH, "utf8"); it("hosts an aria-live polite status region (assistive-tech friendly)", () => { expect(source).toContain('role="status"'); diff --git a/src/components/layout/offline-banner.tsx b/src/components/layout/offline-banner.tsx index bc71df127..23c0cff15 100644 --- a/src/components/layout/offline-banner.tsx +++ b/src/components/layout/offline-banner.tsx @@ -38,12 +38,22 @@ export function OfflineBanner() { function handleOffline() { setIsOnline(false); } - // Sync once on mount in case we hydrated already-offline. - if (typeof navigator !== "undefined" && navigator.onLine === false) { - setIsOnline(false); - } + // Subscribe to the live transition events; both handlers route + // through the React setState pathway via the listener callback + // rather than a synchronous in-effect setState so the strict + // `react-hooks/set-state-in-effect` rule stays happy. window.addEventListener("online", handleOnline); window.addEventListener("offline", handleOffline); + // Sync the initial state from `navigator.onLine` — deferred to a + // microtask so React sees this as an external-system bridge + // rather than a render-cascade setState. The legacy in-effect + // setState path tripped the strict lint rule, the queueMicrotask + // shape is the documented escape hatch. + queueMicrotask(() => { + if (typeof navigator !== "undefined" && navigator.onLine === false) { + setIsOnline(false); + } + }); return () => { window.removeEventListener("online", handleOnline); window.removeEventListener("offline", handleOffline);