Dec 03, 2018

How to use new "Successor Tabs API" of WebExtensions on Firefox 65?

By the fixed bug 1500479, following new features become available on Firefox 65 and later.

  • New property previousTabId for activeInfo object notified to listeners of tabs.onActivated.
  • New property successorTabId for tabs returned by tabs.get(), tabs.query(), and other APIs.
    • It is updatable via tabs.update().
    • However, there is no way to know changes of the property dynamically. It is not notified to listeners of tabs.onUpdated, and there is no alternative listening API like tabs.onHighlighted.
  • New method tabs.moveInSuccession() to set successorTabId for multiple tabs, as an atomic operation.

When you close the active tab (by Ctrl-W or any operation), Firefox instead focuses to its next or previous tab. Or, if the closed tab was opened from another tab, Firefox possibly focuses the opener tab. In short: new Successor Tabs API is a mechanism to override these behaviors.

What is the problem these new APIs solve?

Until these APIs become available, it was impossible to override behaviors described above. So, if you wanted to focus to any other tab after the current tab is closed, you needed to do:

  1. Wait until Firefox focuses to a successor tab calculated by Firefox (the next, previous, or opener tab).
  2. Focus to another tab by a listener for tabs.onActivated.

It was not only visually-ugly, but also troublesome because focusing of a tab by Firefox itself changes "last activated time" of the tab and affects to the behavior of Ctrl-Tab/Ctrl-Shift-Tab (these shortcuts move focus of tabs based on their last activated time, at lately versions of Firefox.)

My addon Tree Style Tab also has a feature like that: when a last child tab is closed, TST tries to focus to the previous sibling tab instead of next tab. The feature should be improved with the new Successor Tabs API.

Basic usage: setting a successor tab for a tab

The most basic usage of new APIs are: overriding the successor of a specific tab. Here is an example to implement "focus to the opener tab always":

// Let's do overriding when a tab is focused.
browser.tabs.onActivated.addListener(async (activeInfo)
=> {
  // If the newly focused tab has its opener information,
  // set it to "successorTabId".
  const activeTab await browser.tabs.get(activeInfo.tabId);
  if (activeTab.openerTabId)
    browser.tabs.update(activeTab.id, {
      successorTabId: activeTab.openerTabId
    });

  // Clear "successorTabId" of the previous active tab.
  if (activeInfo.previousTabId)
    browser.tabs.update(activeInfo.previousTabId, {
      successorTabId: -1 // you need to give "-1" instead of "null".
    });
});

Firefox has a built-in behavior to focus to the opener tab when a secret preference browser.tabs.selectOwnerOnClose is true by default. However, it is designed to be unabled after you browse other tabs. The example above enforces Firefox to apply the behavior always.

The effect of the successorTabId feature has priority higher than Firefox's builtin behaviors about successor tabs. So you cannot get combined behavior like that: specifying custom successor tabs only when the tab's successor won't be controlled by browser.tabs.selectOwnerOnClose. Instead you need to simulate Firefox's builtin behavior by your addon ifselt, on such cases.

Advanced usage: setting successor tabs for multiple tabs at a time

You can set successorTabId for each tab via a new method tabs.moveInSuccession(). This method allows you to modify relations of tabs by just one operation.

However, please note that this method is designed for very specific usecases with high context. As described at the initial design plan of the API by the initial developer, this method is designed for addons like "control focus of tabs based on their last activated time". I strongly recommend you to understand usages of this method based on this design background.

So, let's describe usages of the method with figures on following five scenarios:

  1. Initialize relation of multiple tabs
  2. Update relation of tabs when a tab gets focus
  3. Update relation of tabs when a set of tabs is switched
  4. Update relation of tabs when new tabs are added to an window
  5. Update relation of tabs when closed tabs are restored to an window

Assumed situation

The most important condition to work the tabs.moveInSuccession() method effectively is: all tabs' successorTabId are completely managed by your addon.

(Figure:all each tab has its successor clearly specified)

