From cd1ced53c0731504b890189fd098e5da11d90be5 Mon Sep 17 00:00:00 2001 From: Maciek Date: Thu, 5 Feb 2026 11:06:40 +0100 Subject: [PATCH 1/7] fix(frontend): Remove ScrollArea max-height that hid content behind navbar Signed-off-by: Maciek --- app/src/pages/blocklists/MainContentSection.tsx | 2 +- app/src/pages/blocklists/ServicesContentSection.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/pages/blocklists/MainContentSection.tsx b/app/src/pages/blocklists/MainContentSection.tsx index 8edeea41..09937f94 100644 --- a/app/src/pages/blocklists/MainContentSection.tsx +++ b/app/src/pages/blocklists/MainContentSection.tsx @@ -411,7 +411,7 @@ export default function MainContentSection(): JSX.Element { * by stacked fixed headers, preventing the overall document from scrolling to the very bottom. * We remove forced h-full and instead cap the ScrollArea only when there is sufficient vertical space. */} - +
{loading ? ( <> diff --git a/app/src/pages/blocklists/ServicesContentSection.tsx b/app/src/pages/blocklists/ServicesContentSection.tsx index bbb349dc..39ef6cef 100644 --- a/app/src/pages/blocklists/ServicesContentSection.tsx +++ b/app/src/pages/blocklists/ServicesContentSection.tsx @@ -104,7 +104,7 @@ export default function ServicesContentSection(): JSX.Element {
- +
{loading ? (
From 9e98f011ac504f1f47233c27edd63522bb05f9cc Mon Sep 17 00:00:00 2001 From: Maciek Date: Thu, 5 Feb 2026 11:08:15 +0100 Subject: [PATCH 2/7] fix(frontend): Fix content shifted left on mobile and tablet devices Signed-off-by: Maciek --- app/src/App.tsx | 7 +- .../e2e/layout/content-centering.spec.ts | 160 ++++++++++++++++++ app/src/index.css | 2 - app/src/pages/setup/RightPanelGuide.tsx | 8 +- 4 files changed, 167 insertions(+), 10 deletions(-) create mode 100644 app/src/__tests__/e2e/layout/content-centering.spec.ts diff --git a/app/src/App.tsx b/app/src/App.tsx index cf9ce369..acac46ec 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -370,7 +370,8 @@ function ProtectedLayout() { ? Math.max((viewportWidth - (sidebarWidth + ULTRAWIDE_CONTENT_MAX_WIDTH)) / 2, 0) : 0; - const contentMaxWidth = isDesktop ? DESKTOP_CONTENT_CLAMP : '100%'; + // On non-desktop (tablets in landscape), cap content width so mx-auto centers it + const contentMaxWidth = isDesktop ? DESKTOP_CONTENT_CLAMP : 'min(100%, 1080px)'; return ( <> @@ -426,11 +427,11 @@ function ProtectedLayout() { maxWidth: '100vw' } : { paddingTop: 'var(--app-header-stack, 110px)', - paddingBottom: '72px', + paddingBottom: 'calc(72px + env(safe-area-inset-bottom, 0px))', paddingLeft: '0px', marginLeft: '0px', width: '100%', - minHeight: 'calc(100dvh - 72px)', + minHeight: 'calc(100dvh - 72px - env(safe-area-inset-bottom, 0px))', maxWidth: '100vw' }} > diff --git a/app/src/__tests__/e2e/layout/content-centering.spec.ts b/app/src/__tests__/e2e/layout/content-centering.spec.ts new file mode 100644 index 00000000..0d6bee44 --- /dev/null +++ b/app/src/__tests__/e2e/layout/content-centering.spec.ts @@ -0,0 +1,160 @@ +import { test, expect } from '@playwright/test'; +import { registerMocks } from '../../mocks/registerMocks'; + +/** + * Content centering tests to prevent layout regression where content + * appears shifted to one side on mobile/tablet devices. + * + * Root cause of original bug: body element had `display: flex` and + * `place-items: center` from Vite template, causing #root to be + * horizontally centered when it didn't fill full viewport width. + */ + +const VIEWPORTS = [ + { name: 'iPhone SE', width: 375, height: 667 }, + { name: 'iPhone 14', width: 390, height: 844 }, + { name: 'iPhone 14 Pro Max', width: 430, height: 932 }, + { name: 'iPad Portrait', width: 768, height: 1024 }, + { name: 'iPad Landscape', width: 1024, height: 768 }, + { name: 'iPad Pro Landscape', width: 1194, height: 834 }, +]; + +const PROTECTED_ROUTES = ['/setup', '/blocklists', '/home', '/settings', '/custom-rules', '/query-logs']; + +test.describe('@layout Content centering - body styles', () => { + test('body element should not have centering flex styles', async ({ page }) => { + await registerMocks(page, { authenticated: true }); + await page.goto('/setup'); + + const bodyStyles = await page.evaluate(() => { + const body = document.body; + const computed = getComputedStyle(body); + return { + display: computed.display, + placeItems: computed.placeItems, + justifyContent: computed.justifyContent, + alignItems: computed.alignItems, + justifyItems: computed.justifyItems, + }; + }); + + // Body should NOT be a flex container that centers children + // This was the root cause of the left-shift bug + if (bodyStyles.display === 'flex' || bodyStyles.display === 'inline-flex') { + expect(bodyStyles.placeItems).not.toBe('center'); + expect(bodyStyles.justifyContent).not.toBe('center'); + expect(bodyStyles.justifyItems).not.toBe('center'); + // align-items: center is OK for vertical centering, but combined with + // justify-content: center would cause horizontal shift + if (bodyStyles.alignItems === 'center') { + expect(bodyStyles.justifyContent).not.toBe('center'); + } + } + }); + + test('html and body should span full viewport width', async ({ page }) => { + await registerMocks(page, { authenticated: true }); + await page.goto('/setup'); + + const dimensions = await page.evaluate(() => { + const viewport = window.innerWidth; + const htmlWidth = document.documentElement.offsetWidth; + const bodyWidth = document.body.offsetWidth; + const rootEl = document.getElementById('root'); + const rootWidth = rootEl ? rootEl.offsetWidth : 0; + return { viewport, htmlWidth, bodyWidth, rootWidth }; + }); + + // All should be equal to viewport width (within 1px tolerance for rounding) + expect(dimensions.htmlWidth).toBeGreaterThanOrEqual(dimensions.viewport - 1); + expect(dimensions.bodyWidth).toBeGreaterThanOrEqual(dimensions.viewport - 1); + expect(dimensions.rootWidth).toBeGreaterThanOrEqual(dimensions.viewport - 1); + }); +}); + +test.describe('@layout Content centering - app content area', () => { + test.beforeEach(async ({ page }) => { + await registerMocks(page, { authenticated: true }); + }); + + test('app-content fills full viewport width on mobile', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/setup'); + + const appContent = page.getByTestId('app-content'); + await expect(appContent).toBeVisible(); + + const box = await appContent.boundingBox(); + const viewport = page.viewportSize()!; + + // app-content should start at x=0 (no left offset) + expect(box!.x).toBe(0); + // app-content should span full viewport width + expect(box!.width).toBeGreaterThanOrEqual(viewport.width - 1); + }); + + for (const vp of VIEWPORTS) { + test(`content area starts at left edge on ${vp.name}`, async ({ page }) => { + await page.setViewportSize({ width: vp.width, height: vp.height }); + await page.goto('/setup'); + + const appContent = page.getByTestId('app-content'); + const box = await appContent.boundingBox(); + + // Content should start at x=0, not offset to the right + expect(box!.x, `app-content x offset on ${vp.name}`).toBe(0); + }); + } +}); + +test.describe('@layout Content centering - symmetric margins', () => { + test.beforeEach(async ({ page }) => { + await registerMocks(page, { authenticated: true }); + }); + + for (const vp of VIEWPORTS.filter(v => v.width < 1280)) { + test(`setup-container has symmetric margins on ${vp.name}`, async ({ page }) => { + await page.setViewportSize({ width: vp.width, height: vp.height }); + await page.goto('/setup'); + + const container = page.getByTestId('setup-container'); + await expect(container).toBeVisible(); + + const box = await container.boundingBox(); + const viewport = page.viewportSize()!; + + const leftMargin = box!.x; + const rightMargin = viewport.width - (box!.x + box!.width); + + // Left and right margins should be roughly equal (within 30px tolerance) + // This accounts for px-4 (16px) padding which may round differently + const marginDiff = Math.abs(leftMargin - rightMargin); + expect( + marginDiff, + `Asymmetric margins on ${vp.name}: left=${leftMargin.toFixed(0)}px, right=${rightMargin.toFixed(0)}px, diff=${marginDiff.toFixed(0)}px` + ).toBeLessThan(30); + }); + } + + test('content is visually centered across multiple pages', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }); // iPad portrait + + for (const route of PROTECTED_ROUTES) { + await page.goto(route); + + // Find the main content container (different pages may use different containers) + const appContent = page.getByTestId('app-content'); + const box = await appContent.boundingBox(); + const viewport = page.viewportSize()!; + + // Verify content starts at left edge + expect(box!.x, `${route}: app-content not at left edge`).toBe(0); + + // Verify no horizontal overflow + const hasOverflow = await page.evaluate(() => { + return document.documentElement.scrollWidth > window.innerWidth + 1; + }); + expect(hasOverflow, `${route}: has horizontal overflow`).toBe(false); + } + }); +}); diff --git a/app/src/index.css b/app/src/index.css index 2359f30a..deddcb81 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -60,8 +60,6 @@ a:hover { body { margin: 0; - display: flex; - place-items: center; min-width: 320px; min-height: 100vh; overscroll-behavior: contain; diff --git a/app/src/pages/setup/RightPanelGuide.tsx b/app/src/pages/setup/RightPanelGuide.tsx index 466cb039..37880ed6 100644 --- a/app/src/pages/setup/RightPanelGuide.tsx +++ b/app/src/pages/setup/RightPanelGuide.tsx @@ -150,15 +150,13 @@ export default function SetupGuidePanel({ platform, onClose, isVisible = true, m // Dynamic positioning: respect the measured header stack height so our overlay starts BELOW the fixed header(s) // Header stack height is published to --app-header-stack by useHeaderStackHeight hook (App.tsx) // Fallbacks: mobile ~110px padding (App.tsx fallback) but actual visual header for /setup page is usually ~64-72px. - const isMobile = typeof window !== 'undefined' ? window.innerWidth <= 768 : false; const baseTop = connectionStatusVisible ? 48 : 0; - // Measure actual header + optional page title height + safe-area inset (iOS notch) for precise overlay offset. + // Measure header height for overlay positioning on all non-desktop devices (phones + tablets) const [mobileTop, setMobileTop] = useState(0); useLayoutEffect(() => { if (!(mode === 'overlay')) return; const measure = () => { - if (!isMobile) { setMobileTop(baseTop); return; } const header = document.querySelector('[data-testid=app-header-bar]') as HTMLElement | null; const title = document.querySelector('[data-testid=mobile-header-page-title]') as HTMLElement | null; let total = 0; @@ -167,7 +165,7 @@ export default function SetupGuidePanel({ platform, onClose, isVisible = true, m // Add safe-area inset top if present const safe = parseInt(getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-top)').replace('px', '')) || 0; // Fallback if measurement fails - if (total === 0) total = 64; + if (total === 0) total = baseTop + 64; setMobileTop(total + safe); }; measure(); @@ -189,7 +187,7 @@ export default function SetupGuidePanel({ platform, onClose, isVisible = true, m mo.disconnect(); clearInterval(id); }; - }, [mode, isMobile, baseTop]); + }, [mode, baseTop]); const EXTRA_BUFFER = 24; const bufferedTop = mode === 'overlay' ? mobileTop + EXTRA_BUFFER : 0; From d82f342c05be4f650ea71b65e0d180ddb12d5e08 Mon Sep 17 00:00:00 2001 From: Maciek Date: Thu, 5 Feb 2026 11:13:49 +0100 Subject: [PATCH 3/7] fix(frontend): Makr services cards look the same like blocklists in light mode Signed-off-by: Maciek --- app/src/pages/blocklists/ServiceCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/pages/blocklists/ServiceCard.tsx b/app/src/pages/blocklists/ServiceCard.tsx index b773c541..56964939 100644 --- a/app/src/pages/blocklists/ServiceCard.tsx +++ b/app/src/pages/blocklists/ServiceCard.tsx @@ -28,7 +28,7 @@ const ServiceCard: React.FC = ({ return (
From d7af6149144a58e77099a013e037288191cea4ab Mon Sep 17 00:00:00 2001 From: Maciek Date: Thu, 5 Feb 2026 11:21:23 +0100 Subject: [PATCH 4/7] fix(frontend): Fix linter issue Signed-off-by: Maciek --- app/src/__tests__/e2e/layout/content-centering.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/__tests__/e2e/layout/content-centering.spec.ts b/app/src/__tests__/e2e/layout/content-centering.spec.ts index 0d6bee44..95c631ff 100644 --- a/app/src/__tests__/e2e/layout/content-centering.spec.ts +++ b/app/src/__tests__/e2e/layout/content-centering.spec.ts @@ -145,7 +145,6 @@ test.describe('@layout Content centering - symmetric margins', () => { // Find the main content container (different pages may use different containers) const appContent = page.getByTestId('app-content'); const box = await appContent.boundingBox(); - const viewport = page.viewportSize()!; // Verify content starts at left edge expect(box!.x, `${route}: app-content not at left edge`).toBe(0); From e863b27928c63cc2c3474d268c5c258a1e9625bd Mon Sep 17 00:00:00 2001 From: Maciek Date: Thu, 5 Feb 2026 12:03:32 +0100 Subject: [PATCH 5/7] fix(frontend): Fix Application Error - lazy loading issue Signed-off-by: Maciek --- app/src/App.tsx | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index acac46ec..a83f4fe5 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,4 +1,4 @@ -import React, { lazy, Suspense, useState, useEffect, useRef, createContext, useContext, useCallback } from "react"; +import React, { Suspense, useState, useEffect, useRef, createContext, useContext, useCallback } from "react"; import { useHeaderStackHeight } from '@/lib/useHeaderStackHeight'; import NavigationMenu from './pages/navigation_menu/NavigationMenu'; import { useScreenDetector } from './hooks/useScreenDetector'; @@ -6,25 +6,27 @@ import Header from './pages/header/Header'; import BottomNav from './components/navigation/BottomNav'; import ConnectionStatusHeader from './pages/header/ConnectionStatusHeader'; import { NavigationCollapseProvider, useNavigationCollapse } from "@/context/NavigationCollapseContext"; +import { lazyWithRetry } from '@/lib/lazyWithRetry'; // Lazy-loaded page components (route-level code splitting) -const Setup = lazy(() => import('./pages/setup/Setup')); -const Settings = lazy(() => import('./pages/settings/Settings')); -const PasswordReset = lazy(() => import('./pages/auth/PasswordReset')); -const PasswordResetConfirm = lazy(() => import('./pages/auth/PasswordResetConfirm')); -const Logs = lazy(() => import('./pages/logs/Logs')); -const Blocklists = lazy(() => import('./pages/blocklists/Blocklists')); -const CustomRules = lazy(() => import('./pages/custom_rules/CustomRules')); -const Login = lazy(() => import('./pages/auth/Login')); -const Signup = lazy(() => import('./pages/auth/Signup')); -const TermsOfService = lazy(() => import('./pages/legal/TermsOfService')); -const PrivacyPolicy = lazy(() => import("./pages/legal/PrivacyPolicy")); -const FAQ = lazy(() => import("./pages/legal/FAQ")); -const NotFound = lazy(() => import("./pages/NotFound")); -const AccountPreferences = lazy(() => import('@/pages/account_preferences/Account')); -const MobileconfigPage = lazy(() => import('@/pages/mobileconfig/MobileconfigPage')); -const MobileconfigDownload = lazy(() => import('@/pages/mobileconfig/MobileconfigDownload')); -const HomeScreen = lazy(() => import('./pages/home/HomeScreen')); +// Uses lazyWithRetry to handle HMR failures gracefully +const Setup = lazyWithRetry(() => import('./pages/setup/Setup')); +const Settings = lazyWithRetry(() => import('./pages/settings/Settings')); +const PasswordReset = lazyWithRetry(() => import('./pages/auth/PasswordReset')); +const PasswordResetConfirm = lazyWithRetry(() => import('./pages/auth/PasswordResetConfirm')); +const Logs = lazyWithRetry(() => import('./pages/logs/Logs')); +const Blocklists = lazyWithRetry(() => import('./pages/blocklists/Blocklists')); +const CustomRules = lazyWithRetry(() => import('./pages/custom_rules/CustomRules')); +const Login = lazyWithRetry(() => import('./pages/auth/Login')); +const Signup = lazyWithRetry(() => import('./pages/auth/Signup')); +const TermsOfService = lazyWithRetry(() => import('./pages/legal/TermsOfService')); +const PrivacyPolicy = lazyWithRetry(() => import("./pages/legal/PrivacyPolicy")); +const FAQ = lazyWithRetry(() => import("./pages/legal/FAQ")); +const NotFound = lazyWithRetry(() => import("./pages/NotFound")); +const AccountPreferences = lazyWithRetry(() => import('@/pages/account_preferences/Account')); +const MobileconfigPage = lazyWithRetry(() => import('@/pages/mobileconfig/MobileconfigPage')); +const MobileconfigDownload = lazyWithRetry(() => import('@/pages/mobileconfig/MobileconfigDownload')); +const HomeScreen = lazyWithRetry(() => import('./pages/home/HomeScreen')); import { createBrowserRouter, RouterProvider, Navigate, Outlet, useLoaderData, useLocation, useNavigate, redirect } from 'react-router-dom'; import { ThemeProvider } from "@/components/theme-provider" From b323ef19aa5f668403145f59cb121484f5f6113e Mon Sep 17 00:00:00 2001 From: Maciek Date: Thu, 5 Feb 2026 12:04:16 +0100 Subject: [PATCH 6/7] chore(frontend): Add skeletion loading Signed-off-by: Maciek --- .../account_preferences/PasskeySettings.tsx | 21 ++++++++++--- .../blocklists/ServicesContentSection.tsx | 30 +++++++++++++++++-- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/app/src/pages/account_preferences/PasskeySettings.tsx b/app/src/pages/account_preferences/PasskeySettings.tsx index 37343035..5e168ccc 100644 --- a/app/src/pages/account_preferences/PasskeySettings.tsx +++ b/app/src/pages/account_preferences/PasskeySettings.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; import { AlertCircle, Key, Plus, Trash2 } from "lucide-react"; import { toast } from "sonner"; import api from "@/api/api"; @@ -162,10 +163,22 @@ export default function PasskeySettings() { {/* Existing passkeys list */} {loading ? ( -
-

- Loading passkeys... -

+
+ {Array.from({ length: 2 }).map((_, i) => ( +
+
+ +
+ + +
+
+ +
+ ))}
) : passkeys.length === 0 ? (
diff --git a/app/src/pages/blocklists/ServicesContentSection.tsx b/app/src/pages/blocklists/ServicesContentSection.tsx index 39ef6cef..383b97c9 100644 --- a/app/src/pages/blocklists/ServicesContentSection.tsx +++ b/app/src/pages/blocklists/ServicesContentSection.tsx @@ -1,5 +1,6 @@ import { type JSX, useEffect, useMemo, useState } from "react"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { Skeleton } from "@/components/ui/skeleton"; import api from "@/api/api"; import { useAppStore } from "@/store/general"; import { toast } from "sonner"; @@ -107,9 +108,32 @@ export default function ServicesContentSection(): JSX.Element {
{loading ? ( -
- Loading services... -
+ <> + {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+ + +
+ +
+
+ + + +
+
+
+ +
+
+ ))} + ) : services.length === 0 ? (
No services available. From 805b5fbb838bfd9120a27a1f0fe38a9432c97c14 Mon Sep 17 00:00:00 2001 From: Maciek Date: Thu, 5 Feb 2026 12:47:51 +0100 Subject: [PATCH 7/7] fix(frontend): Add missing lazy loading retry script Signed-off-by: Maciek --- app/src/lib/lazyWithRetry.ts | 65 ++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 app/src/lib/lazyWithRetry.ts diff --git a/app/src/lib/lazyWithRetry.ts b/app/src/lib/lazyWithRetry.ts new file mode 100644 index 00000000..50e52e4f --- /dev/null +++ b/app/src/lib/lazyWithRetry.ts @@ -0,0 +1,65 @@ +import { lazy, ComponentType } from 'react'; + +/** + * Wrapper around React.lazy that retries failed dynamic imports. + * + * During Vite HMR, dynamic imports can fail when modules are invalidated. + * This wrapper catches those failures and either retries the import + * or forces a page reload to get fresh modules. + */ +export function lazyWithRetry>( + importFn: () => Promise<{ default: T }>, + retries = 2 +): React.LazyExoticComponent { + return lazy(async () => { + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= retries; attempt++) { + try { + return await importFn(); + } catch (error) { + lastError = error as Error; + + // Check if this is a dynamic import failure (common during HMR) + const isChunkError = + error instanceof Error && + (error.message.includes('dynamically imported module') || + error.message.includes('Failed to fetch') || + error.message.includes('Loading chunk') || + error.message.includes('Loading CSS chunk')); + + if (!isChunkError) { + // Not a chunk loading error, throw immediately + throw error; + } + + // Wait a bit before retrying (exponential backoff) + if (attempt < retries) { + await new Promise(resolve => setTimeout(resolve, 100 * Math.pow(2, attempt))); + } + } + } + + // All retries failed - force page reload to get fresh modules + // This handles deployment mismatches where old HTML references non-existent chunks + if (typeof window !== 'undefined') { + // Only reload if we haven't recently reloaded to prevent infinite loops + const lastReloadKey = '__lazy_import_reload_timestamp__'; + const lastReload = sessionStorage.getItem(lastReloadKey); + const now = Date.now(); + + if (!lastReload || now - parseInt(lastReload, 10) > 10000) { + sessionStorage.setItem(lastReloadKey, now.toString()); + // Cache-busting reload: add timestamp to bypass browser/CDN cache + // This ensures we fetch fresh index.html with correct chunk references + const url = window.location.href.split('?')[0]; + window.location.href = `${url}?_=${now}`; + // Return a never-resolving promise to keep Suspense showing fallback during redirect + return new Promise(() => {}); + } + } + + // If we can't reload or it didn't help, throw the error + throw lastError; + }); +}