Skip to content

Commit 2cd6bdf

Browse files
Offline/UX: real connectivity probe, anti-hang on load, remove Documentation from app nav
- Connectivity is now decided by actively probing the API /health (5s timeout) rather than trusting navigator.onLine, which a misconfigured NIC/captive portal can get wrong. Re-probed on navigator events and every 25s; offline→online transition flushes the queue. - Auth check is time-bounded (8s) so a stuck/slow network can't leave the app stuck on 'loading'; it also distinguishes a 401 (logged out → no user) from a network error/timeout (offline → keep the cached user). Service worker navigations time out at 4s and fall back to the cached page, so slow mobile networks don't hang. - Removed the Documentation link from the signed-in sidebar (still public at /docs, linked from /legal and the README).
1 parent 491e3e0 commit 2cd6bdf

4 files changed

Lines changed: 60 additions & 33 deletions

File tree

apps/web/public/sw.js

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,21 +50,24 @@ self.addEventListener('fetch', (event) => {
5050
return;
5151
}
5252

53-
// Page navigations: network-first, falling back to the cached page or the app shell.
53+
// Page navigations: network-first with a short timeout (so a slow mobile network falls back to
54+
// the cached page instead of hanging), then the cached page or the app shell.
5455
if (req.mode === 'navigate') {
5556
event.respondWith(
56-
fetch(req)
57-
.then((res) => {
57+
Promise.race([
58+
fetch(req).then((res) => {
5859
const copy = res.clone();
5960
caches.open(SHELL).then((c) => c.put(req, copy)).catch(() => {});
6061
return res;
61-
})
62-
.catch(() =>
63-
caches
64-
.match(req)
65-
.then((r) => r || caches.match('/'))
66-
.then((r) => r || Response.error()),
67-
),
62+
}),
63+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 4000)),
64+
]).catch(() =>
65+
caches
66+
.match(req)
67+
.then((r) => r || caches.match('/'))
68+
.then((r) => r || fetch(req))
69+
.catch(() => Response.error()),
70+
),
6871
);
6972
}
7073
});

apps/web/src/app/(app)/layout.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import Link from 'next/link';
44
import { usePathname, useRouter } from 'next/navigation';
55
import { useEffect, useState } from 'react';
6-
import { FolderLock, Share2, Globe, Settings, ShieldCheck, LogOut, Menu, X, Trash2, BookOpen } from 'lucide-react';
6+
import { FolderLock, Share2, Globe, Settings, ShieldCheck, LogOut, Menu, X, Trash2 } from 'lucide-react';
77
import { useAuth } from '@/lib/auth';
88
import { useT } from '@/lib/i18n';
99
import { Wordmark } from '@/components/Wordmark';
@@ -17,7 +17,6 @@ const NAV = [
1717
{ href: '/shares', labelKey: 'nav.shares', icon: Share2 },
1818
{ href: '/remote', labelKey: 'nav.remote', icon: Globe },
1919
{ href: '/trash', labelKey: 'nav.trash', icon: Trash2 },
20-
{ href: '/docs', labelKey: 'nav.docs', icon: BookOpen },
2120
];
2221

