experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(page): show branded error overlay when a page fails to load

User-reported: navigating to a URL that fails (DNS failure, server not
found, connection refused) used to leave a blank white page forever
with no UI feedback - example `http://www.metikmusic.com/`. The
existing `did-fail-load` handler only logged to console and stopped
loading.

Now when did-fail-load fires for the main frame (errorCode != -3
ABORTED), the page tile renders a Peek-styled overlay with:
- the failed URL
- the human-readable error reason + code (e.g. ERR_NAME_NOT_RESOLVED)
- a Retry button that re-loads the URL
- a Close window button

The overlay clears automatically when did-navigate fires for any non-
`chrome-error://` URL (covers Retry success, address-bar nav, pubsub
`page:navigate` from pagestream).

Notable details:
- Webview did-fail-load events expose `validatedURL`, not `url`. The
pre-existing handler used `e.url` (which is undefined on the DOM
event) - fixed both the new code and the legacy lastFailedNavUrl
tracking by reading `e.validatedURL || e.url || ''`.
- Chromium fires a SECOND did-fail-load for its internal
`chrome-error://chromewebdata/` page navigation with an empty URL.
showLoadErrorOverlay() ignores those so the real error data isn't
wiped.

Test: `tests/desktop/page-load-failure.spec.ts` - 4 cases (DNS failure,
connection refused, retry-success, no overlay on happy path). All
green; existing redirect + navbar suites unaffected.

