Skip to content

layout: Support content: <image> on non-pseudo-elements#41480

Merged
mrobinson merged 1 commit intoservo:mainfrom
andreubotella:issue-41479
Jan 28, 2026
Merged

layout: Support content: <image> on non-pseudo-elements#41480
mrobinson merged 1 commit intoservo:mainfrom
andreubotella:issue-41479

Conversation

@andreubotella
Copy link
Copy Markdown
Contributor

@andreubotella andreubotella commented Dec 22, 2025

Per CSS-CONTENT-3, one of the possible values of the content CSS property is <content-replacement>, which evaluates to a single <image>. This value is also allowed on regular elements, not just on pseudo-elements, and it will make the element into a replaced element representing the given image, discarding its contents.

This patch implements this in traverse_element: if the display value is not none or contents, we first check whether the contents property should make the element replaced, and if it shouldn't, then we check whether the element itself is replaced or a widget.

Per the spec, an invalid image must be treated as representing a transparent black image with zero natural width and height – in particular, it must not show a broken image icon. We added the method ReplacedContents::zero_sized_invalid_image to implement this.

This patch adds support for image URL references, but not for color gradients, which are treated as invalid images. The reason for this is that currently Servo does not support gradients in ReplacedContentKind. This is left as a follow-up change.

Testing: Some of the existing css/css-content/element-replacement* WPT tests now pass with this patch. We also added some new ones dealing with replacing the document root.

Fixes: #41479

@servo-highfive servo-highfive added the S-awaiting-review There is new code that needs to be reviewed. label Dec 22, 2025
@mrobinson mrobinson added the T-linux-wpt Do a try run of the WPT label Dec 22, 2025
@github-actions github-actions bot removed the T-linux-wpt Do a try run of the WPT label Dec 22, 2025
@github-actions
Copy link
Copy Markdown

