diff --git a/Libraries/LibWeb/DOM/Document.h b/Libraries/LibWeb/DOM/Document.h index 4edac037b14..5d4321a0964 100644 --- a/Libraries/LibWeb/DOM/Document.h +++ b/Libraries/LibWeb/DOM/Document.h @@ -872,6 +872,7 @@ public: GC::Ptr active_view_transition() const { return m_active_view_transition; } void set_active_view_transition(GC::Ptr view_transition) { m_active_view_transition = view_transition; } + bool rendering_suppression_for_view_transitions() const { return m_rendering_suppression_for_view_transitions; } void set_rendering_suppression_for_view_transitions(bool value) { m_rendering_suppression_for_view_transitions = value; } GC::Ptr dynamic_view_transition_style_sheet() const { return m_dynamic_view_transition_style_sheet; } void set_show_view_transition_tree(bool value) { m_show_view_transition_tree = value; } diff --git a/Libraries/LibWeb/HTML/EventLoop/EventLoop.cpp b/Libraries/LibWeb/HTML/EventLoop/EventLoop.cpp index b3f910d67df..26028d8ba8d 100644 --- a/Libraries/LibWeb/HTML/EventLoop/EventLoop.cpp +++ b/Libraries/LibWeb/HTML/EventLoop/EventLoop.cpp @@ -344,7 +344,9 @@ void EventLoop::update_the_rendering() if (document.hidden()) return false; - // FIXME: doc's rendering is suppressed for view transitions; or + // doc's rendering is suppressed for view transitions; or + if (document.rendering_suppression_for_view_transitions()) + return false; auto navigable = document.navigable(); if (!navigable) diff --git a/Libraries/LibWeb/HTML/Navigable.cpp b/Libraries/LibWeb/HTML/Navigable.cpp index aa094c96b79..966c4b804b3 100644 --- a/Libraries/LibWeb/HTML/Navigable.cpp +++ b/Libraries/LibWeb/HTML/Navigable.cpp @@ -2456,11 +2456,9 @@ bool Navigable::has_a_rendering_opportunity() const // accounting for hardware refresh rate constraints and user agent throttling for performance reasons, // but considering content presentable even if it's outside the viewport. - // A navigable has no rendering opportunities if its active document is render-blocked - // or if it is suppressed for view transitions; - // otherwise, rendering opportunities are determined based on hardware constraints + // A navigable's rendering opportunities are determined based on hardware constraints // such as display refresh rates and other factors such as page performance - // or whether the document's visibility state is "visible". + // or whether its active document's visibility state is "visible". // Rendering opportunities typically occur at regular intervals. // FIXME: Return `false` here if we're an inactive browser tab. diff --git a/Libraries/LibWeb/ViewTransition/ViewTransition.cpp b/Libraries/LibWeb/ViewTransition/ViewTransition.cpp index ba66f9fbb5a..50823fc0b75 100644 --- a/Libraries/LibWeb/ViewTransition/ViewTransition.cpp +++ b/Libraries/LibWeb/ViewTransition/ViewTransition.cpp @@ -232,7 +232,7 @@ ErrorOr ViewTransition::capture_the_old_state() return Error::from_string_literal("The snapshot containing block is too large."); // 6. Set transition’s initial snapshot containing block size to the snapshot containing block size. - m_initial_snapshot_containing_block_size = CSSPixelSize { snapshot_containing_block.width().raw_value(), snapshot_containing_block.height().raw_value() }; + m_initial_snapshot_containing_block_size = snapshot_containing_block.size(); // 7. For each element of every element that is connected, and has a node document equal to document, in paint // order: @@ -856,8 +856,8 @@ ErrorOr ViewTransition::update_pseudo_element_styles() // 1. Return failure if any of the following conditions is true: // - capturedElement’s new element has a flat tree ancestor that skips its contents. - for (auto* ancestor = captured_element->new_element->parent(); ancestor; ancestor = ancestor->parent()) { - if (as(*ancestor).skips_its_contents()) + for (auto ancestor = captured_element->new_element->parent_element(); ancestor; ancestor = ancestor->parent_element()) { + if (ancestor->skips_its_contents()) return Error::from_string_literal("capturedElement’s new element has a flat tree ancestor that skips its contents."); } diff --git a/Tests/LibWeb/Text/expected/wpt-import/css/css-view-transitions/no-raf-while-render-blocked.txt b/Tests/LibWeb/Text/expected/wpt-import/css/css-view-transitions/no-raf-while-render-blocked.txt new file mode 100644 index 00000000000..27b0a30df70 --- /dev/null +++ b/Tests/LibWeb/Text/expected/wpt-import/css/css-view-transitions/no-raf-while-render-blocked.txt @@ -0,0 +1,6 @@ +Harness status: OK + +Found 1 tests + +1 Pass +Pass rAF is blocked until prepare callback \ No newline at end of file diff --git a/Tests/LibWeb/Text/input/wpt-import/css/css-view-transitions/no-raf-while-render-blocked.html b/Tests/LibWeb/Text/input/wpt-import/css/css-view-transitions/no-raf-while-render-blocked.html new file mode 100644 index 00000000000..bff25e285f9 --- /dev/null +++ b/Tests/LibWeb/Text/input/wpt-import/css/css-view-transitions/no-raf-while-render-blocked.html @@ -0,0 +1,50 @@ + + + +View transitions: rAF is not issued while render-blocked + + + + + + + + + +
+ + diff --git a/Tests/LibWeb/Text/input/wpt-import/dom/events/scrolling/scroll_support.js b/Tests/LibWeb/Text/input/wpt-import/dom/events/scrolling/scroll_support.js new file mode 100644 index 00000000000..cc6fff31e5f --- /dev/null +++ b/Tests/LibWeb/Text/input/wpt-import/dom/events/scrolling/scroll_support.js @@ -0,0 +1,347 @@ +async function waitForEvent(eventName, test, target, timeoutMs = 500) { + return new Promise((resolve, reject) => { + const timeoutCallback = test.step_timeout(() => { + reject(`No ${eventName} event received for target ${target}`); + }, timeoutMs); + target.addEventListener(eventName, (evt) => { + clearTimeout(timeoutCallback); + resolve(evt); + }, { once: true }); + }); +} + +async function waitForScrollendEvent(test, target, timeoutMs = 500) { + return waitForEvent("scrollend", test, target, timeoutMs); +} + +async function waitForScrollendEventNoTimeout(target) { + return new Promise((resolve) => { + target.addEventListener("scrollend", resolve); + }); +} + +// Waits until a rAF callback with no "scroll" event in the last 200ms. +function waitForDelayWithoutScrollEvent(eventTarget) { + const TIMEOUT_IN_MS = 200; + + return new Promise(resolve => { + let lastScrollEventTime = performance.now(); + + const scrollListener = () => { + lastScrollEventTime = performance.now(); + }; + eventTarget.addEventListener('scroll', scrollListener); + + const tick = () => { + if (performance.now() - lastScrollEventTime > TIMEOUT_IN_MS) { + eventTarget.removeEventListener('scroll', scrollListener); + resolve(); + return; + } + requestAnimationFrame(tick); // wait another frame + } + requestAnimationFrame(tick); + }); +} + +// Waits for the end of scrolling. Uses the "scrollend" event if available. +// Otherwise, fall backs to waitForDelayWithoutScrollEvent(). +function waitForScrollEndFallbackToDelayWithoutScrollEvent(eventTargets) { + return new Promise(resolve => { + if (!Array.isArray(eventTargets)) { + eventTargets = [eventTargets]; + } + let listeners = []; + const cleanup = () => { + for (const [eventTarget, eventName, listener] of listeners) { + eventTarget.removeEventListener(eventName, listener); + } + listeners = []; + } + const addListener = (eventTarget, eventName, listener) => { + listeners.push([eventTarget, eventName, listener]); + eventTarget.addEventListener(eventName, listener); + } + if (window.onscrollend !== undefined) { + // If scrollend is supported, wait for the first scrollend event. + for (const eventTarget of eventTargets) { + addListener(eventTarget, 'scrollend', () => { + cleanup(); + resolve(eventTarget); + }); + } + } else { + // Otherwise, wait for the first scroll event, then wait until that + // scroller finishes scrolling. + for (const eventTarget of eventTargets) { + addListener(eventTarget, 'scroll', async () => { + cleanup(); + await waitForDelayWithoutScrollEvent(eventTarget); + resolve(eventTarget); + }); + } + } + }); +} + +async function waitForPointercancelEvent(test, target, timeoutMs = 500) { + return waitForEvent("pointercancel", test, target, timeoutMs); +} + +// Resets the scroll position to (0,0). If a scroll is required, then the +// promise is not resolved until the scrollend event is received. +async function waitForScrollReset(test, scroller, x = 0, y = 0) { + return new Promise(resolve => { + if (scroller.scrollLeft == x && scroller.scrollTop == y) { + resolve(); + } else { + const eventTarget = + scroller == document.scrollingElement ? document : scroller; + scroller.scrollTo(x, y); + waitForScrollendEventNoTimeout(eventTarget).then(resolve); + } + }); +} + +async function createScrollendPromiseForTarget(test, + target_div, + timeoutMs = 500, + targetIsRoot = false) { + return waitForScrollendEvent(test, target_div, timeoutMs).then(evt => { + assert_false(evt.cancelable, 'Event is not cancelable'); + if (targetIsRoot) { + assert_true(evt.bubbles, 'Event targeting element does not bubble'); + } else { + assert_false(evt.bubbles, 'Event targeting element does not bubble'); + } + }); +} + +function verifyNoScrollendOnDocument(test) { + const callback = + test.unreached_func("window got unexpected scrollend event."); + window.addEventListener('scrollend', callback); + test.add_cleanup(() => { + window.removeEventListener('scrollend', callback); + }); +} + +async function verifyScrollStopped(test, target_div) { + const unscaled_pause_time_in_ms = 100; + const x = target_div.scrollLeft; + const y = target_div.scrollTop; + return new Promise(resolve => { + test.step_timeout(() => { + assert_equals(target_div.scrollLeft, x); + assert_equals(target_div.scrollTop, y); + resolve(); + }, unscaled_pause_time_in_ms); + }); +} + +async function resetTargetScrollState(test, target_div) { + if (target_div.scrollTop != 0 || target_div.scrollLeft != 0) { + target_div.scrollTop = 0; + target_div.scrollLeft = 0; + return waitForScrollendEvent(test, target_div); + } +} + +const MAX_FRAME = 700; +const MAX_UNCHANGED_FRAMES = 20; + +// Returns a promise that resolves when the given condition is met or rejects +// after MAX_FRAME animation frames. +// TODO(crbug.com/1400399): deprecate. We should not use frame based waits in +// WPT as frame rates may vary greatly in different testing environments. +function waitFor(condition, error_message = 'Reaches the maximum frames.') { + return new Promise((resolve, reject) => { + function tick(frames) { + // We requestAnimationFrame either for MAX_FRAM frames or until condition + // is met. + if (frames >= MAX_FRAME) + reject(error_message); + else if (condition()) + resolve(); + else + requestAnimationFrame(tick.bind(this, frames + 1)); + } + tick(0); + }); +} + +// TODO(crbug.com/1400446): Test driver should defer sending events until the +// browser is ready. Also the term compositor-commit is misleading as not all +// user-agents use a compositor process. +function waitForCompositorCommit() { + return new Promise((resolve) => { + // rAF twice. + window.requestAnimationFrame(() => { + window.requestAnimationFrame(resolve); + }); + }); +} + +// Please don't remove this. This is necessary for chromium-based browsers. It +// can be a no-op on user-agents that do not have a separate compositor thread. +// TODO(crbug.com/1509054): This shouldn't be necessary if the test harness +// deferred running the tests until after paint holding. +async function waitForCompositorReady() { + const animation = + document.body.animate({ opacity: [ 0, 1 ] }, {duration: 1 }); + return animation.finished; +} + +function waitForNextFrame() { + const startTime = performance.now(); + return new Promise(resolve => { + window.requestAnimationFrame((frameTime) => { + if (frameTime < startTime) { + window.requestAnimationFrame(resolve); + } else { + resolve(); + } + }); + }); +} + +// TODO(crbug.com/1400399): Deprecate as frame rates may vary greatly in +// different test environments. +function waitForAnimationEnd(getValue) { + var last_changed_frame = 0; + var last_position = getValue(); + return new Promise((resolve, reject) => { + function tick(frames) { + // We requestAnimationFrame either for MAX_FRAME or until + // MAX_UNCHANGED_FRAMES with no change have been observed. + if (frames >= MAX_FRAME || frames - last_changed_frame > MAX_UNCHANGED_FRAMES) { + resolve(); + } else { + current_value = getValue(); + if (last_position != current_value) { + last_changed_frame = frames; + last_position = current_value; + } + requestAnimationFrame(tick.bind(this, frames + 1)); + } + } + tick(0); + }) +} + +// Scrolls in target according to move_path with pauses in between +// The move_path should contains coordinates that are within target boundaries. +// Keep in mind that 0,0 is the center of the target element and is also +// the pointerDown position. +// pointerUp() is fired after sequence of moves. +function touchScrollInTargetSequentiallyWithPause(target, move_path, pause_time_in_ms = 100) { + const test_driver_actions = new test_driver.Actions() + .addPointer("pointer1", "touch") + .pointerMove(0, 0, {origin: target}) + .pointerDown(); + + const substeps = 5; + let x = 0; + let y = 0; + // Do each move in 5 steps + for(let move of move_path) { + let step_x = (move.x - x) / substeps; + let step_y = (move.y - y) / substeps; + for(let step = 0; step < substeps; step++) { + x += step_x; + y += step_y; + test_driver_actions.pointerMove(x, y, {origin: target}); + } + test_driver_actions.pause(pause_time_in_ms); // To prevent inertial scroll + } + + return test_driver_actions.pointerUp().send(); +} + +function touchScrollInTarget(pixels_to_scroll, target, direction, pause_time_in_ms = 100) { + var x_delta = 0; + var y_delta = 0; + const num_movs = 5; + if (direction == "down") { + y_delta = -1 * pixels_to_scroll / num_movs; + } else if (direction == "up") { + y_delta = pixels_to_scroll / num_movs; + } else if (direction == "right") { + x_delta = -1 * pixels_to_scroll / num_movs; + } else if (direction == "left") { + x_delta = pixels_to_scroll / num_movs; + } else { + throw("scroll direction '" + direction + "' is not expected, direction should be 'down', 'up', 'left' or 'right'"); + } + return new test_driver.Actions() + .addPointer("pointer1", "touch") + .pointerMove(0, 0, {origin: target}) + .pointerDown() + .pointerMove(x_delta, y_delta, {origin: target}) + .pointerMove(2 * x_delta, 2 * y_delta, {origin: target}) + .pointerMove(3 * x_delta, 3 * y_delta, {origin: target}) + .pointerMove(4 * x_delta, 4 * y_delta, {origin: target}) + .pointerMove(5 * x_delta, 5 * y_delta, {origin: target}) + .pause(pause_time_in_ms) + .pointerUp() + .send(); +} + +// Trigger fling by doing pointerUp right after pointerMoves. +function touchFlingInTarget(pixels_to_scroll, target, direction) { + return touchScrollInTarget(pixels_to_scroll, target, direction, 0 /* pause_time */); +} + +function mouseActionsInTarget(target, origin, delta, pause_time_in_ms = 100) { + return new test_driver.Actions() + .addPointer("pointer1", "mouse") + .pointerMove(origin.x, origin.y, { origin: target }) + .pointerDown() + .pointerMove(origin.x + delta.x, origin.y + delta.y, { origin: target }) + .pointerMove(origin.x + delta.x * 2, origin.y + delta.y * 2, { origin: target }) + .pause(pause_time_in_ms) + .pointerUp() + .send(); +} + +// Returns a promise that resolves when the given condition holds for 10 +// animation frames or rejects if the condition changes to false within 10 +// animation frames. +// TODO(crbug.com/1400399): Deprecate as frame rates may very greatly in +// different test environments. +function conditionHolds(condition, error_message = 'Condition is not true anymore.') { + const MAX_FRAME = 10; + return new Promise((resolve, reject) => { + function tick(frames) { + // We requestAnimationFrame either for 10 frames or until condition is + // violated. + if (frames >= MAX_FRAME) + resolve(); + else if (!condition()) + reject(error_message); + else + requestAnimationFrame(tick.bind(this, frames + 1)); + } + tick(0); + }); +} + +function scrollElementDown(element, scroll_amount) { + let x = 0; + let y = 0; + let delta_x = 0; + let delta_y = scroll_amount; + let actions = new test_driver.Actions() + .scroll(x, y, delta_x, delta_y, {origin: element}); + return actions.send(); +} + +function scrollElementLeft(element, scroll_amount) { + let x = 0; + let y = 0; + let delta_x = scroll_amount; + let delta_y = 0; + let actions = new test_driver.Actions() + .scroll(x, y, delta_x, delta_y, {origin: element}); + return actions.send(); +}