2322
export default function AppLayout({ children }: { children: React.ReactNode }) {

apps/web/src/lib/auth.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
88
import type { PublicUser } from '@opencoperlock/shared/client';
9-
import { login as apiLogin, logout as apiLogout, me } from './api';
9+
import { login as apiLogin, logout as apiLogout, me, ApiError } from './api';
1010

1111
interface AuthState {
1212
user: PublicUser | null;
@@ -24,17 +24,22 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
2424

2525
const refresh = useCallback(async () => {
2626
try {
27-
const res = await me();
27+
// Bound the check so a stuck/slow network can never leave the app on "loading" forever.
28+
const res = await Promise.race([
29+
me(),
30+
new Promise<never>((_, reject) => setTimeout(() => reject(new TypeError('auth timeout')), 8000)),
31+
]);
2832
setUser(res.user);
2933
try {
3034
localStorage.setItem('ocl_user', JSON.stringify(res.user));
3135
} catch {
3236
/* storage unavailable */
3337
}
34-
} catch {
35-
// Offline: keep the last-known user so the app still loads (read-only / queue uploads).
38+
} catch (err) {
39+
// A real HTTP response (e.g. 401) means we're logged out → no user. A network failure or
40+
// timeout means we're probably offline → keep the last-known user so the app still loads.
3641
let cached: PublicUser | null = null;
37-
if (typeof navigator !== 'undefined' && !navigator.onLine) {
42+
if (!(err instanceof ApiError)) {
3843
try {
3944
const raw = localStorage.getItem('ocl_user');
4045
if (raw) cached = JSON.parse(raw) as PublicUser;

apps/web/src/lib/offline.tsx

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
99
import { FAST_UPLOAD_FOLDER_NAME } from '@opencoperlock/shared/client';
10-
import { api } from './api';
10+
import { api, API_URL } from './api';
1111
import { useAuth } from './auth';
1212
import { useT } from './i18n';
1313
import { toast } from '@/components/ui/overlays';
@@ -83,32 +83,52 @@ export function OfflineProvider({ children }: { children: React.ReactNode }) {
8383
}
8484
}, [refresh, reloadItems, t]);
8585

86+
// Real connectivity = can we actually reach the API, not just what navigator.onLine claims
87+
// (a misconfigured NIC or captive portal can lie). We probe /health with a short timeout and
88+
// use that as the source of truth; navigator events and a timer just trigger a re-probe.
89+
const onlineRef = useRef(true);
90+
const probe = useCallback(async (): Promise<boolean> => {
91+
try {
92+
const ctrl = new AbortController();
93+
const to = setTimeout(() => ctrl.abort(), 5000);
94+
const res = await fetch(`${API_URL}/health`, { method: 'GET', cache: 'no-store', credentials: 'omit', signal: ctrl.signal });
95+
clearTimeout(to);
96+
return res.ok;
97+
} catch {
98+
return false;
99+
}
100+
}, []);
101+
102+
const recheck = useCallback(async () => {
103+
const ok = await probe();
104+
const was = onlineRef.current;
105+
onlineRef.current = ok;
106+
setOnline(ok);
107+
if (ok && !was) void flush(); // transitioned offline → online
108+
}, [probe, flush]);
109+
86110
useEffect(() => {
87-
setOnline(navigator.onLine);
88111
void reloadItems();
89-
const goOnline = () => {
90-
setOnline(true);
91-
void flush();
92-
};
93-
const goOffline = () => setOnline(false);
94-
window.addEventListener('online', goOnline);
95-
window.addEventListener('offline', goOffline);
96-
// Catch a connection that came back before this mounted.
97-
if (navigator.onLine) void flush();
112+
void recheck();
113+
const onEvt = () => void recheck();
114+
window.addEventListener('online', onEvt);
115+
window.addEventListener('offline', onEvt);
116+
const iv = setInterval(() => void recheck(), 25000);
98117
return () => {
99-
window.removeEventListener('online', goOnline);
100-
window.removeEventListener('offline', goOffline);
118+
window.removeEventListener('online', onEvt);
119+
window.removeEventListener('offline', onEvt);
120+
clearInterval(iv);
101121
};
102-
}, [flush, reloadItems]);
122+
}, [recheck, reloadItems]);
103123

104124
const enqueueFiles = useCallback(
105125
async (files: FileList | File[], folderId: string, folderName: string) => {
106126
for (const file of Array.from(files)) {
107127
await enqueue({ blob: file, name: file.name, type: file.type, folderId, folderName, createdAt: Date.now() });
108128
}
109129
await reloadItems();
110-
// If we're actually online (e.g. flaky), try to flush right away.
111-
if (navigator.onLine) void flush();
130+
// If we're actually reachable, try to flush right away.
131+
if (onlineRef.current) void flush();
112132
},
113133
[reloadItems, flush],
114134
);

0 commit comments

Comments
 (0)