experiments in a post-browser web
10
fork

Configure Feed

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

fix(electron): guard deferred thumbnail capture against destroyed webContents

Surfaced 2026-04-26 while switching peeks: stderr showed `Unhandled
error: TypeError: Object has been destroyed at Timeout._onTimeout` from
the main process. Three sites in `backend/electron/ipc.ts` (lines 1338,
1542, 1797) deferred `captureThumbnail(win.webContents, …)` 1.5s after
`did-stop-loading`. If the window/webContents was destroyed inside that
window — easy to hit by closing a peek immediately after open — the
setTimeout body dereferenced an already-destroyed BrowserWindow and
threw an unhandled exception.

Fix: each setTimeout body now checks `isDestroyed()` first.

New `tests/desktop/window-thumbnail-close-race.spec.ts` opens a
non-canvas web window and closes it inside the 1.5s thumbnail debounce.

+71 -2
+4
CHANGELOG.md
··· 15 15 16 16 ## 2026-04-20 17 17 18 + Desktop - thumbnail close-race fix 19 + - [x] Fix `TypeError: Object has been destroyed at Timeout._onTimeout` from `backend/electron/ipc.ts` thumbnail capture sites. Three `setTimeout` bodies (lines 1338, 1542, 1797) called `captureThumbnail(win.webContents,…)` 1.5s after `did-stop-loading`; if the window or its webContents was destroyed in that window (e.g. user closed a peek immediately), accessing `win.webContents` threw an unhandled exception in main. Now each setTimeout body bails out via `isDestroyed()` first. 20 + - [x] New `tests/desktop/window-thumbnail-close-race.spec.ts` opens a non-canvas web window and closes it inside the 1.5s thumbnail debounce. 21 + 18 22 Desktop - per-feature Settings options accordion 19 23 - [x] Fix Settings → Features Options accordion never rendering: `refreshFeaturesList` built each feature's `manifest` object without a `schemas` field (the registry's flat entry shape doesn't carry it), so the accordion gate `manifest.schemas && (manifest.schemas.prefs || manifest.schemas.item)` was always false. Now fetches each feature's settings-schema JSON in parallel via `api.features.settingsSchema(id)` and populates `manifest.{schemas,storageKeys,defaults}` from it. 20 24 - [x] Fix per-item card expand/collapse arrows missing inside the Options accordion (Peeks/Slides item cards rendered without ▶/▼). The `.item-card.no-collapse .item-card-title::before { content: none }` rule was a descendant selector — it cascaded into nested per-item card titles. Scoped to direct-child via `.item-card.no-collapse > .item-card-header .item-card-title::before`.
+5
backend/electron/ipc.ts
··· 1334 1334 const guestUrl = guestWebContents.getURL(); 1335 1335 if (guestUrl && (guestUrl.startsWith('http://') || guestUrl.startsWith('https://'))) { 1336 1336 setTimeout(() => { 1337 + if (guestWebContents.isDestroyed()) return; 1337 1338 captureThumbnail(guestWebContents, guestUrl).then((hash) => { 1338 1339 if (hash) { 1339 1340 try { updateItemThumbnail(guestUrl, hash); } catch (e) { ··· 1538 1539 const guestUrl = guestWebContents.getURL(); 1539 1540 if (guestUrl && (guestUrl.startsWith('http://') || guestUrl.startsWith('https://'))) { 1540 1541 setTimeout(() => { 1542 + if (guestWebContents.isDestroyed()) return; 1541 1543 captureThumbnail(guestWebContents, guestUrl).then((hash) => { 1542 1544 if (hash) { 1543 1545 try { updateItemThumbnail(guestUrl, hash); } catch (e) { ··· 1792 1794 const pageUrl = win.webContents.getURL() || url; 1793 1795 if (pageUrl && (pageUrl.startsWith('http://') || pageUrl.startsWith('https://'))) { 1794 1796 setTimeout(() => { 1797 + // Window may have been closed during the 1.5s delay 1798 + // (e.g. user closed a peek immediately after open). 1799 + if (win.isDestroyed() || win.webContents.isDestroyed()) return; 1795 1800 captureThumbnail(win.webContents, pageUrl).then((hash) => { 1796 1801 if (hash) { 1797 1802 try { updateItemThumbnail(pageUrl, hash); } catch (e) {
+6 -2
docs/feed.xml
··· 4 4 <title>Peek Changelog</title> 5 5 <link>https://tangled.org/burrito.space/peek</link> 6 6 <description>Recent changes to Peek</description> 7 - <lastBuildDate>Mon, 27 Apr 2026 10:08:51 GMT</lastBuildDate> 7 + <lastBuildDate>Mon, 27 Apr 2026 10:09:33 GMT</lastBuildDate> 8 8 <generator>changelog-to-rss.js</generator> 9 9 <atom:link href="https://tangled.org/burrito.space/peek/raw/main/docs/feed.xml" rel="self" type="application/rss+xml"/> 10 10 <item> ··· 12 12 <link>https://tangled.org/burrito.space/peek/blob/main/CHANGELOG.md#2026-04-20</link> 13 13 <guid isPermaLink="false">https://tangled.org/burrito.space/peek#2026-04-20</guid> 14 14 <pubDate>Mon, 20 Apr 2026 12:00:00 GMT</pubDate> 15 - <description>Desktop - per-feature Settings options accordion 15 + <description>Desktop - thumbnail close-race fix 16 + - Fix TypeError: Object has been destroyed at Timeout._onTimeout from backend/electron/ipc.ts thumbnail capture sites. Three setTimeout bodies (lines 1338, 1542, 1797) called captureThumbnail(win.webContents,…) 1.5s after did-stop-loading; if the window or its webContents was destroyed in that window (e.g. user closed a peek immediately), accessing win.webContents threw an unhandled exception in main. Now each setTimeout body bails out via isDestroyed() first. 17 + - New tests/desktop/window-thumbnail-close-race.spec.ts opens a non-canvas web window and closes it inside the 1.5s thumbnail debounce. 18 + 19 + Desktop - per-feature Settings options accordion 16 20 - Fix Settings → Features Options accordion never rendering: refreshFeaturesList built each feature&apos;s manifest object without a schemas field (the registry&apos;s flat entry shape doesn&apos;t carry it), so the accordion gate manifest.schemas &amp;&amp; (manifest.schemas.prefs || manifest.schemas.item) was always false. Now fetches each feature&apos;s settings-schema JSON in parallel via api.features.settingsSchema(id) and populates manifest.{schemas,storageKeys,defaults} from it. 17 21 - Fix per-item card expand/collapse arrows missing inside the Options accordion (Peeks/Slides item cards rendered without ▶/▼). The .item-card.no-collapse .item-card-title::before { content: none } rule was a descendant selector — it cascaded into nested per-item card titles. Scoped to direct-child via .item-card.no-collapse &gt; .item-card-header .item-card-title::before. 18 22 - New tests/desktop/feature-options.spec.ts — opens Settings → Features, expands Peeks Options accordion, asserts a .form-section renders + the inner peek card&apos;s ::before content is ▶ or ▼.
+56
tests/desktop/window-thumbnail-close-race.spec.ts
··· 1 + /** 2 + * Regression: opening a peek-style web-page window and closing it within 3 + * 1.5s of did-stop-loading used to crash the main process with 4 + * `TypeError: Object has been destroyed at Timeout._onTimeout` because 5 + * the deferred `captureThumbnail` body dereferenced `win.webContents` on 6 + * an already-destroyed BrowserWindow. 7 + * 8 + * Fix: `setTimeout` body now bails out via `isDestroyed()` first. 9 + */ 10 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 11 + import { Page } from '@playwright/test'; 12 + import { createPerDescribeApp } from '../helpers/test-app'; 13 + 14 + test.describe('window thumbnail close race @desktop', () => { 15 + let app: DesktopApp; 16 + let bgWindow: Page; 17 + 18 + test.beforeAll(async () => { 19 + ({ app, bgWindow } = await createPerDescribeApp('thumb-close-race')); 20 + }); 21 + 22 + test.afterAll(async () => { 23 + if (app) await app.close(); 24 + }); 25 + 26 + test('closing a non-canvas web window before 1.5s thumbnail timer does not crash main', async () => { 27 + // Open as a modal/quick-view (peek-style) so isModalOrQuickView is true 28 + // — this is the non-canvas path that hit the 1797 setTimeout. 29 + const open = await bgWindow.evaluate(async () => { 30 + return await (window as any).app.window.open('https://example.com/', { 31 + modal: true, 32 + width: 600, 33 + height: 400, 34 + role: 'quick-view', 35 + key: 'thumb-race-test', 36 + }); 37 + }); 38 + expect(open.success).toBe(true); 39 + const winId = open.id; 40 + 41 + // Close immediately, well under the 1.5s thumbnail debounce. 42 + await bgWindow.evaluate(async (id: number) => { 43 + return await (window as any).app.window.close(id); 44 + }, winId); 45 + 46 + // Wait past the 1.5s setTimeout window. If the bug were present, the main 47 + // process would throw an unhandled exception during this period. 48 + await new Promise((r) => setTimeout(r, 2500)); 49 + 50 + // Sanity: the IPC bridge is still functional (main didn't crash). 51 + const stillAlive = await bgWindow.evaluate(async () => { 52 + return await (window as any).app.window.list?.() ?? true; 53 + }); 54 + expect(stillAlive).toBeTruthy(); 55 + }); 56 + });