+338 -2
+82
app/page/index.html
··· 1260 1260 opacity: 0.9; 1261 1261 } 1262 1262 1263 + /* --- Load error overlay (server not found, DNS failure, etc.) --- */ 1264 + 1265 + .load-error-overlay { 1266 + position: fixed; 1267 + top: 0; 1268 + left: 0; 1269 + right: 0; 1270 + bottom: 0; 1271 + z-index: 9998; 1272 + display: flex; 1273 + align-items: center; 1274 + justify-content: center; 1275 + background: var(--theme-bg, #1e1e1e); 1276 + padding: 24px; 1277 + } 1278 + 1279 + .load-error-card { 1280 + max-width: 480px; 1281 + width: 100%; 1282 + text-align: center; 1283 + color: var(--theme-text, #e0e0e0); 1284 + font-family: var(--theme-font-sans, system-ui, -apple-system, BlinkMacSystemFont, sans-serif); 1285 + } 1286 + 1287 + .load-error-icon { 1288 + font-size: 32px; 1289 + margin-bottom: 12px; 1290 + opacity: 0.6; 1291 + } 1292 + 1293 + .load-error-title { 1294 + font-size: 18px; 1295 + font-weight: 600; 1296 + margin-bottom: 8px; 1297 + } 1298 + 1299 + .load-error-reason { 1300 + font-size: 13px; 1301 + color: var(--theme-text-secondary, #aaa); 1302 + margin-bottom: 12px; 1303 + } 1304 + 1305 + .load-error-url { 1306 + display: block; 1307 + font-family: var(--theme-font-mono, ui-monospace, SFMono-Regular, monospace); 1308 + font-size: 12px; 1309 + color: var(--theme-text-secondary, #aaa); 1310 + background: color-mix(in srgb, var(--theme-bg-tertiary, #2a2a2a) 60%, transparent); 1311 + padding: 8px 12px; 1312 + border-radius: 6px; 1313 + margin-bottom: 20px; 1314 + word-break: break-all; 1315 + user-select: text; 1316 + } 1317 + 1318 + .load-error-actions { 1319 + display: flex; 1320 + gap: 8px; 1321 + justify-content: center; 1322 + } 1323 + 1324 + .load-error-actions button { 1325 + background: var(--theme-accent, #007aff); 1326 + color: #fff; 1327 + border: none; 1328 + padding: 8px 20px; 1329 + border-radius: 6px; 1330 + font-size: 13px; 1331 + cursor: pointer; 1332 + font-family: inherit; 1333 + } 1334 + 1335 + .load-error-actions button.load-error-close { 1336 + background: color-mix(in srgb, var(--theme-bg-tertiary, #2a2a2a) 80%, transparent); 1337 + color: var(--theme-text, #e0e0e0); 1338 + border: 1px solid color-mix(in srgb, var(--theme-border, rgba(255,255,255,0.1)) 60%, transparent); 1339 + } 1340 + 1341 + .load-error-actions button:hover { 1342 + opacity: 0.9; 1343 + } 1344 + 1263 1345 /* --- Find bar (top-right overlay for find-in-page) --- */ 1264 1346 1265 1347 .find-bar {
+78 -2
app/page/page.js
··· 1836 1836 navbar.setUrl(e.url); 1837 1837 updateState(); 1838 1838 1839 + // Clear the load-error overlay when we successfully navigate to any 1840 + // real URL (not Chromium's internal chrome-error:// page). 1841 + if (e.url && !e.url.startsWith('chrome-error://')) { 1842 + clearLoadErrorOverlay(); 1843 + } 1844 + 1839 1845 // Update OAuth state (Level 2) 1840 1846 const wasOAuth = isOnOAuthPage; 1841 1847 isOnOAuthPage = isOAuthUrl(e.url); ··· 1861 1867 } 1862 1868 }); 1863 1869 1870 + function clearLoadErrorOverlay() { 1871 + const existing = document.querySelector('.load-error-overlay'); 1872 + if (existing) existing.remove(); 1873 + } 1874 + 1875 + function showLoadErrorOverlay(failedUrl, errorCode, errorDescription) { 1876 + // Chromium fires a second did-fail-load for its internal error page 1877 + // navigation (`chrome-error://...`) with an empty URL. Ignore those — 1878 + // they would wipe the real error data we already rendered. 1879 + if (!failedUrl || failedUrl.startsWith('chrome-error://')) return; 1880 + clearLoadErrorOverlay(); 1881 + 1882 + const overlay = document.createElement('div'); 1883 + overlay.className = 'load-error-overlay'; 1884 + 1885 + const card = document.createElement('div'); 1886 + card.className = 'load-error-card'; 1887 + 1888 + const icon = document.createElement('div'); 1889 + icon.className = 'load-error-icon'; 1890 + icon.textContent = '⚠'; 1891 + 1892 + const title = document.createElement('div'); 1893 + title.className = 'load-error-title'; 1894 + title.textContent = 'This page didn’t load'; 1895 + 1896 + const reason = document.createElement('div'); 1897 + reason.className = 'load-error-reason'; 1898 + reason.textContent = errorDescription 1899 + ? `${errorDescription} (${errorCode})` 1900 + : `Load failed (${errorCode})`; 1901 + 1902 + const url = document.createElement('div'); 1903 + url.className = 'load-error-url'; 1904 + url.textContent = failedUrl; 1905 + 1906 + const actions = document.createElement('div'); 1907 + actions.className = 'load-error-actions'; 1908 + 1909 + const retry = document.createElement('button'); 1910 + retry.className = 'load-error-retry'; 1911 + retry.textContent = 'Retry'; 1912 + retry.addEventListener('click', () => { 1913 + clearLoadErrorOverlay(); 1914 + try { webview.loadURL(failedUrl); } catch (_) {} 1915 + }); 1916 + 1917 + const close = document.createElement('button'); 1918 + close.className = 'load-error-close'; 1919 + close.textContent = 'Close window'; 1920 + close.addEventListener('click', () => { 1921 + try { window.close(); } catch (_) {} 1922 + }); 1923 + 1924 + actions.appendChild(retry); 1925 + actions.appendChild(close); 1926 + card.appendChild(icon); 1927 + card.appendChild(title); 1928 + card.appendChild(reason); 1929 + card.appendChild(url); 1930 + card.appendChild(actions); 1931 + overlay.appendChild(card); 1932 + document.body.appendChild(overlay); 1933 + } 1934 + 1864 1935 webview.addEventListener('did-fail-load', (e) => { 1865 1936 if (!e.isMainFrame) return; 1866 1937 ··· 1884 1955 loadingLifecycle.stopLoading(); 1885 1956 return; 1886 1957 } 1887 - console.error('[page] Load failed:', e.errorCode, e.errorDescription, e.url); 1958 + // Webview did-fail-load uses `validatedURL` (per Electron docs); `e.url` is 1959 + // undefined on the DOM event. Fall back through both for safety. 1960 + const failedUrl = e.validatedURL || e.url || ''; 1961 + console.error('[page] Load failed:', e.errorCode, e.errorDescription, failedUrl); 1888 1962 // Track this URL as failed to prevent meta-refresh loops. 1889 1963 // If the previous page had <meta http-equiv="refresh"> pointing to this URL, 1890 1964 // the webview may re-display the previous page and re-fire the refresh. 1891 - lastFailedNavUrl = e.url; 1965 + lastFailedNavUrl = failedUrl; 1892 1966 loadingLifecycle.stopLoading(); 1893 1967 // Stop the webview to kill any pending meta-refresh from the previous page's content. 1894 1968 // Without this, a meta-refresh loop can oscillate startLoading/stopLoading indefinitely. 1895 1969 try { webview.stop(); } catch (_) {} 1970 + showLoadErrorOverlay(failedUrl, e.errorCode, e.errorDescription); 1896 1971 }); 1972 + 1897 1973 1898 1974 // On dom-ready: detect page background color. 1899 1975 // Background detection prevents the white flash when loading pages in dark mode.
+178
tests/desktop/page-load-failure.spec.ts
··· 1 + /** 2 + * Page Host Load Failure Tests 3 + * 4 + * Regression: navigating to a URL that fails to load (DNS failure, server 5 + * not found, connection refused) used to leave a blank white page forever 6 + * with no UI feedback. The fix is a Peek-branded error overlay rendered 7 + * inside the page host when did-fail-load fires for the main frame. 8 + * 9 + * Run with: 10 + * yarn test:grep "Page Host Load Failure" 11 + */ 12 + 13 + import { test, expect, DesktopApp, getSharedApp, closeSharedApp } from '../fixtures/desktop-app'; 14 + import { Page } from '@playwright/test'; 15 + import { waitForExtensionsReady } from '../helpers/window-utils'; 16 + import http from 'http'; 17 + 18 + let sharedApp: DesktopApp; 19 + let sharedBgWindow: Page; 20 + let server: http.Server; 21 + let serverPort: number; 22 + 23 + test.beforeAll(async () => { 24 + sharedApp = await getSharedApp(); 25 + sharedBgWindow = await sharedApp.getBackgroundWindow(); 26 + await waitForExtensionsReady(sharedBgWindow); 27 + 28 + await new Promise<void>((resolve) => { 29 + server = http.createServer((req, res) => { 30 + if (req.url === '/ok') { 31 + res.writeHead(200, { 'Content-Type': 'text/html' }); 32 + res.end('<!DOCTYPE html><html><body><h1 id="title">OK</h1></body></html>'); 33 + } else { 34 + res.writeHead(404); 35 + res.end('Not found'); 36 + } 37 + }); 38 + server.listen(0, '127.0.0.1', () => { 39 + const addr = server.address(); 40 + serverPort = typeof addr === 'object' && addr ? addr.port : 0; 41 + resolve(); 42 + }); 43 + }); 44 + }); 45 + 46 + test.afterAll(async () => { 47 + if (server) server.close(); 48 + await closeSharedApp(); 49 + }); 50 + 51 + async function openCanvasPage( 52 + bgWindow: Page, 53 + url: string, 54 + ): Promise<{ pageWindow: Page; windowId: number }> { 55 + const result = await bgWindow.evaluate(async (targetUrl: string) => { 56 + return await (window as any).app.window.open(targetUrl, { 57 + width: 800, 58 + height: 600, 59 + }); 60 + }, url); 61 + expect(result.success).toBe(true); 62 + const pageWindow = await sharedApp.getWindow('page/index.html', 15000); 63 + expect(pageWindow).toBeTruthy(); 64 + return { pageWindow, windowId: result.id }; 65 + } 66 + 67 + async function closeWindow(bgWindow: Page, windowId: number) { 68 + await bgWindow.evaluate(async (id: number) => { 69 + return await (window as any).app.window.close(id); 70 + }, windowId); 71 + } 72 + 73 + async function waitForErrorOverlay(pageWindow: Page, timeout = 15000) { 74 + await pageWindow.waitForFunction( 75 + () => !!document.querySelector('.load-error-overlay'), 76 + undefined, 77 + { timeout }, 78 + ); 79 + } 80 + 81 + test.describe('Page Host Load Failure @desktop', () => { 82 + test('DNS failure: shows branded error overlay with URL and retry button', async () => { 83 + // .invalid is reserved by RFC 2606 — guaranteed to never resolve. 84 + const badUrl = 'http://nonexistent-host-xyz123.invalid/'; 85 + const { pageWindow, windowId } = await openCanvasPage(sharedBgWindow, badUrl); 86 + 87 + try { 88 + await waitForErrorOverlay(pageWindow); 89 + 90 + const overlay = await pageWindow.evaluate(() => { 91 + const el = document.querySelector('.load-error-overlay'); 92 + if (!el) return null; 93 + return { 94 + urlText: el.querySelector('.load-error-url')?.textContent || '', 95 + reasonText: el.querySelector('.load-error-reason')?.textContent || '', 96 + hasRetry: !!el.querySelector('.load-error-retry'), 97 + hasClose: !!el.querySelector('.load-error-close'), 98 + }; 99 + }); 100 + 101 + expect(overlay).toBeTruthy(); 102 + expect(overlay!.urlText).toContain('nonexistent-host-xyz123.invalid'); 103 + // Some Chromium build of the error mentions DNS / NAME_NOT_RESOLVED 104 + expect(overlay!.reasonText.toLowerCase()).toMatch(/name|resolve|dns|not.found|failed/); 105 + expect(overlay!.hasRetry).toBe(true); 106 + expect(overlay!.hasClose).toBe(true); 107 + } finally { 108 + await closeWindow(sharedBgWindow, windowId); 109 + } 110 + }); 111 + 112 + test('Connection refused: shows branded error overlay', async () => { 113 + // Port 1 is reserved and nothing listens — connection refused. 114 + const badUrl = 'http://127.0.0.1:1/'; 115 + const { pageWindow, windowId } = await openCanvasPage(sharedBgWindow, badUrl); 116 + 117 + try { 118 + await waitForErrorOverlay(pageWindow); 119 + 120 + const urlText = await pageWindow.evaluate(() => { 121 + return document.querySelector('.load-error-url')?.textContent || ''; 122 + }); 123 + expect(urlText).toContain('127.0.0.1:1'); 124 + } finally { 125 + await closeWindow(sharedBgWindow, windowId); 126 + } 127 + }); 128 + 129 + test('Successful load after error: clicking Retry on a now-reachable URL clears the overlay', async () => { 130 + // Start by hitting a port we can later make reachable: instead of stopping a server, 131 + // we navigate first to a bad URL, then call the address bar with a good one. 132 + const badUrl = 'http://nonexistent-host-xyz456.invalid/'; 133 + const goodUrl = `http://127.0.0.1:${serverPort}/ok`; 134 + const { pageWindow, windowId } = await openCanvasPage(sharedBgWindow, badUrl); 135 + 136 + try { 137 + await waitForErrorOverlay(pageWindow); 138 + 139 + // Programmatic navigation to a working URL — the overlay should clear 140 + // once load succeeds. 141 + await pageWindow.evaluate((url: string) => { 142 + const webview = document.getElementById('content') as any; 143 + webview.loadURL(url); 144 + }, goodUrl); 145 + 146 + await pageWindow.waitForFunction( 147 + () => !document.querySelector('.load-error-overlay'), 148 + undefined, 149 + { timeout: 15000 }, 150 + ); 151 + } finally { 152 + await closeWindow(sharedBgWindow, windowId); 153 + } 154 + }); 155 + 156 + test('Successful initial load: no error overlay appears', async () => { 157 + const goodUrl = `http://127.0.0.1:${serverPort}/ok`; 158 + const { pageWindow, windowId } = await openCanvasPage(sharedBgWindow, goodUrl); 159 + 160 + try { 161 + await pageWindow.waitForFunction( 162 + () => { 163 + const webview = document.getElementById('content'); 164 + return webview && webview.classList.contains('loaded'); 165 + }, 166 + undefined, 167 + { timeout: 15000 }, 168 + ); 169 + 170 + const hasOverlay = await pageWindow.evaluate( 171 + () => !!document.querySelector('.load-error-overlay'), 172 + ); 173 + expect(hasOverlay).toBe(false); 174 + } finally { 175 + await closeWindow(sharedBgWindow, windowId); 176 + } 177 + }); 178 + });