🔨 Triggering try run (#20443356076) for Linux (WPT)

Copy link
Copy Markdown
Member

@mrobinson mrobinson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great stuff. Question below:

Comment on lines +202 to +219
// If `content` is a single image URL, the box gets replaced with a
// replaced image.
let contents = match info.style.clone_content() {
Content::Items(GenericContentItems { items, .. }) if items.len() == 1 => {
if let GenericContentItem::Image(img) = &items[0] {
match ReplacedContents::from_image(element, context, img) {
Some(replaced) => Contents::Replaced(replaced),
// Invalid images are treated as zero-sized.
None => Contents::Replaced(ReplacedContents::zero_sized_invalid_image(
element,
)),
}
} else {
Contents::for_element(element, context)
}
},
_ => Contents::for_element(element, context),
};
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this go into Contents::from_element. For instance, does content: <image> work on the root?

Copy link
Copy Markdown
Contributor Author

@andreubotella andreubotella Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. The spec doesn't say it shouldn't work on the root; and it does work in Chromium and Webkit, but not in Firefox. I think it makes sense to make it work on the root, and so to move it into Contents::for_element, although I'd have to add WPT tests for it.

(By the way, if we replace the root in a quirks mode document, Chromium renders the image at its natural size, but Webkit stretches it vertically to fit the viewport if the image is smaller. This seems to be caused by the "html element fills the viewport" quirk, but shouldn't that also stretch the width? But anyway, Servo doesn't seem to implement this quirk, so it's probably not worth worrying too much about it.)

@github-actions
Copy link
Copy Markdown

Test results for linux-wpt from try job (#20443356076):

Flaky unexpected result (32)
  • OK /_mozilla/css/offset_properties_inline.html (#40543)
    • FAIL [expected PASS] subtest: offsetTop

      assert_equals: offsetTop of #inline-1 should be 0. expected 0 but got -1
      

    • FAIL [expected PASS] subtest: offsetLeft

      assert_equals: offsetLeft of #inline-2 should be 40. expected 40 but got 25
      

  • OK /_mozilla/webxr/create_session.https.html
    • FAIL [expected PASS] subtest: create_session

      can't access property "simulateDeviceConnection", navigator.xr.test is undefined
      

  • OK /_mozilla/webxr/obtain_frame.https.html
    • FAIL [expected PASS] subtest: obtain_frame

      promise_test: Unhandled rejection with value: object "TypeError: can't access property "simulateDeviceConnection", navigator.xr.test is undefined"
      

  • OK /_webgl/conformance/textures/misc/texture-upload-size.html (#21770)
    • PASS [expected FAIL] subtest: WebGL test #45
    • PASS [expected FAIL] subtest: WebGL test #47
    • PASS [expected FAIL] subtest: WebGL test #49
    • PASS [expected FAIL] subtest: WebGL test #51
    • FAIL [expected PASS] subtest: WebGL test #53

      assert_true: Texture was smaller than the expected size 2x2 expected true got false
      

    • FAIL [expected PASS] subtest: WebGL test #55

      assert_true: getError expected: INVALID_VALUE. Was NO_ERROR : when calling texSubImage2D with the same texture upload with offset 1, 1 expected true got false
      

    • FAIL [expected PASS] subtest: WebGL test #57

      assert_true: Texture was smaller than the expected size 2x2 expected true got false
      

    • FAIL [expected PASS] subtest: WebGL test #59

      assert_true: getError expected: INVALID_VALUE. Was NO_ERROR : when calling texSubImage2D with the same texture upload with offset 1, 1 expected true got false
      

    • PASS [expected FAIL] subtest: WebGL test #61
    • PASS [expected FAIL] subtest: WebGL test #63
    • And 6 more unexpected results...
  • TIMEOUT /content-security-policy/inheritance/location-reload.html (#38983)
    • FAIL [expected PASS] subtest: location.reload() of empty iframe.

      assert_equals: Image should be blocked by CSP after reload. expected "img blocked" but got "img loaded"
      

  • FAIL [expected PASS] /css/css-backgrounds/background-size-042.html
  • FAIL [expected PASS] /css/css-backgrounds/border-image-repeat-space-9.html
  • OK /css/css-cascade/layer-cssom-order-reverse.html (#36094)
    • PASS [expected FAIL] subtest: Delete layer invalidates @font-face
  • OK /custom-elements/form-associated/ElementInternals-setFormValue.html (#29174)
    • PASS [expected FAIL] subtest: Single value - empty name exists
  • TIMEOUT [expected OK] /fetch/api/redirect/redirect-keepalive.https.any.html (#32153)
    • TIMEOUT [expected PASS] subtest: [keepalive][iframe][load] mixed content redirect; setting up

      Test timed out
      

  • OK /fetch/content-length/api-and-duplicate-headers.any.worker.html (#35197)
    • FAIL [expected PASS] subtest: fetch() and duplicate Content-Length/Content-Type headers

      promise_test: Unhandled rejection with value: object "TypeError: Network error occurred"
      

  • OK /html/browsers/browsing-the-web/navigating-across-documents/009.html (#24456)
    • PASS [expected FAIL] subtest: Link with onclick form submit to javascript url with document.write and href navigation
  • OK /html/browsers/browsing-the-web/navigating-across-documents/initial-empty-document/load-pageshow-events-window-open.html (#28691)
    • PASS [expected FAIL] subtest: load event does not fire on window.open('about:blank')
  • CRASH [expected OK] /html/browsers/sandboxing/sandbox-initial-empty-document-toward-same-origin.html (#35948)
  • OK /html/semantics/document-metadata/the-meta-element/pragma-directives/attr-meta-http-equiv-refresh/allow-scripts-flag-changing-2.html (#39703)
    • FAIL [expected PASS] subtest: Meta refresh of the original iframe is not blocked if moved into a sandboxed iframe

      uncaught exception: Error: assert_unreached: The iframe into which the meta was moved must not refresh Reached unreachable code
      

  • TIMEOUT /html/semantics/embedded-content/media-elements/autoplay-allowed-by-feature-policy.https.sub.html (#41404)
    • PASS [expected TIMEOUT] subtest: Feature-Policy header: autoplay * allows same-origin iframes.
  • OK /html/semantics/embedded-content/the-iframe-element/iframe-loading-lazy-nav-link-click.html (#32664)
    • FAIL [expected PASS] subtest: Navigating iframe loading='lazy' before it is loaded: link click

      uncaught exception: Error: assert_equals: expected "http://web-platform.test:8000/html/semantics/embedded-content/the-iframe-element/support/blank.htm?nav" but got "http://web-platform.test:8000/html/semantics/embedded-content/the-iframe-element/support/blank.htm?src"
      

  • TIMEOUT [expected OK] /html/semantics/embedded-content/the-iframe-element/iframe_sandbox_navigate_other_frame_popup.sub.html (#39702)
    • TIMEOUT [expected FAIL] subtest: Sandboxed iframe can not navigate other frame's popup

      Test timed out
      

  • OK /html/semantics/forms/form-submission-0/multipart-formdata.window.html (#28725)
    • PASS [expected FAIL] subtest: multipart/form-data: 0x00 in value (formdata event)
    • PASS [expected FAIL] subtest: multipart/form-data: \n in value (formdata event)
  • OK /html/semantics/forms/form-submission-0/text-plain.window.html (#28687)
    • FAIL [expected PASS] subtest: text/plain: Basic File test (normal form)

      assert_equals: expected "basic=file-test.txt\r\n" but got ""
      

    • PASS [expected FAIL] subtest: text/plain: 0x00 in name (normal form)
    • PASS [expected FAIL] subtest: text/plain: 0x00 in value (normal form)
    • PASS [expected FAIL] subtest: text/plain: \r\n in name (normal form)
    • PASS [expected FAIL] subtest: text/plain: \r\n in value (formdata event)
  • OK /html/semantics/forms/form-submission-0/urlencoded2.window.html (#28687)
    • FAIL [expected PASS] subtest: application/x-www-form-urlencoded: Basic File test (normal form)

      assert_equals: expected "basic=file-test.txt" but got ""
      

    • PASS [expected FAIL] subtest: application/x-www-form-urlencoded: 0x00 in name (formdata event)
  • ERROR [expected TIMEOUT] /html/semantics/forms/form-submission-target/rel-base-target.html (#40379)
  • OK /html/semantics/scripting-1/the-script-element/execution-timing/077.html (#22139)
    • FAIL [expected PASS] subtest: adding several types of scripts through the DOM and removing some of them confuses scheduler

      assert_array_equals: expected property 1 to be "Script #1 ran" but got "Script #3 ran" (expected array ["Script #2 ran", "Script #1 ran", "Script #3 ran", "Script #4 ran"] got ["Script #2 ran", "Script #3 ran", "Script #4 ran", "Script #1 ran"])
      

  • OK [expected ERROR] /html/user-activation/no-activation-thru-escape-key.html (#40343)
  • OK /mixed-content/tentative/autoupgrades/video-upgrade.https.sub.html (#41135)
    • FAIL [expected PASS] subtest: Video autoupgraded

      assert_equals: Length. expected 1 but got Infinity
      

  • ERROR [expected TIMEOUT] /performance-timeline/not-restored-reasons/abort-block-bfcache.window.html
  • OK /preload/preload-error.sub.html (#37177)
    • FAIL [expected PASS] subtest: 404 (fetch): main

      assert_greater_than: http://web-platform.test:8000/preload/resources/dummy.xml?pipe=status%28404%29&amp;label=fetch should be loaded expected a number greater than 0 but got 0
      

    • FAIL [expected PASS] subtest: CORS (fetch): main

      assert_greater_than: http://not-web-platform.test:8000/preload/resources/dummy.xml?pipe=header%28Access-Control-Allow-Origin%2C*%29&amp;label=fetch should be loaded expected a number greater than 0 but got 0
      

  • TIMEOUT /resource-timing/test_resource_timing.html (#25720)
    • FAIL [expected PASS] subtest: PerformanceEntry has correct name, initiatorType, startTime, and duration (img)

      assert_equals: expected 12.54 but got 12.53
      

  • TIMEOUT /trusted-types/trusted-types-navigation.html?06-10 (#37920)
    • TIMEOUT [expected FAIL] subtest: Navigate a frame via anchor with javascript:-urls in report-only mode.

      Test timed out
      

    • NOTRUN [expected TIMEOUT] subtest: Navigate a frame via anchor with javascript:-urls w/ default policy in report-only mode.
  • CRASH [expected TIMEOUT] /wasm/webapi/invalid-code.any.worker.html
  • OK [expected ERROR] /webxr/render_state_update.https.html (#27535)
  • ERROR [expected OK] /workers/baseurl/alpha/sharedworker-in-worker.html (#21315)
Stable unexpected results that are known to be intermittent (26)
  • TIMEOUT /FileAPI/url/url-in-tags-revoke.window.html (#19978)
    • TIMEOUT [expected PASS] subtest: Fetching a blob URL immediately before revoking it works in &lt;script&gt; tags.

      Test timed out
      

  • OK /IndexedDB/idbcursor-continuePrimaryKey-exceptions.any.html (#39277)
    • FAIL [expected PASS] subtest: IDBCursor continuePrimaryKey() on object store cursor

      assert_throws_dom: continuePrimaryKey() should throw if source is not an index function "function() {
              cursor.continuePrimaryKey(2, 2);
            }" threw object "TypeError: cursor.continuePrimaryKey is not a function" that is not a DOMException InvalidAccessError: property "code" is equal to undefined, expected 15
      

  • OK /IndexedDB/idbcursor-continuePrimaryKey-exceptions.any.worker.html (#39277)
    • FAIL [expected PASS] subtest: IDBCursor continuePrimaryKey() on object store cursor

      assert_throws_dom: continuePrimaryKey() should throw if source is not an index function "function() {
              cursor.continuePrimaryKey(2, 2);
            }" threw object "TypeError: cursor.continuePrimaryKey is not a function" that is not a DOMException InvalidAccessError: property "code" is equal to undefined, expected 15
      

  • OK /IndexedDB/idbobjectstore_getAll.any.html (#39276)
    • PASS [expected FAIL] subtest: Get all values with transaction.commit()
  • OK /IndexedDB/idbobjectstore_getAll.any.worker.html (#39400)
    • PASS [expected FAIL] subtest: Get all values with transaction.commit()
  • OK /IndexedDB/idbrequest-onupgradeneeded.any.html (#38895)
    • PASS [expected FAIL] subtest: transaction oncomplete ordering relative to open request onsuccess
  • OK /IndexedDB/idbrequest-onupgradeneeded.any.worker.html (#38971)
    • PASS [expected FAIL] subtest: transaction oncomplete ordering relative to open request onsuccess
  • OK /IndexedDB/key-conversion-exceptions.any.html (#39305)
    • FAIL [expected PASS] subtest: IDBCursor continue() method with throwing/invalid keys

      assert_throws_exactly: key conversion with throwing getter should rethrow function "() =&gt; {
            receiver[method](key);
          }" threw object "TypeError: receiver[method] is not a function" but we expected it to throw object "getter: throwing from getter"
      

    • FAIL [expected PASS] subtest: IDBCursor update() method with throwing/invalid keys

      assert_throws_exactly: throwing getter should rethrow during clone function "() =&gt; {
            cursor.update(value);
          }" threw object "TypeError: cursor.update is not a function" but we expected it to throw object "getter: throwing from getter"
      

  • OK /IndexedDB/key-conversion-exceptions.any.worker.html (#39284)
    • FAIL [expected PASS] subtest: IDBCursor continue() method with throwing/invalid keys

      assert_throws_exactly: key conversion with throwing getter should rethrow function "() =&gt; {
            receiver[method](key);
          }" threw object "TypeError: receiver[method] is not a function" but we expected it to throw object "getter: throwing from getter"
      

    • FAIL [expected PASS] subtest: IDBCursor update() method with throwing/invalid keys

      assert_throws_exactly: throwing getter should rethrow during clone function "() =&gt; {
            cursor.update(value);
          }" threw object "TypeError: cursor.update is not a function" but we expected it to throw object "getter: throwing from getter"
      

  • FAIL [expected PASS] /_mozilla/mozilla/sslfail.html (#10760)
  • TIMEOUT [expected OK] /_mozilla/mozilla/window_resize_event.html (#36741)
    • TIMEOUT [expected PASS] subtest: Popup onresize event fires after resizeTo

      Test timed out
      

  • OK /css/css-fonts/generic-family-keywords-001.html (#37467)
    • FAIL [expected PASS] subtest: @font-face matching for quoted and unquoted generic(kai)

      assert_equals: quoted generic(kai) matches  @font-face rule expected 50 but got 30
      

    • PASS [expected FAIL] subtest: @font-face matching for quoted and unquoted generic(khmer-mul)
  • OK /css/css-fonts/generic-family-keywords-003.html (#38994)
    • PASS [expected FAIL] subtest: @font-face matching for quoted and unquoted cursive (drawing text in a canvas)
    • PASS [expected FAIL] subtest: @font-face matching for quoted and unquoted monospace (drawing text in a canvas)
  • OK /fetch/metadata/generated/css-font-face.https.sub.tentative.html (#32732)
    • FAIL [expected PASS] subtest: sec-fetch-storage-access - Cross-site

      promise_test: Unhandled rejection with value: object "Error: Failed to query for recorded headers."
      

  • OK /fetch/metadata/generated/css-font-face.sub.tentative.html (#34624)
    • PASS [expected FAIL] subtest: sec-fetch-storage-access - Not sent to non-trustworthy same-origin destination
    • PASS [expected FAIL] subtest: sec-fetch-storage-access - Not sent to non-trustworthy same-site destination
    • PASS [expected FAIL] subtest: sec-fetch-storage-access - Not sent to non-trustworthy cross-site destination
  • TIMEOUT /fetch/metadata/generated/css-images.sub.tentative.html (#29047)
    • PASS [expected FAIL] subtest: content sec-fetch-site - Not sent to non-trustworthy same-origin destination
    • PASS [expected FAIL] subtest: content sec-fetch-site - Not sent to non-trustworthy same-site destination
    • PASS [expected FAIL] subtest: content sec-fetch-site - Not sent to non-trustworthy cross-site destination
    • PASS [expected FAIL] subtest: content sec-fetch-mode - Not sent to non-trustworthy same-origin destination
    • PASS [expected FAIL] subtest: content sec-fetch-mode - Not sent to non-trustworthy same-site destination
    • PASS [expected FAIL] subtest: content sec-fetch-mode - Not sent to non-trustworthy cross-site destination
    • PASS [expected FAIL] subtest: content sec-fetch-dest - Not sent to non-trustworthy same-origin destination
    • PASS [expected FAIL] subtest: content sec-fetch-dest - Not sent to non-trustworthy same-site destination
    • PASS [expected FAIL] subtest: content sec-fetch-dest - Not sent to non-trustworthy cross-site destination
    • PASS [expected FAIL] subtest: content sec-fetch-user - Not sent to non-trustworthy same-origin destination
    • And 6 more unexpected results...
  • ERROR /fetch/metadata/generated/serviceworker.https.sub.html (#36247)
    • FAIL [expected PASS] subtest: sec-fetch-site - Same origin, no options - registration

      promise_test: Unhandled rejection with value: object "Error: Failed to query for recorded headers."
      

  • ERROR [expected OK] /focus/focus-event-after-switching-iframes.sub.html (#40368)
  • OK /html/browsers/browsing-the-web/navigating-across-documents/navigation-unload-same-origin-fragment.html (#20768)
    • PASS [expected FAIL] subtest: Tests that a fragment navigation in the unload handler will not block the initial navigation
  • OK /html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/a-click.html (#28697)
    • FAIL [expected PASS] subtest: aElement.click() before the load event must NOT replace

      assert_equals: expected "http://web-platform.test:8000/common/blank.html?thereplacement" but got "http://web-platform.test:8000/html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/resources/code-injector.html?pipe=sub(none)&amp;code=%0A%20%20%20%20const%20a%20%3D%20document.createElement(%22a%22)%3B%0A%20%20%20%20a.href%20%3D%20%22%2Fcommon%2Fblank.html%3Fthereplacement%22%3B%0A%20%20%20%20document.currentScript.before(a)%3B%0A%20%20%20%20a.click()%3B%0A%20%20"
      

  • TIMEOUT /html/interaction/focus/the-autofocus-attribute/supported-elements.html (#24145)
    • TIMEOUT [expected FAIL] subtest: Element with tabindex should support autofocus

      Test timed out
      

    • NOTRUN [expected TIMEOUT] subtest: Non-HTMLElement should not support autofocus
  • OK /html/semantics/scripting-1/the-script-element/module/dynamic-import/blob-url.any.html (#33948)
    • PASS [expected FAIL] subtest: Revoking a blob URL immediately after calling import will not fail
  • OK /navigation-timing/test-navigation-type-reload.html (#33334)
    • FAIL [expected PASS] subtest: Reload domContentLoadedEventEnd &gt; Original domContentLoadedEventEnd

      assert_true: Reload domContentLoadedEventEnd &gt; Original domContentLoadedEventEnd expected true got false
      

  • OK /preload/preload-xhr.html (#39092)
    • FAIL [expected PASS] subtest: Make an XHR request immediately after creating link rel=preload.

      assert_equals: resources/dummy.xml?token=69c2c61e-e361-4f6c-af46-183bf116b850 expected 1 but got 0
      

  • TIMEOUT /trusted-types/trusted-types-navigation.html?31-35 (#38034)
    • TIMEOUT [expected PASS] subtest: Navigate a frame via form-submission with javascript:-urls in report-only mode.

      Test timed out
      

    • NOTRUN [expected TIMEOUT] subtest: Navigate a frame via form-submission with javascript:-urls w/ default policy in report-only mode.
  • OK [expected TIMEOUT] /webstorage/localstorage-about-blank-3P-iframe-opens-3P-window.partitioned.html (#29053)
    • PASS [expected TIMEOUT] subtest: StorageKey: test 3P about:blank window opened from a 3P iframe
Stable unexpected results (5)
  • TIMEOUT /fetch/metadata/generated/css-images.https.sub.tentative.html
    • PASS [expected FAIL] subtest: content sec-fetch-site - Same origin
    • PASS [expected FAIL] subtest: content sec-fetch-site - Cross-site
    • PASS [expected FAIL] subtest: content sec-fetch-site - Same site
    • PASS [expected FAIL] subtest: content sec-fetch-site - Cross-Site -&gt; Cross-Site
    • PASS [expected FAIL] subtest: content sec-fetch-site - Same-Origin -&gt; Same-Site
    • PASS [expected FAIL] subtest: content sec-fetch-site - Same-Origin -&gt; Cross-Site
    • PASS [expected FAIL] subtest: content sec-fetch-site - Same-Site -&gt; Same-Site
    • PASS [expected FAIL] subtest: content sec-fetch-site - Same-Site -&gt; Cross-Site
    • PASS [expected FAIL] subtest: content sec-fetch-mode
    • PASS [expected FAIL] subtest: content sec-fetch-dest
    • And 2 more unexpected results...
  • FAIL [expected PASS] /html/rendering/replaced-elements/images/input-image-content.html
  • PASS [expected FAIL] /html/semantics/interactive-elements/the-dialog-element/modal-dialog-in-replaced-renderer.html
  • OK /referrer-policy/css-integration/image/inline-style-with-differentorigin-base-tag.tentative.html
    • PASS [expected FAIL] subtest: Image from inline styles.
  • OK /referrer-policy/css-integration/image/inline-style.html
    • PASS [expected FAIL] subtest: Image from inline styles.

@github-actions
Copy link
Copy Markdown

⚠️ Try run (#20443356076) failed.

@servo-wpt-sync
Copy link
Copy Markdown
Collaborator

🤖 Opened new upstream WPT pull request (web-platform-tests/wpt#56912) with upstreamable changes.

@servo-wpt-sync
Copy link
Copy Markdown
Collaborator

📝 Transplanted new upstreamable changes to existing upstream WPT pull request (web-platform-tests/wpt#56912).

@andreubotella
Copy link
Copy Markdown
Contributor Author

andreubotella commented Dec 24, 2025

For the record, /fetch/metadata/generated/css-images.sub.tentative.html and /fetch/metadata/generated/css-images.https.sub.tentative.html seemed to be deterministically failing in all subtests except for the background-image ones which were timing out, but this patch makes the content subtests flaky in whether they pass or fail. As far as I can tell, the reason for this is that the tests seem to expect the content property replacement to block the document load, even though it's not specified to do so. I'm not sure whether it blocks it in other browsers, but Firefox, Webkit and Chromium do pass these tests.

This PR also makes html/rendering/replaced-elements/images/input-image-content.html fail when it was previously passing, but this doesn't mean any functionality broke – it was previously passing because the reference used <input type=image>, which doesn't yet render the image in Servo.

I've also added new tests for the document root.

@servo-wpt-sync
Copy link
Copy Markdown
Collaborator

✍ Updated existing upstream WPT pull request (web-platform-tests/wpt#56912) title and body.

@mrobinson
Copy link
Copy Markdown
Member

I suspect that pending images whether loaded via the image tag of CSS content should delay screenshot taking until their load completes successfully or fails.

@andreubotella
Copy link
Copy Markdown
Contributor Author

andreubotella commented Dec 24, 2025

I suspect that pending images whether loaded via the image tag of CSS content should delay screenshot taking until their load completes successfully or fails.

True, but /fetch/metadata/generated/css-images.sub.tentative.html is not a reftest, but a testharness test that waits until an iframe's load event fires, and then checks which requests were made. This is why I wonder if content (and background-image, border-image and the various other ways of fetching images via CSS that the test is testing) are delaying the load event in other browsers, even when there's nothing in the specs to indicate that.

Edit: In fact, I believe pending images loaded via content are already delaying screenshots, since they're treated just like <img> replaced elements in the box tree, and Window::maybe_resolve_pending_screenshot_readiness_requests does check the pending layout images.

@servo-wpt-sync
Copy link
Copy Markdown
Collaborator

📝 Transplanted new upstreamable changes to existing upstream WPT pull request (web-platform-tests/wpt#56912).

@andreubotella
Copy link
Copy Markdown
Contributor Author

andreubotella commented Dec 24, 2025

I noticed that changes of the content property in non-pseudos did not invalidate the box tree, so I added a condition to check this when computing the layout damage.

This should have been caught by the css/css-content/element-replacement-dynamic.html WPT, but this test was not properly doing a layout before changing the content property. I changed the test to use reftest-wait instead.

@Loirooriol
Copy link
Copy Markdown
Contributor

this test was not properly doing a layout before changing the content property

The test uses getComputedStyle(target).width to force a layout. The problem is that for some reason that I need to investigate, such layout is never reused, a new layout happens later even if it wouldn't be necessary. E.g. this triggers 2 layouts:

<!DOCTYPE html>
<script>document.documentElement.offsetWidth</script>

That's why the test seemed to work. Note the problem would be exposed with

<script>
document.body.style.display = "none";
requestAnimationFrame(() => {
  document.body.style.display = "block";
  const target = document.getElementById("target");
  getComputedStyle(target).width;
  target.className = "replaced";
});
</script>

With the requestAnimationFrame delay, the layout forced by getComputedStyle(target).width can be reused (without you last commit).

It's still fine to change the test, but it's not like it was wrong, it's an orthogonal problem.

Copy link
Copy Markdown
Member

@mrobinson mrobinson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems reasonable. I have a few nits below, but maybe @Loirooriol wants to take a look as well.

@servo-highfive servo-highfive added S-needs-rebase There are merge conflict errors. and removed S-awaiting-review There is new code that needs to be reviewed. labels Dec 29, 2025
@servo-highfive servo-highfive added S-awaiting-review There is new code that needs to be reviewed. S-needs-rebase There are merge conflict errors. and removed S-needs-rebase There are merge conflict errors. labels Dec 29, 2025
@servo-wpt-sync
Copy link
Copy Markdown
Collaborator

📝 Transplanted new upstreamable changes to existing upstream WPT pull request (web-platform-tests/wpt#56912).

@servo-wpt-sync
Copy link
Copy Markdown
Collaborator

📝 Transplanted new upstreamable changes to existing upstream WPT pull request (web-platform-tests/wpt#56912).

@servo-wpt-sync
Copy link
Copy Markdown
Collaborator

📝 Transplanted new upstreamable changes to existing upstream WPT pull request (web-platform-tests/wpt#56912).

@servo-highfive servo-highfive removed the S-awaiting-review There is new code that needs to be reviewed. label Dec 30, 2025
@servo-highfive servo-highfive added the S-awaiting-review There is new code that needs to be reviewed. label Jan 28, 2026
@mrobinson mrobinson enabled auto-merge January 28, 2026 15:36
@servo-wpt-sync
Copy link
Copy Markdown
Collaborator

📝 Transplanted new upstreamable changes to existing upstream WPT pull request (web-platform-tests/wpt#56912).

@mrobinson mrobinson added this pull request to the merge queue Jan 28, 2026
@servo-highfive servo-highfive added the S-awaiting-merge The PR is in the process of compiling and running tests on the automated CI. label Jan 28, 2026
github-merge-queue bot pushed a commit that referenced this pull request Jan 28, 2026
Per CSS-CONTENT-3, one of the possible values of the `content` CSS
property is `<content-replacement>`, which evaluates to a single
`<image>`. This value is also allowed on regular elements, not just on
pseudo-elements, and it will make the element into a replaced element
representing the given image, discarding its contents.

This patch implements this in `traverse_element`: if the `display` value
is not `none` or `contents`, we first check whether the `contents`
property should make the element replaced, and if it shouldn't, then we
check whether the element itself is replaced or a widget.

Per the spec, an invalid image must be treated as representing a
transparent black image with zero natural width and height – in
particular, it must not show a broken image icon. We added the method
`ReplacedContents::zero_sized_invalid_image` to implement this.

This patch adds support for image URL references, but not for color
gradients, which are treated as invalid images. The reason for this is
that currently Servo does not support gradients in
`ReplacedContentKind`. This is left as a follow-up change.

Testing: Some of the existing `css/css-content/element-replacement*` WPT
tests now pass with this patch. We also added some new ones dealing with
replacing the document root.

Fixes: #41479

Signed-off-by: Andreu Botella <[email protected]>
@github-merge-queue github-merge-queue bot removed this pull request from the merge queue due to failed status checks Jan 28, 2026
@servo-highfive servo-highfive added S-tests-failed The changes caused existing tests to fail. and removed S-awaiting-merge The PR is in the process of compiling and running tests on the automated CI. labels Jan 28, 2026
@mrobinson mrobinson added this pull request to the merge queue Jan 28, 2026
@servo-highfive servo-highfive added S-awaiting-merge The PR is in the process of compiling and running tests on the automated CI. and removed S-tests-failed The changes caused existing tests to fail. labels Jan 28, 2026
@mrobinson mrobinson removed this pull request from the merge queue due to a manual request Jan 28, 2026
@servo-highfive servo-highfive removed the S-awaiting-merge The PR is in the process of compiling and running tests on the automated CI. label Jan 28, 2026
Per CSS-CONTENT-3, one of the possible values of the `content` CSS
property is `<content-replacement>`, which evaluates to a single
`<image>`. This value is also allowed on regular elements, not just on
pseudo-elements, and it will make the element into a replaced element
representing the given image, discarding its contents.

This patch implements this in `traverse_element`: if the `display`
value is not `none` or `contents`, we first check whether the
`contents` property should make the element replaced, and if it
shouldn't, then we check whether the element itself is replaced or a
widget.

Per the spec, an invalid image must be treated as representing a
transparent black image with zero natural width and height – in
particular, it must not show a broken image icon. We added the method
`ReplacedContents::zero_sized_invalid_image` to implement this.

This patch adds support for image URL references, but not for color
gradients, which are treated as invalid images. The reason for this is
that currently Servo does not support gradients in
`ReplacedContentKind`. This is left as a follow-up change.

Testing: Existing `css/css-content/element-replacement*` WPT tests
that now pass with this patch. `element-replacement-gradient.html`
still fails because this patch does not add support for gradients.

Fixes: servo#41479

Signed-off-by: Andreu Botella <[email protected]>
@servo-wpt-sync
Copy link
Copy Markdown
Collaborator

📝 Transplanted new upstreamable changes to existing upstream WPT pull request (web-platform-tests/wpt#56912).

@mrobinson mrobinson added this pull request to the merge queue Jan 28, 2026
@servo-highfive servo-highfive added the S-awaiting-merge The PR is in the process of compiling and running tests on the automated CI. label Jan 28, 2026
Merged via the queue into servo:main with commit b039956 Jan 28, 2026
29 checks passed
@servo-highfive servo-highfive removed the S-awaiting-merge The PR is in the process of compiling and running tests on the automated CI. label Jan 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

S-awaiting-review There is new code that needs to be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

content: <image> on a (non-pseudo) element should replace it with the image

5 participants