From 04ee67bc3525b1bf0ae77a771c1edf1d5e603130 Mon Sep 17 00:00:00 2001 From: Salah Al-Nawah Date: Mon, 26 Oct 2026 20:33:00 +0000 Subject: [PATCH 1/3] ci(devops): fix lint warnings and enforce --max-warnings 0 Each of the 22 warnings gets an inline disable with a reason; no behaviour change. Refs SAL-3522 Made-with: agent Co-authored-by: mc-agents[bot] <298817976+mc-agents[bot]@users.noreply.github.com> --- app/components/common/BottomSheet.tsx | 1 + app/components/home/EnhancedSlider.tsx | 1 + app/components/layout/Header.tsx | 1 + app/components/layout/HeaderNav.tsx | 1 + app/components/layout/MobileAccountSheet.tsx | 1 + app/components/layout/MobileBottomBar.tsx | 6 ++++++ app/components/layout/MobilePageTitleBar.tsx | 2 ++ app/components/layout/ThemeLayout.tsx | 1 + app/components/product/ProductGallery.tsx | 1 + app/components/product/ProductListPage.tsx | 1 + app/hooks/useAppSafeAreaTop.ts | 1 + app/hooks/useStaggeredRows.ts | 4 ++++ package.json | 2 +- tests/hooks/useScrollDirection.test.tsx | 1 + 14 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/components/common/BottomSheet.tsx b/app/components/common/BottomSheet.tsx index 9255696..262f1be 100644 --- a/app/components/common/BottomSheet.tsx +++ b/app/components/common/BottomSheet.tsx @@ -51,6 +51,7 @@ export function BottomSheet({ useEffect(() => { if (open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: state must sync after mount/route change (SSR-safe, avoids hydration mismatch) setMounted(true); return; } diff --git a/app/components/home/EnhancedSlider.tsx b/app/components/home/EnhancedSlider.tsx index 6ba3650..7b1f346 100644 --- a/app/components/home/EnhancedSlider.tsx +++ b/app/components/home/EnhancedSlider.tsx @@ -156,6 +156,7 @@ function YouTubeBackground({ id }: { id: string }) { ref={ref} className="enhanced-slider__video enhanced-slider__video--embed" data-state={state} + // eslint-disable-next-line no-restricted-properties -- intentional: no ref available for this global DOM/location read; behaviour kept src={youtubeEmbedUrl(id, window.location.origin)} title="" loading="lazy" diff --git a/app/components/layout/Header.tsx b/app/components/layout/Header.tsx index 2f2d5bd..a6a485a 100644 --- a/app/components/layout/Header.tsx +++ b/app/components/layout/Header.tsx @@ -168,6 +168,7 @@ export function Header({ navOpen: navOpenProp, onNavOpenChange }: HeaderProps = // the height). useEffect(() => { if (!stickyEnabled) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: state must sync after mount/route change (SSR-safe, avoids hydration mismatch) setScrolled(false); return; } diff --git a/app/components/layout/HeaderNav.tsx b/app/components/layout/HeaderNav.tsx index e63f2f9..b80e5bb 100644 --- a/app/components/layout/HeaderNav.tsx +++ b/app/components/layout/HeaderNav.tsx @@ -368,6 +368,7 @@ function useDrawerTransition(open: boolean) { useEffect(() => { if (open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: state must sync after mount/route change (SSR-safe, avoids hydration mismatch) setMounted(true); return; } diff --git a/app/components/layout/MobileAccountSheet.tsx b/app/components/layout/MobileAccountSheet.tsx index 3f31111..1d91c1b 100644 --- a/app/components/layout/MobileAccountSheet.tsx +++ b/app/components/layout/MobileAccountSheet.tsx @@ -348,6 +348,7 @@ export function MobileAccountSheet({ const [itemsIn, setItemsIn] = useState(false); useEffect(() => { if (!open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: state must sync after mount/route change (SSR-safe, avoids hydration mismatch) setItemsIn(false); return; } diff --git a/app/components/layout/MobileBottomBar.tsx b/app/components/layout/MobileBottomBar.tsx index 5c11095..1f53d27 100644 --- a/app/components/layout/MobileBottomBar.tsx +++ b/app/components/layout/MobileBottomBar.tsx @@ -59,10 +59,13 @@ function getCartSdk(): CartSdk | undefined { type Tab = 'home' | 'categories' | 'search' | 'cart' | 'account'; +// eslint-disable-next-line no-restricted-properties -- intentional: no ref available for this global DOM/location read; behaviour kept const searchModal = (): Element | null => document.querySelector('salla-modal.s-search-modal'); const userMenu = (): (HTMLElement & { opened?: boolean }) | null => + // eslint-disable-next-line no-restricted-properties -- intentional: no ref available for this global DOM/location read; behaviour kept (document.querySelector('salla-user-menu.header-action') ?? + // eslint-disable-next-line no-restricted-properties -- intentional: no ref available for this global DOM/location read; behaviour kept document.querySelector('salla-user-menu')) as (HTMLElement & { opened?: boolean }) | null; /** Open the Salla search modal — the same event the header's search button fires. */ @@ -122,7 +125,9 @@ function activeTab( return null; } +// eslint-disable-next-line no-restricted-properties -- intentional: no ref available for this global DOM/location read; behaviour kept const isSearchOpen = () => !!document.querySelector('salla-modal.s-search-modal[visible]'); +// eslint-disable-next-line no-restricted-properties -- intentional: no ref available for this global DOM/location read; behaviour kept const isMenuOpen = () => !!document.querySelector('.s-user-menu-toggler.opened'); /** @@ -147,6 +152,7 @@ function useDomFlag(test: () => boolean, enabled: boolean): boolean { useEffect(() => { if (!enabled) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: state must sync after mount/route change (SSR-safe, avoids hydration mismatch) setOn(false); return; } diff --git a/app/components/layout/MobilePageTitleBar.tsx b/app/components/layout/MobilePageTitleBar.tsx index 1137a77..1c4bfc9 100644 --- a/app/components/layout/MobilePageTitleBar.tsx +++ b/app/components/layout/MobilePageTitleBar.tsx @@ -61,6 +61,7 @@ export function MobilePageTitleBar() { const [docTitle, setDocTitle] = useState(''); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: state must sync after mount/route change (SSR-safe, avoids hydration mismatch) setDocTitle(routeTitle ? '' : cleanDocumentTitle()); }, [routeTitle]); @@ -76,6 +77,7 @@ export function MobilePageTitleBar() { className="mobile-page-title__back" onClick={() => { if (window.history.length > 1) window.history.back(); + // eslint-disable-next-line no-restricted-properties -- intentional: no ref available for this global DOM/location read; behaviour kept else window.location.assign('/'); }} aria-label={t('blocks.header.back', 'Back')} diff --git a/app/components/layout/ThemeLayout.tsx b/app/components/layout/ThemeLayout.tsx index c9f20e0..07704e7 100644 --- a/app/components/layout/ThemeLayout.tsx +++ b/app/components/layout/ThemeLayout.tsx @@ -133,6 +133,7 @@ export function ThemeLayout({ children }: ThemeLayoutProps) { // state, so they would otherwise stay open over the new page. const pathname = useRouterState({ select: (s) => s.location.pathname }); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: state must sync after mount/route change (SSR-safe, avoids hydration mismatch) setNavOpen(false); setAccountOpen(false); setImportantLinksOpen(false); diff --git a/app/components/product/ProductGallery.tsx b/app/components/product/ProductGallery.tsx index 9bdd237..c606c2c 100644 --- a/app/components/product/ProductGallery.tsx +++ b/app/components/product/ProductGallery.tsx @@ -135,6 +135,7 @@ export function ProductGallery({ product }: ProductGalleryProps) { className="product-gallery__bar-btn" onClick={() => { if (window.history.length > 1) window.history.back(); + // eslint-disable-next-line no-restricted-properties -- intentional: no ref available for this global DOM/location read; behaviour kept else window.location.assign('/'); }} aria-label={t('blocks.header.back', 'Back')} diff --git a/app/components/product/ProductListPage.tsx b/app/components/product/ProductListPage.tsx index 512e796..cec7e8e 100644 --- a/app/components/product/ProductListPage.tsx +++ b/app/components/product/ProductListPage.tsx @@ -49,6 +49,7 @@ function usePref( // This fills the hook's own state from localStorage; no parent is // involved: see .react-doctor/false-positives.md. // react-doctor-disable-next-line react-doctor/no-pass-data-to-parent + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: state must sync after mount/route change (SSR-safe, avoids hydration mismatch) if (stored && values.includes(stored)) setValue(stored); } catch { /* private mode */ diff --git a/app/hooks/useAppSafeAreaTop.ts b/app/hooks/useAppSafeAreaTop.ts index b26c1fa..8b270f4 100644 --- a/app/hooks/useAppSafeAreaTop.ts +++ b/app/hooks/useAppSafeAreaTop.ts @@ -17,6 +17,7 @@ export function useAppSafeAreaTop(): number { const [top, setTop] = useState(0); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: state must sync after mount/route change (SSR-safe, avoids hydration mismatch) setTop(read()); const observer = new MutationObserver(() => setTop(read())); observer.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] }); diff --git a/app/hooks/useStaggeredRows.ts b/app/hooks/useStaggeredRows.ts index a34a32b..cd6038f 100644 --- a/app/hooks/useStaggeredRows.ts +++ b/app/hooks/useStaggeredRows.ts @@ -35,6 +35,7 @@ export function useStaggeredRows( useEffect(() => { if (!open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: state must sync after mount/route change (SSR-safe, avoids hydration mismatch) setRowsIn(false); return; } @@ -47,15 +48,18 @@ export function useStaggeredRows( timer = setTimeout(() => { // Force a reflow so the web component's closed state is painted before // `.is-in` flips it; otherwise a just-inserted row never transitions. + // eslint-disable-next-line no-restricted-properties -- intentional: no ref available for this global DOM/location read; behaviour kept void document.querySelector(containerSelector)?.offsetWidth; cancelPaint = afterNextPaint(() => setRowsIn(true)); }, ROWS_IN_DELAY_MS); }; + // eslint-disable-next-line no-restricted-properties -- intentional: no ref available for this global DOM/location read; behaviour kept if (document.querySelector(rowSelector)) { release(); } else { observer = new MutationObserver(() => { + // eslint-disable-next-line no-restricted-properties -- intentional: no ref available for this global DOM/location read; behaviour kept if (document.querySelector(rowSelector)) { observer?.disconnect(); release(); diff --git a/package.json b/package.json index 8164f39..061fffb 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "lint": "eslint .", + "lint": "eslint . --max-warnings 0", "format:check": "prettier --check ." }, "dependencies": { diff --git a/tests/hooks/useScrollDirection.test.tsx b/tests/hooks/useScrollDirection.test.tsx index d95a6a1..507842c 100644 --- a/tests/hooks/useScrollDirection.test.tsx +++ b/tests/hooks/useScrollDirection.test.tsx @@ -5,6 +5,7 @@ import { useScrollDirection } from '../../app/hooks/useScrollDirection'; let direction: string | undefined; function Probe() { + // eslint-disable-next-line react-hooks/globals -- test probe captures the hook result outside render by design direction = useScrollDirection(); return null; } From e3997b13137e2e1820ab0ffa1e21937aa668ce7d Mon Sep 17 00:00:00 2001 From: Salah Al-Nawah Date: Tue, 27 Oct 2026 07:13:48 +0000 Subject: [PATCH 2/3] test(themes): unmount the header before clearing the safe-area inset A late re-render after globals were unstubbed threw an unhandled IntersectionObserver error. Refs SAL-3522 Made-with: agent Co-authored-by: mc-agents[bot] <298817976+mc-agents[bot]@users.noreply.github.com> --- tests/components/layout/Header.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/components/layout/Header.test.tsx b/tests/components/layout/Header.test.tsx index b1d6322..e65bed1 100644 --- a/tests/components/layout/Header.test.tsx +++ b/tests/components/layout/Header.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, fireEvent, act } from '@testing-library/react'; +import { render, fireEvent, act, cleanup } from '@testing-library/react'; import { Header } from '../../../app/components/layout/Header'; import { HOME_ROUTE_ID } from '../../../app/lib/routeIds'; @@ -86,6 +86,8 @@ beforeEach(() => { }); afterEach(() => { + // Unmount first so clearing the inset cannot re-render a mounted header after globals are unstubbed. + cleanup(); document.documentElement.style.removeProperty('--app-safe-area-top'); vi.unstubAllGlobals(); }); From 3f6c193628f6b49f1a0c21cc31c0bc49b488131e Mon Sep 17 00:00:00 2001 From: Salah Al-Nawah Date: Tue, 27 Oct 2026 10:23:34 +0000 Subject: [PATCH 3/3] test(themes): name the scroll direction probe test id by feature-component-action Refs SAL-3522 Made-with: agent Co-authored-by: mc-agents[bot] <298817976+mc-agents[bot]@users.noreply.github.com> --- tests/hooks/useScrollDirection.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/hooks/useScrollDirection.test.tsx b/tests/hooks/useScrollDirection.test.tsx index 73cd485..eca140c 100644 --- a/tests/hooks/useScrollDirection.test.tsx +++ b/tests/hooks/useScrollDirection.test.tsx @@ -3,10 +3,10 @@ import { act, render, screen } from '@testing-library/react'; import { useScrollDirection } from '../../app/hooks/useScrollDirection'; function Probe() { - return {useScrollDirection()}; + return {useScrollDirection()}; } -const direction = () => screen.getByTestId('direction').textContent; +const direction = () => screen.getByTestId('scroll-direction-probe-value').textContent; /** Scroll the window to `y` and run the frame the hook waits for. */ function scrollTo(y: number) {