AMP, iOS, Scrolling Redo 2 — the shadow wrapper approach (experimental)
The problem and the original solution
This article is another follow up on the problem and solution described in the original post AMP, iOS, Scrolling and Position Fixed and the subsequent AMP, iOS, Scrolling and Position Fixed Redo — the wrapper approach.
As a reminder, a documents can be embedded on a page as a scrollable iframe. The structure would normally look as following:
<html>
<head>
<title>I'm a Web Page that embeds a document</title>
<style>
iframe {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
</style>
</head>
<body>
<iframe … width="100%" height="100%"
scrolling="yes"
src="https://cdn.ampproject.org/c/pub1.com/doc1"></iframe>
</body>
</html>
This generally works great in most of browsers. We did try many other approaches, including sizing iframe by the content and scrolling the main document. But they all have significant functional detriments and performance issues. See the original post for details. So the scrollable iframe is usually the best option for document embedding.
However, iOS Safari does not support scrollable iframes. In other words scrolling="yes"
is simply ignored. See this demo. The long-standing iOS Safari issue can be found at bugs.webkit.org/149264. As visible in this bug report, we tried to fix this issue directly in WebKit in collaboration with Igalia team, but our code patches have been languishing in reviews for over a year now. Thus, for the foreseeable future, the only option we have is to employ numerous “hacks”. The latest such hack is described in the AMP, iOS, Scrolling and Position Fixed Redo — the wrapper approach. This article is a variation and an improvement of this hack. Since iOS Safari 11 supports Shadow DOM, it opens additional opportunities to simplify our solution.
The goal is to introduce a scrolling element with minimal observable changes to the original DOM. Some key factors include:
- Keep the DOM structure and CSS unchanged as much as possible. Preserve the existing CSS selectors, such as
html > body {...}
,body > header {...}
, etc. - Preserve
<body>
layout properties, such asdisplay: flex
, etc. - Avoid
position:fixed
bugs when used with-webkit-overflow-scrolling:touch
container. See bugs.webkit.org/154399. - Support header/footer offsetting with minimal performance and layout impact.
The new solution — the shadow wrapper approach
The new solution, currently in the experimentation phase, creates a shadow root inside the <body>
element. The scrolling element is built as part of the shadow root, and the actual <body>
children are distributed inside the scrolling container via a shadow slot.
DOM structure
The new DOM structure, including shadow root content, looks like this:
<html>
<head></head>
<body
style="
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;">
#shadow_root
<div scroller
style="
overflow-y: auto;
-webkit-overflow-scrolling: touch;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
">
<div body
style="
overflow: visible;
<!-- copy CSS from <body> -->
">
<slot></slot>
</div>
</div> <!-- document content -->
</body>
</html>
Here’s what’s going on here:
- The body element and its contents stay mostly untouched.
- The shadow root is created inside the body using the
body.attachShadow()
API. - The body’s shadow root content contains three nested elements: the scroller, the body wrapper, and the default slot element. The scroller is styled with with
overflow:auto
and-webkit-overflow-scrolling:touch
CSS. The scroller contains the body wrapper, which is styled withoverflow:visible
. The slot is used to distribute all original body child nodes inside the body wrapper.
Implementation
The code to implement this solution is straightforward:
const shadowRoot = document.body.attachShadow({mode: 'open'});// Scroller is used for scrolling.
const scroller = document.createElement('div');
scroller.style = 'overflow-y: auto;' +
'-webkit-overflow-scrolling: touch;' +
'...';// The wrapper wraps the body's direct children.
const wrapper = document.createElement('div');
wrapper.style = 'overflow: visible; ...';
scroller.appendChild(wrapper);// Main slot will absorb all undistributed children.
const mainSlot = document.createElement('slot');
wrapper.appendChild(mainSlot);
shadowRoot.appendChild(scroller);// Many body CSS styles need to be copied (re-inherited) from
// the body element to the wrapper.
onBodyChanged(() => {
const styles = getComputedStyle(document.body);
INHERIT_CSS.map(style => {
return {style, value: styles[style]};
}).forEach(sv => {
wrapper.style[sv.style] = sv.value;
});
});
Having to monitor and copy styles from the body
element to the wrapper
is an unfortunate negative aspect of this solution. However, in practice monitoring mutations on one DOM element is not too problematic. The INHERIT_CSS
lists the layout styles that are not otherwise inherited, including display
, padding
, margin
, etc.
For demo, see this jsbin.
Position:fixed problem [Updated]
As a reminder position:fixed
problem refers to fixed elements jumping around during the scroll. This is the consequence of some implementation issues between position:fixed
and -webkit-overflow-scrolling:touch
. This problem is described in detail in the original post. The related iOS Safari bug is bugs.webkit.org/154399.
Unfortunately, this solution still requires the fixed transfer layer. Some recent patches from the Igalia team (see bugs.webkit.org/154399) have improved this issue drastically, but there are still cases where fixed elements jump around very noticeably.
On the positive side, however, the shadow root allows us to solve this problem much simpler. It still requires a surrogate element outside the scroller to represent the transfer layer. But the fixed elements themselves are not needed to be moved into the transfer layer — they simply need to be re-distributed with slots.
In DOM, this looks like this:
<html>
<head></head>
<body style="...">
#shadow_root
<div scroller style="overflow-y: auto; ...">
<div body style="overflow: visible; ...">
<slot></slot>
</div>
</div>
<div fixed-transfer
style="
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;">
<slot name="i-amphtml-fixed-0"></slot>
</div> <!-- document content -->
... <div class="fixed-footer" slot="i-amphtml-fixed-0">
This is a fixed element and it will be distributed
inside a shadow root's "fixed-transfer" container.
</div>
</body>
</html>
Key benefits of this structure are:
- The elements stay where they are supposed to be in the DOM — they are merely distributed using slots. It’s a significant performance benefit.
- All CSS selectors are preserved naturally, the shadow distribution is completely transparent to the document’s styles.
Key benefits
What are the main benefits of this solution?
- The DOM structure is exactly the same. We don’t need to fool the document into thinking it has a new
body
element, etc. - All scrolling details are hidden inside the shadow root. It’s not possible to break the scrolling styles with CSS of the main document.
- Fewer CSS bugs, such as
html > body
bugs. - The elements are NOT actually reparanted. This is a significant performance benefit. This also reduces a risk of iframe reloading due to reparenting.
- While the transfer layer is still needed, this solution is still a significant improvement since there’s no need to reparent fixed elements —only redistribute.
References
- Original solution: AMP, iOS, Scrolling and Position Fixed
- Follow up solution: AMP, iOS, Scrolling and Position Fixed Redo — the wrapper approach
- Webkit bug: IFrame scrolling=yes is ignored in iOS Safari
- Webkit bug: Position fixed is buggy with overflow:auto scrolling
- AMP and Igalia collaboration blog post