This figure describes a situation that there are 8 tabs ("A"-"H") and they are associated via their successorTabId. The tab "A"'s successorTabId is the ID of the tab "B", and when the tab "A" is closed Firefox will focus to the tab "B" as its successor. Please remember and remind the principle: the method tabs.moveInSuccession() is used from listeners of tabs.onActivated, tabs.onCreated, and tabs.onAttached on such a situation.

In other words, the method tabs.moveInSuccession() is designed for such a situation and not suitable for a situation with short-live successorTabId like the first example.

You can run example codes in following sections, with a script for testing. Install any addon which has a tabs permission and start remote debugger for the addon from about:debugging, then activate the "Console" tab and run the script. After that functions and variables appearing in following examples become available.

Scenario 1: Initialize relation of multiple tabs

This happens when the addon is installed, or a Firefox window is restored by the built-in session management feature.

When there are 8 tabs and you hope to initialize relation of them, you should do:

// Initialize relation of tabs before testing
setSuccessorsById([]);
// The first tab "A" is now active, and next (right side) tabs should
// be focused when active tabs are closed.
browser.tabs.moveInSuccession(
  [A, B, C, D, E, F, G, H],
  A
)

(Figure: updated relation of tabs)

The first argument of the tabs.moveInSuccession() method is an array of tabs' ID (=tabs.Tab.id). Typically you'll sort tabs by their recently activated time or something and collect their ID like: (await browser.tabs.query({ windowId })).sort(sortFunction).map(tab=> tab.id)`.

The second argument is the ID of the tab to be associated to the last tab of the first argument (=H). The first argument needs to have unique values and any tab cannot appear twice in the array (it will throws an exception), so you need to use the second argument if you need to specify a circular relation like this example.

The order of the array indicates the succession order of tabs. If you need to set relation of tabs in reversed order, you'll do like following:

// Initialize relation of tabs before testing
setSuccessorsById([]);
// The last tab "H" is now active, and previous (left side) tabs should
// be focused when active tabs are closed.
browser.tabs.moveInSuccession(
  [H, G, F, E, D, C, B, A],
  H
)

(Figure: updated relation of tabs)

Scenario 2: Update relation of tabs when a tab gets focus

This is most popular case after the relation of tabs is initialized by the scenario 1.

When all tabs are associated to each other and one of them becomes active, you need to update successorTabId of the activated tab, the previous active tab, and all other related tabs. The method tabs.moveInSuccession() does that by just one operation.

Assume that the tab "C" is active and the tab "D" is clicked. Then the tab "D" becomes active and the operation you should do is:

// Initialize relation of tabs before testing
setSuccessorsById([B, C, D, E, F, G, H, A]);
// The listener for "tabs.onActivated" is called with an activeInfo:
// { tabId: D, previousTabId: C }
browser.tabs.moveInSuccession(
  [D],
  C
)

(Figure: updated relation of tabs)

When you close the new active tab "D", the "C" will become active. After that tabs become active in the order: "E" => "F" => "G".

But there is a problem: the tab "D" won't get focus after any other tab is closed, because it is not referred from other tabs anymore.

The insert option sepcified via the third argument solves that. If you give insert:true as an option, the partial relation "D" => "C" is naturally embedded to the original relation "B" => "C" => "D" => "E":

// Initialize relation of tabs before testing
setSuccessorsById([B, C, D, E, F, G, H, A]);
// The listener for "tabs.onActivated" is called with an activeInfo:
// { tabId: D, previousTabId: C }
browser.tabs.moveInSuccession(
  [D],
  C,
  { insert: true }
)

(Figure: updated relation of tabs)

The order "B" => "C" => "D" => "E" has became "B" => "D" => "C" => "E" and the chain of relation has been kept consistently.

The first argument of the tabs.moveInSuccession() must be an array. Even if you hope to specify just one tab, you cannot give a raw tabs.Tab.id directly.

Scenario 3: Update relation of tabs when a set of tabs is switched

Only one tab can be active in a window, but you need to treat a set of tabs as "active" in some situations.

For example, some addons like Sync Tab Groups or Panorama View provide ability to manage tabs with groups. Tabs of inactive groups are hidden and only tabs of the active group are visible. In such situations, invisible tab should not be focused easily, instead any visible tab should get focus when the active tab is closed.

Assume that there are three groups of tabs: "A, B, C", "D, E, F", and "G, H". The "C" was previously active and the "D" has become active, then "D, E, F" become visible instead of "A, B, C". In this situation you should do:

// Initialize relation of tabs before testing
setSuccessorsById([B, C, D, E, F, G, H, A]);
// The listener for "tabs.onActivated" is called with an activeInfo:
// { tabId: D, previousTabId: C } and the result of
// tabs.query({ windowId: D.windowId, hidden: false }) is "D, E, F".
browser.tabs.moveInSuccession(
  [D, E, F],
  C
)

(Figure: updated relation of tabs)

Relation of tabs has been updated not only for "D" but "E" and F" also. Now tabs are focused in the order "D" => "E" => "F", and the "C" will become active after the tab "F" is closed.

Please note that the "D" won't get focus after any other tab is closed. The insert:true option allows you to embed a partial relation "D" => "E" => "F" => "C" into the large chain "B" => "C" => "D" => "E" => "G" naturally:

// Initialize relation of tabs before testing
setSuccessorsById([B, C, D, E, F, G, H, A]);
// { tabId: D, previousTabId: C } and the result of
// tabs.query({ windowId: D.windowId, hidden: false }) is "D, E, F".
browser.tabs.moveInSuccession(
  [D, E, F],
  C,
  { insert: true }
)

(Figure: updated relation of tabs)

As described above, you should specify the option insert:true when you need to keep the chain of tabs' relation.

Scenario 4: Update relation of tabs when new tabs are added to an window

The method tabs.moveInSuccession() is also used on cases when unmanaged tabs appear in a window. For example, new tabs are opened or existing tabs are moved from other windows.

Assume that there were 5 tabs ("A"-"E") and a new tab "F" is added. To attach the tab "F" to the existing tabs' relation, you should do:

// Initialize relation of tabs before testing
setSuccessorsById([B, C, D, E, A]);
// "F" is notified to the listener for "tabs.onCreated" or "tabs.onAttached".
// "F" is active and "C" is the previous active tab.
browser.tabs.moveInSuccession(
  [F],
  C
)

(Figure: updated relation of tabs)

After the active tab "F" is closed the "C" will become active.

But the tab "C" won't get focus after any other tab is closed. Yes, you need to specify the insert:true option to embed new partial relation to the existing relation naturally.

// Initialize relation of tabs before testing
setSuccessorsById([B, C, D, E, A]);
// "F" is notified to the listener for "tabs.onCreated" or "tabs.onAttached".
// "F" is active and "C" is the previous active tab.
browser.tabs.moveInSuccession(
  [F],
  C,
  { insert: true }
)

(Figure: updated relation of tabs)

Even if multiple tabs appear at a window, you should do same operation for them. All new tabs should be given via the first argument, like:

// Initialize relation of tabs before testing
setSuccessorsById([B, C, D, E, A]);
// "F", "G" and "H" are notified to the listener for "tabs.onCreated" or "tabs.onAttached".
// "F" is active and "C" is the previous active tab.
browser.tabs.moveInSuccession(
  [F, G, H],
  C,
  { insert: true }
)

(Figure: updated relation of tabs)

Then only added tabs are focused after the active tab is closed, and tabs in another group will be focused after that.

On the other hand, you don't need to do anything when tabs are closed or detached from a window. In such cases Firefox automatically keep consistency of tabs' relation like:

(Figure: updated relation of tabs)

Scenario 5: Update relation of tabs when closed tabs are restored to an window

There is one more option for the tabs.moveInSuccession() method: the third argument of the method accepts an option append. It is designed for a case when a restored tab (by Ctrl-Shift-T or something) need to be focused after the active tab is closed because the restored tab was the "parent" of the active tab.

Assume that there are five tabs "A", "B", "C", "E" and "F". Now a tab "D" is restored and it was a parent of the active tab "C". In this case you need to do:

// Initialize relation of tabs before testing
setSuccessorsById([B, C, E, null, F, A]);
// "D" is notified to the listener for "tabs.onCreated" or "tabs.onAttached".
// "C" is the tab going to be focused after "D" is closed.
browser.tabs.moveInSuccession(
  [D],
  C,
  { append: true }
)

(Figure: updated relation of tabs)

Note that the association graph "C" => "D" is restored (it is opposite of "D" => "C" at the previous scenario.)

Now no other tab won't get focus after the "D" is closed, because the partial relation "C" => "D" breaks the existing chain. You should specify insert:true together to keep that:

// Initialize relation of tabs before testing
setSuccessorsById([B, C, E, null, F, A]);
// "D" is notified to the listener for "tabs.onCreated" or "tabs.onAttached".
// "C" is the tab going to be focused after "D" is closed.
browser.tabs.moveInSuccession(
  [D],
  C,
  { append: true,
    insert: true }
)

(Figure: updated relation of tabs)

The chain of "C" => "E" is updated to "C" => "D" => "E" naturally ,and the "E" will become active after the "D" is closed.

For a case with multiple restored tabs, assume that there are 6 tabs "A", "B", "C", "F", "G" and "H", and two tabs "D" and "E" are restored. Then you should do:

// Initialize relation of tabs before testing
setSuccessorsById([B, C, F,null, null, G, H, A]);
// "D" and "E" are notified to the listener for "tabs.onCreated" or "tabs.onAttached".
// "C" is the tab going to be focused after "D" is closed.
browser.tabs.moveInSuccession(
  [D, E],
  C,
  { append: true,
    insert: true }
)

(Figure: updated relation of tabs)

Both tabs are naturally attached to the chain.

You may realize that this operation is quite similar to the previous scenario. The tabs.moveInSuccession() method with the append:true option can produce just same result if you specify different tab as the second argument, like:

// Initialize relation of tabs before testing
setSuccessorsById([B, C, D, E, A]);
// "F", "G" and "H" are notified to the listener for "tabs.onCreated" or "tabs.onAttached".
// "C" is the tab should be focused after they are closed.
browser.tabs.moveInSuccession(
  [F, G, H],
  C,
  { insert: true }
)
// Or, one of them should be focused after the "B" is closed.
browser.tabs.moveInSuccession(
  [F, G, H],
  B,
  { append: true,
    insert: true }
)

(Figure: updated relation of tabs)

As described in the example above:

  • When you know a tab should become active after all added tabs are closed, you should not specify append:true and the second argument is the tab.
  • When you know that one of added tabs should become active after a tab is closed, you should specify append:true and the second argument is the tab.

Conclusion

The new method tabs.moveInSuccession() is designed for very specific cases as described above. You need to call it carefully for suitable scenario. I thought that such an high-context feature won't be accepted to WebExtensions, because I also thought the policy of WE as: we should do everything by existing primitive APIs.

However it is actually useful and effective for some reasons. As described at the initial API design, such operations require too much API calls via IPC (inter process communication) and it may require CPU resource. Moreover, as described in an extra comment for this article, such operations can become collapsed by focus changing happened parallely while them because they are asynchronous. To avoid such a problem, addons need to implement queuing or waiting mechanism to run asynchronously-requested operations sequentially (and my Tree Style Tab actually does that to manage tabs consistently). Atomic operation produced by tabs.moveInSuccession() don't introduce such an ugly problem.

The adoption of the API design reminds me again that importance of detailed descriptions of a proposal with rational reasons and writing patches by myself including automated tests. I hope to contribute with patches instead of grumbling on SNS and bugzilla...

エントリを編集します。

wikieditish message: Ready to edit this entry.











拡張機能