Skip to content

Commit

Permalink
[scroll-animations] Handle changes to selector(#id) references
Browse files Browse the repository at this point in the history
An @scroll-timeline rule may reference elements with selector(#id)
syntax. Whenever the element represented by the selector() function
changes, the "effective" CSSScrollTimeline produced by the
@scroll-timeline rule changes as well.

This CL solves this problem by registering IdTargetObservers
for the IDs a CSSScrollTimeline depends on. The IdTargetObservers
are registered when at least one Animation is attached, and
unregistered when the last Animation is detached. This is
similar to how the ScrollTimeline itself is registered/
unregistered with the resolved scroll source when Animations
attach/detach.

Marking the animation target elements for style recalc
(non-animation-style-change) means we'll re-evaluate the
@scroll-timeline rule against the current DOM state, and consider
whether or not we need a new CSSScrollTimeline for the animation.

Bug: 1074052
Change-Id: Iab0c6b8d8b57d7d63283e97355ee5d9948b831a0
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2356506
Commit-Queue: Anders Hartvoll Ruud <[email protected]>
Reviewed-by: Kevin Ellis <[email protected]>
Reviewed-by: Fredrik Söderquist <[email protected]>
Reviewed-by: Rune Lillesveen <[email protected]>
Cr-Commit-Position: refs/heads/master@{#821724}
  • Loading branch information
andruud authored and chromium-wpt-export-bot committed Oct 28, 2020
1 parent 6770e23 commit 326f137
Show file tree
Hide file tree
Showing 2 changed files with 395 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
<!DOCTYPE html>
<title>@scroll-timeline element offset invalidation</title>
<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#typedef-element-offset">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/web-animations/testcommon.js"></script>
<style>
#scroller {
overflow: scroll;
width: 100px;
height: 100px;
}
#scroller > div {
height: 50px;
}
@keyframes expand {
from { width: 100px; }
to { width: 200px; }
}
@scroll-timeline timeline {
source: selector(#scroller);
time-range: 1e10s;
start: selector(#offset1) end;
end: selector(#offset2) end;
}
#element {
width: 0px;
height: 20px;
animation: expand 1e10s linear;
animation-timeline: timeline;
}
/* Ensure stable expectations if feature is not supported */
@supports not (animation-timeline:foo) {
#element { animation-play-state: paused; }
}
</style>
<div id=scroller></div>
<div id=element></div>
<p class=sibling1></p>
<p class=sibling2></p>
<script>

function setup() {
while (scroller.firstChild)
scroller.firstChild.remove();
for (let i = 0; i < 10; i++)
scroller.append(document.createElement('div'));
}

// The contents of the scroller look like this:
//
// +-------+
// | 50px | div (0)
// +-------+
// +-------+
// | 50px | div (1)
// +-------+
// +-------+
// | 50px | div (2)
// +-------+
// +-------+
// | 50px | div (3)
// +-------+
// +-------+
// | 50px | div (4)
// +-------+
// +-------+
// | 50px | div (5)
// +-------+
// +-------+
// | 50px | div (6)
// +-------+
// +-------+
// | 50px | div (7)
// +-------+
// +-------+
// | 50px | div (8)
// +-------+
// +-------+
// | 50px | div (9)
// +-------+
//
// The height of the scrollport is 100px.

function invalidation_test(func, description) {
promise_test(async (t) => {
setup();
await func();
}, description);
}

function remove(id) {
let old_element = document.getElementById(id);
if (old_element)
old_element.removeAttribute('id');
}

function reassign(id, element) {
remove(id);
element.setAttribute('id', id);
}

async function assert_element_width_at_scroll(expected_width, scroll) {
scroller.scrollTop = scroll;
await waitForNextFrame();
assert_equals(getComputedStyle(element).width, expected_width);
}

invalidation_test(async () => {
await assert_element_width_at_scroll('0px', 0);
}, 'Offsets missing');

invalidation_test(async () => {
// [50, 150]
reassign('offset1', scroller.children[3]);
reassign('offset2', scroller.children[5]);
await assert_element_width_at_scroll('150px', 100);

// [100, 150]
reassign('offset1', scroller.children[4]);
await assert_element_width_at_scroll('100px', 100);
}, 'Change first offset');

invalidation_test(async () => {
// [50, 150]
reassign('offset1', scroller.children[3]);
reassign('offset2', scroller.children[5]);
await assert_element_width_at_scroll('150px', 100);

// [50, 250]
reassign('offset2', scroller.children[7]);
await assert_element_width_at_scroll('125px', 100);
}, 'Change second offset');

invalidation_test(async () => {
// [50, 250]
reassign('offset1', scroller.children[3]);
reassign('offset2', scroller.children[7]);
await assert_element_width_at_scroll('125px', 100);

// [0, 200]
reassign('offset1', scroller.children[2]);
reassign('offset2', scroller.children[4]);
await assert_element_width_at_scroll('150px', 50);
}, 'Change both offsets');

invalidation_test(async () => {
// [50, 150]
reassign('offset1', scroller.children[3]);
reassign('offset2', scroller.children[5]);
await assert_element_width_at_scroll('150px', 100);

remove('offset1');
await assert_element_width_at_scroll('0px', 0);
}, 'Remove first offset');

invalidation_test(async () => {
// [50, 150]
reassign('offset1', scroller.children[3]);
reassign('offset2', scroller.children[5]);
await assert_element_width_at_scroll('150px', 100);

remove('offset2');
await assert_element_width_at_scroll('0px', 0);
}, 'Remove second offset');

invalidation_test(async () => {
// [50, 150]
reassign('offset1', scroller.children[3]);
reassign('offset2', scroller.children[5]);
await assert_element_width_at_scroll('150px', 100);

remove('offset1');
remove('offset2');
await assert_element_width_at_scroll('0px', 0);
}, 'Remove both offsets');

invalidation_test(async () => {
// [50, 150]
reassign('offset1', scroller.children[3]);
reassign('offset2', scroller.children[5]);
await assert_element_width_at_scroll('150px', 100);

reassign('offset1', document.querySelector('.sibling1'));
await assert_element_width_at_scroll('0px', 0);
}, 'Reassign first offset to sibling of scroller');

invalidation_test(async () => {
// [50, 150]
reassign('offset1', scroller.children[3]);
reassign('offset2', scroller.children[5]);
await assert_element_width_at_scroll('150px', 100);

reassign('offset2', document.querySelector('.sibling2'));
await assert_element_width_at_scroll('0px', 0);
}, 'Reassign second offset to sibling of scroller');

invalidation_test(async () => {
// [50, 150]
reassign('offset1', scroller.children[3]);
reassign('offset2', scroller.children[5]);
await assert_element_width_at_scroll('150px', 100);

reassign('offset1', document.querySelector('.sibling1'));
reassign('offset2', document.querySelector('.sibling2'));
await assert_element_width_at_scroll('0px', 0);
}, 'Reassign both offsets to sibling of scroller');
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
<!DOCTYPE html>
<title>@scroll-timeline source invalidation</title>
<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#scroll-timeline-at-rule">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/web-animations/testcommon.js"></script>
<style>
#scrollers {
overflow: hidden;
height: 0;
}
.scroller {
overflow: scroll;
width: 100px;
height: 100px;
}
.contents {
height: 200px;
}
@keyframes expand {
from { width: 100px; }
to { width: 200px; }
}
@scroll-timeline timeline {
source: selector(#scroller);
time-range: 1e10s;
start: 0px;
end: 100px;
}
#element {
width: 0px;
height: 20px;
animation: expand 1e10s linear;
animation-timeline: timeline;
}
/* Ensure stable expectations if feature is not supported */
@supports not (animation-timeline:foo) {
#element { animation-play-state: paused; }
}
</style>
<div id=scrollers></div>
<div id=element></div>
<script>

function createScroller() {
let scroller = document.createElement('div');
let contents = document.createElement('div');
scroller.classList.add('scroller');
contents.classList.add('contents');
scroller.append(contents);
return scroller;
}

function wrapInDiv(element) {
let div = document.createElement('div');
div.append(element);
return div;
}

function scrollerAt(n) {
return document.querySelectorAll('.scroller')[n];
}

// Resets #scrollers to a state where it has three .scroller children with
// scrollTop offsets 10, 20 and 30.
function cleanup() {
while (scrollers.firstChild)
scrollers.firstChild.remove();

for (let i = 0; i < 3; i++)
scrollers.append(createScroller());

scrollerAt(0).scrollTop = 10;
scrollerAt(1).scrollTop = 20;
scrollerAt(2).scrollTop = 30;
}

// Do an initial "cleanup" to set up the first test.
cleanup();

function invalidation_test(func, description) {
promise_test(async (t) => {
t.add_cleanup(cleanup);
await func();
}, description);
}

invalidation_test(() => {
assert_equals(getComputedStyle(element).width, '0px');
}, 'Nonexistent source');

invalidation_test(() => {
assert_equals(getComputedStyle(element).width, '0px');
scrollerAt(0).setAttribute('id', 'scroller');
assert_equals(getComputedStyle(element).width, '110px');
scrollerAt(1).setAttribute('id', 'scroller'); // No effect
assert_equals(getComputedStyle(element).width, '110px');
scrollerAt(2).setAttribute('id', 'scroller'); // No effect
assert_equals(getComputedStyle(element).width, '110px');
}, 'Setting id attribute');

invalidation_test(() => {
assert_equals(getComputedStyle(element).width, '0px');
scrollerAt(0).setAttribute('id', 'scroller');
assert_equals(getComputedStyle(element).width, '110px');
scrollerAt(0).removeAttribute('id');
assert_equals(getComputedStyle(element).width, '0px');
}, 'Removing id attribute');

invalidation_test(() => {
assert_equals(getComputedStyle(element).width, '0px');
scrollerAt(2).setAttribute('id', 'scroller');
assert_equals(getComputedStyle(element).width, '130px');
scrollerAt(1).setAttribute('id', 'scroller');
assert_equals(getComputedStyle(element).width, '120px');
scrollerAt(0).setAttribute('id', 'scroller');
assert_equals(getComputedStyle(element).width, '110px');
}, 'Setting id attribute earlier in the tree');

invalidation_test(async () => {
assert_equals(getComputedStyle(element).width, '0px');

// Appending a new element with id 'scroller' already set before
// insertion into the tree.
let scroller = createScroller();
scroller.setAttribute('id', 'scroller');
scrollers.append(scroller);

// Make sure |scroller| has a layout box.
//
// https://drafts.csswg.org/scroll-animations-1/#avoiding-cycles
//
// TODO: Depending on the outcome of Issue 5261, the call to offsetTop
// might be unnecessary.
// https://github.com/w3c/csswg-drafts/issues/5261
scroller.offsetTop;
await waitForNextFrame();

assert_equals(getComputedStyle(element).width, '100px');
}, 'Appending a new element');

invalidation_test(async () => {
assert_equals(getComputedStyle(element).width, '0px');

let scroller = createScroller();
scroller.setAttribute('id', 'scroller');
scrollers.append(wrapInDiv(wrapInDiv(scroller)));
await waitForNextFrame();

assert_equals(getComputedStyle(element).width, '100px');
}, 'Inserting a subtree with #scroller descendant');

invalidation_test(() => {
assert_equals(getComputedStyle(element).width, '0px');

scrollerAt(0).setAttribute('id', 'scroller');
scrollerAt(1).setAttribute('id', 'scroller');
scrollerAt(2).setAttribute('id', 'scroller');
assert_equals(getComputedStyle(element).width, '110px');

scrollerAt(0).remove();
assert_equals(getComputedStyle(element).width, '120px');

scrollerAt(0).remove();
assert_equals(getComputedStyle(element).width, '130px');

scrollerAt(0).remove();
assert_equals(getComputedStyle(element).width, '0px');
}, 'Removing source element');

invalidation_test(async () => {
assert_equals(getComputedStyle(element).width, '0px');

// Create a chain: #scrollers -> div -> div -> #scroller
let scroller = createScroller();
let div = wrapInDiv(wrapInDiv(scroller));
scrollers.append(div);
scroller.setAttribute('id', 'scroller');
scroller.scrollTop = 50;
await waitForNextFrame();
assert_equals(getComputedStyle(element).width, '150px');

div.remove();
assert_equals(getComputedStyle(element).width, '0px');
}, 'Removing ancestor of source element');

</script>

0 comments on commit 326f137

Please sign in to comment.