Skip to content

feat(browser): tabs (multiple embedded documents)#406

Merged
nicoburns merged 17 commits intoDioxusLabs:mainfrom
tonybierman:browser-tabs
Apr 28, 2026
Merged

feat(browser): tabs (multiple embedded documents)#406
nicoburns merged 17 commits intoDioxusLabs:mainfrom
tonybierman:browser-tabs

Conversation

@tonybierman
Copy link
Copy Markdown
Contributor

@tonybierman tonybierman commented Apr 28, 2026

Summary

Implements the following features from the Browser UI backlog (#363):

  • Tabs
  • Status bar
  • Menu item to clear HTTP cache

It also refactors the Browser UI into multiple files.

Out-of-scope items deliberately deferred for follow-ups:

  • drag-to-reorder tabs
  • target=_blank → new tab
  • keyboard shortcuts (Ctrl+T/W/Tab),
  • pre-warmed hidden viewport (layout hidden tabs with correct viewport)
  • pinning / right-click menu / thumbnails, session persistence.

Implementation details:

  • One <web-view> per tab, rendered with display: none on inactive tabs so style resolution and asset fetching continue in the background while paint is skipped.
  • Per-tab Tab struct: history, DocumentLoader, document, node_handle, html_source, title. Each tab loads independently; closing a tab drops its loader and cancels in-flight fetches.
  • Tab strip with click-to-switch, "+" new-tab, "×" close (hidden when only one tab remains). Rounded upper corners, seamless join with the urlbar.
  • Live tab titles via find_title_node() cached into a per-tab Signal at load time (falls back to URL).
  • Window title reactively follows the active tab's title via the chrome's <title> element.
  • Back/forward buttons render disabled (dim, click is a no-op) when there's no history in that direction.
  • All chrome handlers (back/forward/refresh/home/menu/URL bar) operate on the active tab read at call time.

tonybierman and others added 2 commits April 27, 2026 19:46
…bled nav buttons

- Per-tab title Signal populated after parse; tab strip shows live titles, falls back to URL.
- Window title bound reactively to active tab's title (Step 5 of DioxusLabs#363 plan).
- Back/forward IconButtons gain a disabled prop; render dim and click is a no-op when there's no history in that direction.
- Tabs widened, rounded upper corners, seamless join with urlbar.
- active_tab() returns Tab directly instead of a 5-tuple (clears clippy type_complexity).
- Drop apps/browser/TABS-PLAN.md (planning doc, no longer needed in-tree).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@tonybierman
Copy link
Copy Markdown
Contributor Author

Screenshot From 2026-04-27 20-12-31

tonybierman and others added 8 commits April 28, 2026 05:21
…stale-request bug

- Extract TabStrip and Toolbar as sub-components to reduce app() from ~425 to ~60 lines
- Fix inverted stale-request guard in load_document (was printing but never returning)
- Deduplicate DocumentConfig construction into make_doc_config() helper
- Replace println! with tracing::{info,warn,error,debug}; add tracing as direct dep
- Fix is_focussed → is_focused spelling
- Replace core::mem::drop() → drop() (two sites)
- Fix window_title to call tab_title_or_url() instead of duplicating logic
- Remove dead History::refresh method
- Extract HOME_URL_STR constant; merge std use-tree imports

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New tabs and the home button now open about:newtab, a local start page
that displays the Blitz logo centered on a light background. The logo
is embedded as a base64 data URI so no network request is needed.
DocumentLoader intercepts about:newtab synchronously before spawning
a fetch task.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
StatusBar component polls the sub-document's hover node at 100ms
intervals (same pattern as FpsOverlay), walks the layout_parent chain
to find the nearest <a> ancestor, and resolves relative hrefs against
the current page URL. Displays loading URL during fetches; hidden when
idle and not hovering a link. Fixed-positioned bottom-left, pointer-
events: none so it never blocks clicks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The polling task wakes up during Dioxus event processing, which holds
a mutable borrow on the outer BaseDocument. Replace handle.doc() with
handle.try_doc() so that if the RefCell is already borrowed, the poll
cycle is silently skipped rather than panicking.

Add NodeHandle::try_doc() to dioxus-native-dom for this purpose.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The async load task never wrote Idle back when done, so the status bar
kept showing "Loading…" indefinitely. One write after the match covers
both the success and error paths; the stale-request early-return is
unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- blitz-net: store CACacheManager in Provider, expose async clear_cache()
- browser: Clear Cache menu item calls it via use_context (cache feature-gated)
- browser.css: fps-overlay top 48px → 80px to clear the urlbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Rename `Ordering as Ao` alias to plain `Ordering`
- Pre-compute `has_back`/`has_forward` once per Toolbar render instead of calling `active_tab()` twice
- Extract `hovered_href` free fn to replace 50-line labeled-block nest in StatusBar polling loop
- Replace `str::from_utf8(&bytes).unwrap()` with `String::from_utf8_lossy` to avoid panic on non-UTF-8 responses
- Use `saturating_sub(1)` in `close_tab` to guard against underflow
- Fix clippy `redundant_closure` in `use_signal(|| String::new())`
- Run rustfmt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@tonybierman
Copy link
Copy Markdown
Contributor Author

@nicoburns I was going to move components out of main to their own code files

@nicoburns
Copy link
Copy Markdown
Member

@tonybierman Seems reasonable. I've noticed a couple of bugs:

  • Documents weren't unloading properly when a tab was closed (this was a bug in blitz-dom: I've pushed a fix for this)
  • Navigation only works in the right-most tab (regardless of which tab is active). If I submit a url in the url bar or click a link in a non-rightmost tab then the url in the url bar updates, but the page only actually loads if I press the "refresh" button.
  • The browser seems to be stuck in "continually rendering frames" mode. Normally this is supposed to happen when there is an active animation. I can't see one, but perhaps there's one I can't see. Could potentially also be linked to Fix panic in resolve_stylist when animation entry is stale #408 (perhaps we're not removing animation correctly if the DOM node gets removed before the animation completes?)

