The proposal is to create some new way of exposing DOM to the browser, that is still searchable, but is invisible. That is, find in page must be able to search within this DOM and fragment navigation should be able to find a named invisible DOM, etc., but we shouldn't need to do all the work of layout/paint/etc.
Searchable Invisible DOM has merged with Display Locking, and this explainer is no longer maintained. With display locking, developers can avoid paying rendering costs, while also allowing user-agent features such as find-in-page, accessibility, indexability, focus navigation, anchor links, etc. to work with the display locked elements.
Note that this explainer and repo is no longer maintained, please direct questions or issues to the Display Locking repo.
An increasing amount of content on the web is becoming virtualized: that is, kept in memory in some non-DOM data structure, with only the visible portion converted into DOM nodes and inserted into the document. Motivating examples include https://m.twitter.com/, the popular React Virtualized component, and the layered API proposal.
Virtualized content can create a poor user experience in several areas:
- Find in page: the browser's native find-in-page functionality searches over DOM, which does not contain any off-screen data.
- Anchor navigation: if a visible piece of content links, using
<a href="#anchor">
, to some piece of content that is currently being virtualized, navigation to the anchor will fail. - Indexability: search engines ingest pages based on rendering them with a headless browser, and inspecting the contents of the DOM (along with other information such as style and layout). Virtualized content thus ends up not indexed by search engines.
- Accessibility: accessibility tools create their accessibility tree from the DOM, and so don't include virtualized content.
Here we have a list of items, and we add more items to the DOM asynchronously, and set up an event handler to remove the invisible-ness when needed. Note that this is a very simple example and doesn't handle more complex stuff such as adding the invisible-ness to elements that aren't on the viewport anymore, etc.
<ul id="itemsList">
<div>Item #1</div>
<div>Item #2</div>
<div>Item #3</div>
</ul>
<script>
remainingItems.forEach(item => {
// Add the remaining list items asynchronously.
requestIdleCallback((deadline) => {
let itemEl = createElementForItem(item);
itemEl.setAttribute("invisible", "");
itemsList.appendChild(itemEl);
});
});
// Set up handler to show element when needed.
itemsList.addEventListener("activateinvisible", (e) => {
e.preventDefault();
let item = e.activatedElement;
// Show this element and some elements nearby, and hide everything else.
// This is a very unefficient way of doing it :)
const currentPosition = getPosition(item);
for (let i = 0; i < itemsList.children.length; i++) {
if (shouldBeShown(itemsList.children[i], currentPosition))
itemsList.children[i].removeAttribute("invisible");
else
itemsList.children[i].setAttribute("invisible", "");
}
});
</script>
By default, invisible elements are quite similar to display:none
elements except that find-in-page, fragment url navigation, crawler bots and accessibility will know to not ignore them. Elements that are searchable invisible are marked with the invisible
attribute. If a previously searchable invisible element got its invisible
removed, the element will show up as normal (unless it is a flat-tree descendant of another searchable invisible element)
The element's subtree is treated as if it has display:none
. If an element has the invisible
attribute, all of its inclusive flat-tree descendants ("searchable invisible descendants") are all searchable invisible too.
<div invisible>
You can't see me!
<div>
Can't see me either!
</div>
</div>
There is a stronger mode, static
searchable invisible DOM. In this mode, the behaviors in the normal version are there but some expensive operations in the searchable invisible subtree are also not done/deferred until the invisible attribute is removed.
Some things that the static
mode will do:
- Defer custom element upgrades, unless specifically trigerred by
CustomElementsRegistry.upgrade
- Defer loading of resources
- Defer running of scripts under the element
- [There might be more!]
You can use it by setting the invisible
attribute value to static
.
<div invisible="static">
<my-element>
I don't have my fancy stuff...
</my-element>
<img src="notloaded.jpg">
<link rel="style" type="text/css" href="notloaded.css">
<script>
notRun();
</script>
</div>
<script>
customElements.define("my-element", class extends HTMLElement {
constructor() {
super();
addFancyStuffToElement();
}
});
</script>
As we can see from the above example, static
mode is quite strong and brings many advantages. However web authors need to handle a lot more things by themselves when using the mode, such as asynchronously upgrading the custom elements in the background.
property | value |
---|---|
bubbles | true |
composed | false |
target | the ancestor with the invisible attribute |
activatedElement | The element that needs activation |
When a searchable invisible node needs to be shown (see cases below) a DOM event of type activateinvisible
will be sent to all of its flat tree inclusive ancestors that have the invisible
attribute, up until the invisible
root (highest ancestor with the invisible
attribute) of the element, so that every invisible
ancestor can be made visible. If there are N
such ancestors, N
events will be sent, each of them bubbling up. Events will be sent from the innermost elements first.
Consider this tree structure:
<div id="first">
<div id="second" invisible>
<div id="third">
blah
<div id="fourth" invisible="static">
bleh
</div>
</div>
</div>
<div>
If we need to activate #fourth
div, then two activateinvisible
Events will be fired. One targeted at #fourth
and one targeted at #second
. The activatedElement
in both of them is the #fourth
div.
If instead we need to activate #third
div, only one activateinvisible
Event will be fired, at #second
with #third
in the activatedElement
field.
The activateinvisible
Event is sent when one of these situations occur:
-
Fragment URL navigation is happening, and the element to be navigated to is searchable-invisible. After the events are handled, the element will be scrolled-to (unless for some reason the web author kept it invisible)
-
Active match (currently focused match) of find-in-page is changed (through find next/prev [up/down buttons]) such that the next active match is under a searchable-invisible element. After the events are handled, the active match element will be highlighted and scrolled-to (again, unless for some reason the web author kept it invisible)
If the event is not canceled, the default event handler of the event will remove the invisible
attribute of the target. As the event is sent to all searchable invisible ancestors, this means it will remove invisibility of all ancestors, making the element and its ancestors show up.
Note that in most cases, you might not want to rely on the default event handler because it only shows the element (and it's ancestors' subtree). Most likely you'll want to also do make some other elements invisible, or maybe make neighboring elements not invisible etc.
Web authors can cancel the event and do more advanced stuff like showing elements not in the viewport, etc. and also use event delegation on the common ancestors of many invisible elements.
Also note as the event is not composed
, it will not pass through shadow boundaries, so web authors need to make sure if there are invisible
elements inside a shadow tree, the event listener for it is appropriately placed.
Since the contents are in the DOM already, find-in-page should be able to search through the contents of the searchable invisible element. Matches in searchable invisible elements are counted in the total match. They aren't highlighted because they are invisible, but when a certain match needs to be shown (as the active/main/focused match), the activation event activateinvisible
will be dispatched.
When we want to navigate to a searchable invisible element, the activation event activateinvisible
will be dispatched and navigation continues after that.
Crawler bots will be able to see the contents of searchable invisible elements.
Accessibility tree nodes can be constructed from searchable invisible elements in the DOM.
Adding items that needs heavy setup, and handling when it needs to be shown but not ready yet.
listOfExpensiveItems.forEach(expensiveItem => {
// expensiveItems have basic contents that might be useful/needed early. It will do
// expensive operations needed for the full version asynchronously.
// When we do need to show the element we will force the expensive operations.
let nonCompleteElement = document.createElement(expensiveItem.tagName);
nonCompleteElement.setAttribute("invisible", "");
nonCompleteElement.innerHTML = expensiveItem.basicHTMLcontent;
itemsList.appendChild(nonCompleteElement);
requestIdleCallback(expensiveItem.doExpensiveOperation);
nonCompleteElement.addEventListener("activateinvisible", () => {
expensiveItem.finishExpensiveOperation();
});
});
Using activateinvisible
events to load items nearby that aren't fetched yet.
item.addEventListener("activateinvisible", (e) => {
let el = e.activatedElement;
// shouldLoadMoreItemsNearElement returns true if we are nearing the end of currently loaded
// elements, and we should start fetching from server etc to add more elements to the list.
if (shouldLoadMoreItemsNearElement(el)) {
loadItemsAfter(el).then((newItems) => insertAsInvisibleElements(items));
}
});
Upgrading custom elements in static
subtrees asynchronously.
let nonUpgradedElement = document.createElement("my-element");
nonUpgradedElement.setAttribute("invisible", "static");
itemsList.appendChild(nonUpgradedElement);
nonUpgradedElements.push(item);
...
function upgradeElements(deadline) {
let i = 0;
for (i = 0; i < nonUpgradedElements.length && deadline.timeRemaining() > 0; ++i){
customElements.upgrade(nonUpgradedElements[i]);
}
if (i < nonUpgradedElements.length) {
nonUpgradedElements = nonUpgradedElements.slice(i);
requestIdleCallback(upgradeElements);
}
}
Detecting an element is not in the viewport anymore and adding invisibility to it.
TODO