Lazy browsers
A tab can sit in the strip with nothing loaded behind it. Session restore creates
every tab but the selected and pinned ones that way, the tab unloader returns a
loaded tab to that state, and gBrowser.addTab’s createLazyBrowser option asks
for one directly. Such a tab shows a label, an icon and a URL, but it has no
document, no docShell and no content process — and reading the wrong property off
it quietly gives it all three. This page describes what a lazy tab has, what
makes its browser real, what a discard tears down, and where this stops and
Tab Unloading starts.
What a lazy tab has
tab.linkedBrowser is set before TabOpen fires, for a lazy tab as much as an
eager one, so its presence says nothing. What a lazy tab lacks is a browser in
the document, and tab.linkedPanel is what reports that: it holds the id of the
browser’s panel in gBrowser.tabpanels and is null until the browser is
inserted.
if (!tab.linkedPanel) {
// The tab has a <browser> element, but nothing is loaded in it.
}
The element itself is a MozBrowser that has never been connected, so the
properties that depend on a frame loader answer empty rather than throwing:
browsingContext, docShell, contentWindow and contentDocument are null,
contentPrincipal is undefined and browserId is 0. permanentKey is set from
the start, which is how session store keys the tab’s state either way.
What the tab does carry:
Session store’s lazy state — a
url, atitle, auserTypedValueand auserTypedClear, held for as long as the browser is lazy and read withSessionStore.getLazyTabValue(tab, key). This is where the substituted properties below get their answers, and what a consumer should read instead of going through the browser.The
pendingattribute, set by session store while the tab is waiting to be restored and removed when the restore actually starts. Underbrowser.tabs.fadeOutUnloadedTabsit fades the tab’s favicon.The
discardedattribute, set when the tab was explicitly unloaded rather than restored into this state.browser.tabs.fadeOutExplicitlyUnloadedTabsfades those tabs on their own.
The active tab is never lazy. Selecting a tab inserts its browser, and
discardBrowser refuses the selected tab. Multi-selected tabs are another
matter: gBrowser.selectedTabs can contain lazy ones, which is why
reloadWithFlags has a separate path for them.
The substituted properties
#createLazyBrowser defines the 42 names in #browserBindingProperties as own
accessors on the <browser> element, shadowing the element’s real
implementations until insertion deletes them again. Thirteen of them answer
without touching the browser:
Property |
Answers with |
|---|---|
|
Session store’s URL for the tab as an |
|
Session store’s title for the tab. |
|
Session store’s values for the tab. |
|
|
|
Whether the element carries the |
|
Whether the tab carries the |
|
|
|
|
|
A function returning |
|
A function returning |
|
A function returning |
reload and reloadWithFlags are substituted with a version that inserts the
browser and defers the reload until session store has restored the tab.
Every one of the remaining 27 inserts the browser, on read and on write
alike. So a consumer can undo a tab’s laziness by looking at it: a docShell, a
content process, a synchronous about:blank load and a TabBrowserInserted
caused by nothing but the read. On Nightly that path logs the property’s name and
a stack citing
Bug 1345098 to the
browser console. The ones that read as harmless are the trap:
documentURI, next to acurrentURIthat is safe.webProgress,addProgressListenerandremoveProgressListener, so a lazy tab cannot be wired up for Progress listeners without ceasing to be lazy.sessionHistory,canGoBackandcanGoForward.blockedPopups,imageDocument,characterSetandpreferences.resumeMedia,audioPlaybackStartedandaudioPlaybackStopped, next to a safeaudioMuted.
A property outside the 42 gets neither treatment: it reads through to the unbound
element and answers null or undefined, with nothing logged. So tab.linkedPanel
is the only reliable way to tell whether the browser is real.
To load something into a lazy tab, ask for the insertion rather than tripping
over it. tabs.update in the extension API and gBrowser’s own reload paths all
use this shape:
if (tab.linkedPanel) {
browser.fixupAndLoadURIString(url, options);
} else {
// Wait for the load handler to be instantiated before loading.
tab.addEventListener(
"SSTabRestoring",
() => browser.fixupAndLoadURIString(url, options),
{ once: true }
);
gBrowser.insertBrowser(tab);
}
The cycle
---
config:
flowchart:
wrappingWidth: 400
---
flowchart TD
add(["gBrowser.addTab"])
lazy{"createLazyBrowser?"}
ins1(["TabBrowserInserted<br/>insertedOnTabCreation: true"])
open(["TabOpen"])
touch["something reads the browser,<br/>or the tab is selected"]
ins2(["TabBrowserInserted<br/>insertedOnTabCreation: falsy"])
discard["discardBrowser"]
disc(["TabBrowserDiscarded"])
add --> lazy
lazy -- "no" --> ins1 --> open
lazy -- "yes" --> open
open --> touch --> ins2
ins2 --> discard --> disc
disc -. "browser is lazy again" .-> touch
classDef event fill:#dbeafe,stroke:#1e40af;
class ins1,ins2,open,disc event;
When a tab’s browser enters and leaves the document.
Making the browser real
gBrowser.insertBrowser(tab) is the public entry point. It does nothing if the
tab already has a linkedPanel or the window is closing, so calling it
defensively is fine. Otherwise, in order:
Deletes the substituted properties, unmasking the element’s own, and consumes the
_browserParamsthe tab has been carrying since it was created or last discarded —uriIsAboutBlank,remoteTypeandusingPreloadedContent.Gives the panel a unique id and sets
tab.linkedPanelto it.Appends the panel to
tabpanels. That runs the browser element’s constructors, which fire notifications that can run code inspecting tabbrowser state, and performs a synchronousabout:blankload. The tab has to be fully initialized before this point.Wires a
TabProgressListenerto the browser through a status filter, as Progress listeners describes.Rebinds
loadURIandfixupAndLoadURIStringtoURILoadingWrapperand sets the defaultdroppedLinkHandler.Deactivates the docShell unless the content was preloaded, and sets
browsingContext.hasSiblings,browsingContext.isAppTabfromtab.pinnedand theusercontextidattribute.Dispatches
TabBrowserInserted— but only if the tab is connected. Session restore creates its tabs outside the strip and dispatches the event itself for the whole batch once they are in the DOM.
Besides the property reads above and an explicit insertBrowser call, four
things insert a browser:
Selecting the tab. The tab strip’s
getRelatedElementinserts the browser when the tab has no panel, and only for the selected tab, so asking for another tab’s panel returns null rather than inserting anything.A remoteness change.
updateBrowserRemotenessinserts before swapping the frame loader.Showing the tab in a split view.
showSplitViewPanelsinserts each of the view’s tabs.A docShell swap.
#swapBrowserDocShellsinserts the receiving tab’s browser. Moving a lazy tab to another window takes a different path:swapBrowsersAndCloseOthercopies the tab’s session state instead of swapping docShells, so the tab arrives in the new window still lazy.
TabBrowserInserted is also what makes session store restore the tab: it clones
the tab’s state, starts the restore, and dispatches SSTabRestoring and then
SSTabRestored on the tab. The lazy state is dropped at that point, so
getLazyTabValue answers undefined from then on. detail.insertedOnTabCreation
distinguishes the two cases — true when addTab inserted the browser as it
created the tab, undefined for every later insertion.
Discarding a browser
gBrowser.discardBrowser(tab, forceDiscard) takes a loaded tab back to lazy. It
returns false without doing anything for a tab that is selected, closing,
already lazy or not remote, for a window that is closing, and when
permitUnload says no. A tab with an open dialog is also refused unless
forceDiscard is set, since a discard dismisses dialogs.
Flush the tab’s state first. await gBrowser.prepareDiscardBrowser(tab) hands
session store the latest data, and it is safe to call even if the discard is
then refused.
The discard resets the tab’s sharing state and forgets its WebRTC streams,
aborts its dialogs, records _browserParams for the eventual restore, has
session store take the tab back to lazy state, removes the progress listener and
its filter, closes and removes the findbar, and clears the tab’s now-stale
activemedia-blocked, busy, pendingicon, progress and soundplaying
attributes. Only then does it destroy the browser, remove its panel, clear
linkedpanel and re-install the substituted properties, and dispatch
TabBrowserDiscarded last of all.
One TabAttrModified always precedes it, for sharing, which the sharing reset
dispatches whether or not the tab was sharing anything. A second one lists
whichever of the five stale attributes were set.
The <browser> element survives, so a cached reference is not dangling. It is an
element whose docShell and browsing context are gone and whose properties will
quietly build new ones, which makes it the more dangerous kind of stale
reference. A consumer holding a browser, its browsingContext, or anything keyed
on either has to drop it on TabBrowserDiscarded.
Callers include the tab unloader under memory pressure,
gBrowser.explicitUnloadTabs behind the tab context menu’s unload command, and
the tabs.discard extension API.
Where this page stops
This page covers what a lazy browser is and what inserting or discarding one does. Tab Unloading owns the other half: which tabs are eligible, how they are prioritized, and what makes Firefox decide to discard one.