Proposal: add new `browser.tabs.waitForTab(tabId: number)` that will wait for tab to load
Juraj-Masiar opened this issue · comments
The issue is always the same, but every time slightly different:
- I have a code running in one of my contexts (background script, tab, popup, sidebar...)
- I want to open new window/tab/page/popup/sidebar (something that loads HTML and JS), or update URL in existing tab.
- from my original context, I want to send it some message / execute script / use some API using
tabId
.
And this is one huge race condition, because some API calls in some browsers will fail if the page is not loaded (example, example).
Existing workarounds are complex:
- let the opened page ask for work - this is a spaghetti code solution, because the workflow started in another context that may want to await the job execution and even get a result.
- let opened page send message to the caller that it's ready for work - if programmed right, it can work fine (except when updating tab URL), but it requires a lot of code.
- use
tabs.onUpdated
to watch for status "complete" event - again, a lot of code needed and was very buggy in the past (for example "complete" fired twice in some cases). - use
webNavigation.onCompleted
is similarly complex plus requireswebNavigation
permission
The proposed, browser.tabs.waitForTab
API would solve all this in one line, example:
const tab = await browser.tabs.create({url: '/quetions.html'});
await browser.tabs.waitForTab(tab.id);
const response = await browser.tabs.sendMessage(tab.id, {type: 'ask_user_for_something'});
There could be a second optional parameter in this API that would specify, whether to await the DOMContentLoaded
or load
event. With DOMContentLoaded
being a default.
And if the tab is not in the "loading" state, it would resolve right way.
Alternative solution 1:
Updating specification for the tabs.create
, tabs.update
, windows.create
and similar API, to require them to resolve the returned promise only after the DOMContentLoaded event.
Alternative solution 2:
Updating specification for scripting.executeScript
, tabs.sendMessage
and similar, to require them to await tab getting ready before executing them.
This is a common issue: When is a tab ready to be messaged?
The best solution at this point appears to be: sendMessage
on repeat until it's successful. This is not great.
I think a better solution for messaging could be:
const tab = await browser.tabs.create({url: '/quetions.html'});
const response = await browser.tabs.sendMessage(
tab.id,
{type: 'ask_user_for_something'},
{waitForListener: true} // 👈
);
This way the message would wait indefinitely until the content script called runtime.onMessage.addListener
, or until the tab is closed (throwing an error)
Messaging as a whole is painful and I think it needs to be reevaluated. The worst offender is runtime.sendMessage
acting as a broadcast
a second optional parameter in this API that would specify, whether to await the DOMContentLoaded or load event. With DOMContentLoaded being a default.
document_start
is also necessary for extensions that run content scripts at document_start
.
Updating specification for the tabs.create, tabs.update, windows.create and similar API, to require them to resolve the returned promise only after the DOMContentLoaded event.
It should be optional and we should be able to specify the timing e.g. document_start
.
sendMessage [...]
waitForListener: true
Just waiting for a listener may be unreliable with chrome.tabs.update because it may send to the current contents of the tab before it is navigated as the browser doesn't destroy it until the new URL's response is received.
It could be something like onNewDocumentInjected: true
.
Some considerations that were raised during today's discussion include:
- In order to be more confident about the specific document being awaited, we should allow developers to target a page via
documentId
. - An API like this should probably allow the developer to provide an AbortController.
- Consider adding a bool to signal whether or not redirects would be taken into consideration.
I'll raise a few questions:
- at what point does the promise resolve exactly?
- what happens with content scripts running on
document_idle
? Should it wait for content scripts to run? - what happens if the content script is injected manually via scripting API?
- if the API follows the content script loading and is specifically targeting the ability to message the content script, does its name make sense?
- why does it return a promise instead of just being a regular
tabs.onReady
event?
I would be more supportive of having a tabs.waitForTab()
method, as opposed to adding options to the various APIs. A standalone method can be used for various APIs.
@fregante regarding the Promise VS event, using Promise allows caller to await the tab creation operation (page load) and continue working with the tab without loosing the context, for example sending it work/command/request/query. This will make the execution flow easy to reason about (see my original comment for an example).
I would say the Promise should resolve after all content scripts are executed - that is, for the specific load event the API is targeting. I can imagine the default could be document_idle
with option to change that to document_end
using some optional resolveAt
parameter.
That should fix all race conditions since in both cases you would be sure that the page code was executed so all top level registered handlers are ready. This includes extension pages, content script powered pages and normal websites. And ideally also popup and toolbar, although I'm not sure now how to target those
Maybe waitForLoad({contentScriptInjected: true})
? Because there are other cases that don't need to wait for JS content scripts: screen capture, dynamic insertCSS, doing something else first before dynamically injecting the scripts.
document_start
should be also possible as it allows to inject functionality that the extension wants to be available while the page is still loading, which may take many seconds for pages with big HTML, especially on a mobile/WiFi network.
I like the waitForLoad
name, since "tab" is redundant with the tabs
namespace. I ran with this and wrote this idea up further in an MDN-like style.
Proposal for browser.tabs.waitForLoad()
Signature
browser.tabs.waitForLoad(target, options)
Parameters
-
target (
object
): Specifies target identifiers and options for the tab, frame, or document:- tabId (
integer
, optional): The ID of the tab. - frameId (
integer
, optional): The ID of a specific frame within the tab. If onlyframeId
is specified, it defaults to the main frame unless otherwise noted. - documentId (
string
, optional): A unique identifier for the document within the tab or frame. - subFrames (
boolean
, optional): Iftrue
, waits for all subframes of the specified frame to reach the specified state or event. Defaults tofalse
.
At least one of
tabId
,frameId
, ordocumentId
must be specified. If onlyframeId
is specified, it must be non-zero. - tabId (
-
options (
object
, optional):- state (
string
, optional): Specifies the load state that the promise will wait for before resolving. This option is mutually exclusive withevent
. Valid options are"document_start"
,"document_end"
, and"document_idle"
. Defaults to"document_idle"
if noevent
is specified. - event (
string
, optional): Specifies a specific DOM event that the promise will wait for before resolving. This option is mutually exclusive withstate
. Valid options are"DOMContentLoaded"
or"load"
. - timeout (
integer
, optional): The maximum number of milliseconds to wait before the promise rejects. Defaults to30_000
milliseconds (30 seconds).
- state (
Returns
- Promise: Resolves after the specified target reaches the specified state or event, and after all content scripts for that state have executed, or at the time a content script would typically run if registered at that state. The promise resolves immediately if the specified state or event has already been reached for the target and any specified frames. If the target cannot reach this state or event, or if the timeout expires without reaching the state or triggering the event, the promise will reject.
Example
const tab = await browser.tabs.create({ url: '/questions.html' });
await browser.tabs.waitForLoad({ tabId: tab.id, subFrames: true }, { event: 'DOMContentLoaded', timeout: 5_000 });
const response = await browser.tabs.sendMessage(tab.id, { type: 'ask_user_for_something' });
@tophf That was the intention for all states. If you have a content script at any run_at mode this function can be used to wait for it to have listeners registered (including document_idle).
Ah, sorry, I see it's already specifically mentioned. But maybe you could specify what happens if there are no content scripts.
This proposal likely could be polyfilled by using scripting.registerContentScripts()
with a script for the tab's current URL in matches
at the run_at
for the state
(or document_start
and listens for the specified event
.) Then the script sends a message that the API is listening for and resolves the promise, or it rejects at the timeout
. Then unregister the content script so it does not fire again if the same URL loads in another tab.
That would be a good way to test this design out and guide any further changes needed. I likely won't have time to write that polyfill, but anyone else is welcome to take a crack at it!
The only complications I see with a polyfill are:
- Targeting a specific tab, if the same URL happens to load in a second tab at the same time.
- Knowing when the tab is already at the desired state or event so the promise resolves immediately.
- Properly handling
subFrames
and waiting for messages from each frame.
could be polyfilled by using scripting.registerContentScripts
Yes, for the content script case, which is arguably the most popular one, but waitForLoad may be useful even without host access and a content script, in which case it can be polyfilled by using chrome.webNavigation.onCommitted (or chrome.tabs.onUpdated) + tabs.onRemoved + tabs.onReplaced and that won't have the problem of targeting the tab.
@xeenon great idea with the registerContentScripts
API!
I was so excited I've tested it right away, but there is a catch.
Some of my many use-cases is actually for awaiting my own extension page load for example:
const newTab = await browser.tabs.duplicate(thisTabId);
await browser.tabs.waitForLoad({tabId: newTab.id});
await browser.tabs.sendMessage(newTab.id, {type: 'openSearch'});
And although it works in Firefox where you can register content scripts even for extension pages, it won't work in Chrome where you can't.
const scriptId = 'browser.tabs.waitForLoad_' + JSON.stringify(target);
await browser.scripting.registerContentScripts([{
id: scriptId,
matches: [
'<all_urls>',
browser.runtime.getURL('') + '*',
],
js: ['/wait_for_load.content_script.js'],
runAt: 'document_end',
}]);
It will complain about: Error: Script with ID 'browser.tabs.waitForLoad_{"tabId":1435791607}' has invalid value for matches[1]: Invalid scheme.
@Juraj-Masiar Bummer!