detached 0c81a22

perf(startup): defer webview pool prewarm

Avoid blocking cold-start navigation by moving WebView pool warming to GTK idle after the initial tab begins loading.
commit
0c81a2242ade45b0ff89e51bd7c2f265f415a397
author
brice · · 2025-12-27 07:47 UTC
parent
00c4c81
signature
unsigned
3 files · +72 -5
M CHANGELOG.md +1 -0
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
55 ## [Unreleased]
66
77 ### Fixed
8+- Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency.
89 - README CLI examples: updated to match current commands/flags (`purge --force` only, `logs` usage, `sessions list --limit`, removed deprecated `--dmenu` root flag).
910
1011 ## [0.21.0] - 2025-12-26
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
5 ## [Unreleased]5 ## [Unreleased]
6 6
7 ### Fixed7 ### Fixed
8+- Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency.
8 - README CLI examples: updated to match current commands/flags (`purge --force` only, `logs` usage, `sessions list --limit`, removed deprecated `--dmenu` root flag).9 - README CLI examples: updated to match current commands/flags (`purge --force` only, `logs` usage, `sessions list --limit`, removed deprecated `--dmenu` root flag).
9 10
10 ## [0.21.0] - 2025-12-2611 ## [0.21.0] - 2025-12-26
M internal/infrastructure/webkit/pool.go +60 -0
@@ -8,6 +8,7 @@ import (
88
99 "github.com/bnema/dumber/internal/logging"
1010 "github.com/bnema/puregotk-webkit/webkit"
11+ "github.com/jwijenbergh/puregotk/v4/glib"
1112 )
1213
1314 // FilterApplier applies content filters to a UserContentManager.
@@ -207,6 +208,65 @@ func (p *WebViewPool) Prewarm(ctx context.Context, count int) {
207208 p.prewarmSync(ctx, count)
208209 }
209210
211+// PrewarmAsync schedules WebView creation on the GTK idle loop.
212+// This avoids blocking startup (especially cold-start navigation) while still
213+// warming up WebViews for subsequent tab creation.
214+func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) {
215+ if count <= 0 {
216+ count = p.config.PrewarmCount
217+ }
218+ if count <= 0 || p.closed.Load() {
219+ return
220+ }
221+
222+ p.wg.Add(1)
223+ var doneOnce sync.Once
224+ remaining := count
225+
226+ var schedule func()
227+ schedule = func() {
228+ cb := glib.SourceFunc(func(_ uintptr) bool {
229+ log := logging.FromContext(ctx)
230+
231+ // Stop if pool is closing/closed.
232+ if p.closed.Load() {
233+ doneOnce.Do(p.wg.Done)
234+ return false
235+ }
236+
237+ // Stop early if we reached capacity.
238+ if len(p.pool) >= p.config.MaxSize {
239+ doneOnce.Do(p.wg.Done)
240+ return false
241+ }
242+
243+ wv, err := p.createWebView(ctx)
244+ if err != nil {
245+ log.Warn().Err(err).Msg("failed to prewarm webview")
246+ } else {
247+ select {
248+ case p.pool <- wv:
249+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
250+ default:
251+ wv.Destroy()
252+ }
253+ }
254+
255+ remaining--
256+ if remaining <= 0 {
257+ doneOnce.Do(p.wg.Done)
258+ return false
259+ }
260+
261+ schedule()
262+ return false
263+ })
264+ glib.IdleAdd(&cb, 0)
265+ }
266+
267+ schedule()
268+}
269+
210270 // prewarmSync creates WebViews synchronously.
211271 func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {
212272 log := logging.FromContext(ctx)
@@ -8,6 +8,7 @@ import (
8 8
9 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
10 "github.com/bnema/puregotk-webkit/webkit"10 "github.com/bnema/puregotk-webkit/webkit"
11+ "github.com/jwijenbergh/puregotk/v4/glib"
11 )12 )
12 13
13 // FilterApplier applies content filters to a UserContentManager.14 // FilterApplier applies content filters to a UserContentManager.
@@ -207,6 +208,65 @@ func (p *WebViewPool) Prewarm(ctx context.Context, count int) {
207 p.prewarmSync(ctx, count)208 p.prewarmSync(ctx, count)
208 }209 }
209 210
211+// PrewarmAsync schedules WebView creation on the GTK idle loop.
212+// This avoids blocking startup (especially cold-start navigation) while still
213+// warming up WebViews for subsequent tab creation.
214+func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) {
215+ if count <= 0 {
216+ count = p.config.PrewarmCount
217+ }
218+ if count <= 0 || p.closed.Load() {
219+ return
220+ }
221+
222+ p.wg.Add(1)
223+ var doneOnce sync.Once
224+ remaining := count
225+
226+ var schedule func()
227+ schedule = func() {
228+ cb := glib.SourceFunc(func(_ uintptr) bool {
229+ log := logging.FromContext(ctx)
230+
231+ // Stop if pool is closing/closed.
232+ if p.closed.Load() {
233+ doneOnce.Do(p.wg.Done)
234+ return false
235+ }
236+
237+ // Stop early if we reached capacity.
238+ if len(p.pool) >= p.config.MaxSize {
239+ doneOnce.Do(p.wg.Done)
240+ return false
241+ }
242+
243+ wv, err := p.createWebView(ctx)
244+ if err != nil {
245+ log.Warn().Err(err).Msg("failed to prewarm webview")
246+ } else {
247+ select {
248+ case p.pool <- wv:
249+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
250+ default:
251+ wv.Destroy()
252+ }
253+ }
254+
255+ remaining--
256+ if remaining <= 0 {
257+ doneOnce.Do(p.wg.Done)
258+ return false
259+ }
260+
261+ schedule()
262+ return false
263+ })
264+ glib.IdleAdd(&cb, 0)
265+ }
266+
267+ schedule()
268+}
269+
210 // prewarmSync creates WebViews synchronously.270 // prewarmSync creates WebViews synchronously.
211 func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {271 func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {
212 log := logging.FromContext(ctx)272 log := logging.FromContext(ctx)
M internal/ui/app.go +11 -5
@@ -179,7 +179,9 @@ func (a *App) onActivate(ctx context.Context) {
179179 log.Debug().Msg("GTK application activated")
180180
181181 a.applyGTKColorSchemePreference(ctx)
182- a.prewarmWebViewPool(ctx)
182+ // Configure pool background color early (prevents white flash), but avoid
183+ // synchronous prewarming that delays the first navigation on cold start.
184+ a.setupPoolBackgroundColor(ctx)
183185
184186 if err := a.createMainWindow(ctx); err != nil {
185187 log.Error().Err(err).Msg("failed to create main window")
@@ -198,6 +200,8 @@ func (a *App) onActivate(ctx context.Context) {
198200 a.initSnapshotService(ctx)
199201 a.initUpdateCoordinator(ctx)
200202 a.createInitialTab(ctx)
203+ // Prewarm the remaining WebViews after the initial tab starts loading.
204+ a.prewarmWebViewPoolAsync(ctx)
201205 a.finalizeActivation(ctx)
202206 }
203207
@@ -223,19 +227,21 @@ func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
223227 Msg("set gtk-application-prefer-dark-theme")
224228 }
225229
226-func (a *App) prewarmWebViewPool(ctx context.Context) {
230+func (a *App) setupPoolBackgroundColor(ctx context.Context) {
227231 // Set theme background color on pool to eliminate white flash.
228- // Must be done before prewarming so WebViews get the correct color.
232+ // Must be done before any WebView creation so WebViews get the correct color.
229233 if a.pool != nil && a.deps != nil && a.deps.Theme != nil {
230234 r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
231235 a.pool.SetBackgroundColor(r, g, b, alpha)
232236 }
237+}
233238
234- // Prewarm WebView pool now that GTK is initialized.
239+func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
240+ // Prewarm WebView pool after startup so cold-start navigation is not blocked.
235241 if a.pool == nil {
236242 return
237243 }
238- a.pool.Prewarm(ctx, 0)
244+ a.pool.PrewarmAsync(ctx, 0)
239245 }
240246
241247 func (a *App) createMainWindow(ctx context.Context) error {
@@ -179,7 +179,9 @@ func (a *App) onActivate(ctx context.Context) {
179 log.Debug().Msg("GTK application activated")179 log.Debug().Msg("GTK application activated")
180 180
181 a.applyGTKColorSchemePreference(ctx)181 a.applyGTKColorSchemePreference(ctx)
182- a.prewarmWebViewPool(ctx)182+ // Configure pool background color early (prevents white flash), but avoid
183+ // synchronous prewarming that delays the first navigation on cold start.
184+ a.setupPoolBackgroundColor(ctx)
183 185
184 if err := a.createMainWindow(ctx); err != nil {186 if err := a.createMainWindow(ctx); err != nil {
185 log.Error().Err(err).Msg("failed to create main window")187 log.Error().Err(err).Msg("failed to create main window")
@@ -198,6 +200,8 @@ func (a *App) onActivate(ctx context.Context) {
198 a.initSnapshotService(ctx)200 a.initSnapshotService(ctx)
199 a.initUpdateCoordinator(ctx)201 a.initUpdateCoordinator(ctx)
200 a.createInitialTab(ctx)202 a.createInitialTab(ctx)
203+ // Prewarm the remaining WebViews after the initial tab starts loading.
204+ a.prewarmWebViewPoolAsync(ctx)
201 a.finalizeActivation(ctx)205 a.finalizeActivation(ctx)
202 }206 }
203 207
@@ -223,19 +227,21 @@ func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
223 Msg("set gtk-application-prefer-dark-theme")227 Msg("set gtk-application-prefer-dark-theme")
224 }228 }
225 229
226-func (a *App) prewarmWebViewPool(ctx context.Context) {230+func (a *App) setupPoolBackgroundColor(ctx context.Context) {
227 // Set theme background color on pool to eliminate white flash.231 // Set theme background color on pool to eliminate white flash.
228- // Must be done before prewarming so WebViews get the correct color.232+ // Must be done before any WebView creation so WebViews get the correct color.
229 if a.pool != nil && a.deps != nil && a.deps.Theme != nil {233 if a.pool != nil && a.deps != nil && a.deps.Theme != nil {
230 r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()234 r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
231 a.pool.SetBackgroundColor(r, g, b, alpha)235 a.pool.SetBackgroundColor(r, g, b, alpha)
232 }236 }
237+}
233 238
234- // Prewarm WebView pool now that GTK is initialized.239+func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
240+ // Prewarm WebView pool after startup so cold-start navigation is not blocked.
235 if a.pool == nil {241 if a.pool == nil {
236 return242 return
237 }243 }
238- a.pool.Prewarm(ctx, 0)244+ a.pool.PrewarmAsync(ctx, 0)
239 }245 }
240 246
241 func (a *App) createMainWindow(ctx context.Context) error {247 func (a *App) createMainWindow(ctx context.Context) error {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close