@tonybierman
Copy link
Copy Markdown
Contributor Author

@tonybierman Seems reasonable. I've noticed a couple of bugs:

  • Documents weren't unloading properly when a tab was closed (this was a bug in blitz-dom: I've pushed a fix for this)
  • Navigation only works in the right-most tab (regardless of which tab is active). If I submit a url in the url bar or click a link in a non-rightmost tab then the url in the url bar updates, but the page only actually loads if I press the "refresh" button.
  • The browser seems to be stuck in "continually rendering frames" mode. Normally this is supposed to happen when there is an active animation. I can't see one, but perhaps there's one I can't see. Could potentially also be linked to Fix panic in resolve_stylist when animation entry is stale #408 (perhaps we're not removing animation correctly if the DOM node gets removed before the animation completes?)

I'll take a look

tonybierman and others added 2 commits April 28, 2026 06:37
Move History, Tab, DocumentLoader, TabStrip, Toolbar, and StatusBar
into their own files. Expose a public HistoryNav delegation trait to
bridge the private trait generated by #[store] across module boundaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@nicoburns
Copy link
Copy Markdown
Member

The browser seems to be stuck in "continually rendering frames" mode. Normally this is supposed to happen when there is an active animation. I can't see one, but perhaps there's one I can't see. Could potentially also be linked to #408 (perhaps we're not removing animation correctly if the DOM node gets removed before the animation completes?)

This was the status bar code unconditionally writing to a Dioxus signal even if the value hadn't changed. The Signals model of state that Dioxus uses is that any write triggers an update (even if they value is the same). To avoid rerenders you have to compare and write conditionally.

(fix pushed)

Incorporates fix from b70dcf2: guard hover_url.set() behind an equality
check in StatusBar so Dioxus change detection is not triggered when the
hovered URL hasn't actually changed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@tonybierman
Copy link
Copy Markdown
Contributor Author

tonybierman commented Apr 28, 2026

The browser seems to be stuck in "continually rendering frames" mode. Normally this is supposed to happen when there is an active animation. I can't see one, but perhaps there's one I can't see. Could potentially also be linked to #408 (perhaps we're not removing animation correctly if the DOM node gets removed before the animation completes?)

