Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 24 additions & 21 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
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';
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"
Expand Down Expand Up @@ -370,7 +372,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 (
<>
Expand Down Expand Up @@ -426,11 +429,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'
}}
>
Expand Down
159 changes: 159 additions & 0 deletions app/src/__tests__/e2e/layout/content-centering.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
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();

// 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);
}
});
});
2 changes: 0 additions & 2 deletions app/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,6 @@ a:hover {

body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
overscroll-behavior: contain;
Expand Down
65 changes: 65 additions & 0 deletions app/src/lib/lazyWithRetry.ts
Original file line number Diff line number Diff line change
@@ -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<T extends ComponentType<unknown>>(
importFn: () => Promise<{ default: T }>,
retries = 2
): React.LazyExoticComponent<T> {
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;
});
}
21 changes: 17 additions & 4 deletions app/src/pages/account_preferences/PasskeySettings.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -162,10 +163,22 @@ export default function PasskeySettings() {

{/* Existing passkeys list */}
{loading ? (
<div className="text-center py-4">
<p className="text-sm text-[var(--tailwind-colors-slate-200)]">
Loading passkeys...
</p>
<div className="space-y-3">
{Array.from({ length: 2 }).map((_, i) => (
<div
key={i}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between p-4 border border-[var(--tailwind-colors-slate-600)] rounded-lg w-full gap-3"
>
<div className="flex items-center gap-3 w-full sm:w-auto">
<Skeleton className="h-8 w-8 rounded-full flex-shrink-0" />
<div className="space-y-1.5">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
<Skeleton className="h-9 w-20 rounded-md" />
</div>
))}
</div>
) : passkeys.length === 0 ? (
<div className="flex flex-col items-center justify-center py-3 w-full">
Expand Down
2 changes: 1 addition & 1 deletion app/src/pages/blocklists/MainContentSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/}
<ScrollArea className="w-full max-h-[calc(100vh-var(--app-header-stack,120px)-200px)] md:max-h-[unset]">
<ScrollArea className="w-full">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-6 pb-8">
{loading ? (
<>
Expand Down
2 changes: 1 addition & 1 deletion app/src/pages/blocklists/ServiceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const ServiceCard: React.FC<ServiceCardProps> = ({
return (
<Card
data-testid="service-card"
className="bg-[var(--variable-collection-card)] p-3 border-none rounded-[var(--tailwind-primitives-border-radius-rounded)] shadow-sm flex flex-col justify-between h-[196px] lg:h-[180px] w-full overflow-hidden"
className="bg-transparent dark:bg-[var(--variable-collection-surface)] p-3 border border-[var(--tailwind-colors-slate-light-300)] dark:border-transparent rounded-[var(--tailwind-primitives-border-radius-rounded)] shadow-sm flex flex-col justify-between h-[196px] lg:h-[180px] w-full overflow-hidden"
>
<CardContent className="p-0 flex flex-col justify-between h-full">
<div className="flex flex-col gap-1">
Expand Down
Loading
Loading