This was the status bar code unconditionally writing to a Dioxus signal even if the value hadn't changed. The Signals model of state that Dioxus uses is that any write triggers an update (even if they value is the same). To avoid rerenders you have to compare and write conditionally.

(fix pushed)

I merged your fix into my refactor (components out of main) and pushed. Haven't worked on bugs from your previous comment yet, will do shortly.

@nicoburns
Copy link
Copy Markdown
Member

Navigation only works in the right-most tab (regardless of which tab is active). If I submit a url in the url bar or click a link in a non-rightmost tab then the url in the url bar updates, but the page only actually loads if I press the "refresh" button.

Just a note for clarity that this is the only remaining bug from that comment (fixes pushed for the other - but I'm not planning on looking at this one right now)

@nicoburns
Copy link
Copy Markdown
Member

Tested on mobile, and it actually works pretty well! Only thing is that the tab bar should probably be a (horizontal) scroll container, because you run out of space for more tabs very quickly:

Screen.Recording.2026-04-28.at.13.04.35.mp4

@nicoburns
Copy link
Copy Markdown
Member

Running on ios sim with dx serve --ios -p browser --no-default-features --features hybrid,incremental,floats --release btw. Android also works although I'd recommend thenskia renderer rather than the hybrid renderer on Android. cargo install dioxus-cli to get dx.

tonybierman and others added 2 commits April 28, 2026 09:16
Navigation now works in all tabs, not just the rightmost one, by
subscribing the load effect to every tab's current URL signal.

Fixes a panic ("invalid key") in resolve_layout_children_recursive
when incremental layout is enabled: anonymous blocks and pseudo-elements
can be removed from the slab between render passes, leaving stale IDs
in cached layout_children. Guard the recursive function entry against
stale keys and filter them from the cached children list before use.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tabs now shrink equally as more are opened (flex: 1 1 0, min-width: 0)
so the + button is never pushed off screen. A CSS tooltip shows the
full tab title on hover for tabs that are too narrow to read.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@tonybierman
Copy link
Copy Markdown
Contributor Author

tonybierman commented Apr 28, 2026

@nicoburns I handled the tab abundance issue by progressively compressing each tab's width as new tabs are added. I feel like we're at a good stopping point for this PR if you feel like it is ready.

@nicoburns
Copy link
Copy Markdown
Member

nicoburns commented Apr 28, 2026

I'm mostly happy with where this is (a lot of new features!). And I'm keen to avoid this PR getting too much bigger. However, your fix for the navigation issue has a problem: now the page is reloaded every time the active tab is changed! This is a little tricky to get right. I think the easist way to fix this may be to put the effect that reloads on history change into a component that only has access to one Tab's state. That feels a little hacky, but I'm not currently able to think of a better way.

Comment thread packages/blitz-dom/src/resolve.rs Outdated
Comment on lines +195 to +202
// Filter out stale IDs — anonymous blocks and pseudo-elements
// can be removed from the slab between render passes, so cached
// layout_children may contain keys that are no longer valid.
let layout_children: Vec<usize> = layout_children
.into_iter()
.filter(|&id| doc.nodes.contains(id))
.collect();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should avoid allocating another Vec here, and instead put the contains check in the loop just below, and continue from the loop if the node doesn't exist.

tonybierman and others added 2 commits April 28, 2026 10:54
Move the load-on-history-change effect into a per-tab TabView component
so it only subscribes to that one tab's history URL. Switching tabs no
longer fires any load effect since active_tab_id is not a reactive
dependency of TabView's use_effect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@tonybierman tonybierman requested a review from nicoburns April 28, 2026 16:05
Copy link
Copy Markdown
Member

@nicoburns nicoburns left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This LGTM to me now. And I've cleaned up the sloppy PR description.

@nicoburns nicoburns merged commit c18bf64 into DioxusLabs:main Apr 28, 2026
15 checks passed
@tonybierman tonybierman deleted the browser-tabs branch April 28, 2026 18:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants