Skip to content

devtools: Apply attribute modifications in the inspector to the DOM tree#42601

Merged
simonwuelker merged 3 commits intoservo:mainfrom
simonwuelker:inspector-attribute-modifications
Feb 17, 2026
Merged

devtools: Apply attribute modifications in the inspector to the DOM tree#42601
simonwuelker merged 3 commits intoservo:mainfrom
simonwuelker:inspector-attribute-modifications

Conversation

@simonwuelker
Copy link
Copy Markdown
Member

The inspector view allows modifying the attributes of DOM elements. However, we lie to the devtools client: While it looks like the attributes change, the changes are never actually applied to the DOM.

This change fixes that, and also makes it so attribute modifications from non-inspector sources are shown in the inspector.

Testing: This change adds two tests

@servo-highfive servo-highfive added the S-awaiting-review There is new code that needs to be reviewed. label Feb 13, 2026
#[serde(rename = "newValue")]
new_value: Option<String>,
},
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This enum may seem a bit redundant. I have a followup to this PR where more variants are added.

@simonwuelker simonwuelker added the T-linux-wpt Do a try run of the WPT label Feb 13, 2026
@github-actions github-actions Bot removed the T-linux-wpt Do a try run of the WPT label Feb 13, 2026
@github-actions
Copy link
Copy Markdown

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

@simonwuelker simonwuelker force-pushed the inspector-attribute-modifications branch from ee7de5c to c3195ac Compare February 13, 2026 16:19
Signed-off-by: Simon Wülker <simon.wuelker@arcor.de>
Signed-off-by: Simon Wülker <simon.wuelker@arcor.de>
@simonwuelker simonwuelker force-pushed the inspector-attribute-modifications branch from c3195ac to 18a9be4 Compare February 13, 2026 16:22
@github-actions
Copy link
Copy Markdown

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

Flaky unexpected result (26)
  • OK [expected ERROR] /IndexedDB/cursor-overloads.any.worker.html (#42324)
  • PASS [expected FAIL] /_mozilla/css/linear_gradients_reverse_a.html
  • 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/mozilla/getBoundingClientRect.html (#39668)
    • FAIL [expected PASS] subtest: getBoundingClientRect 1

      assert_equals: expected 62 but got 60.35
      

  • OK /content-security-policy/frame-ancestors/frame-ancestors-path-ignored.window.html (#36468)
    • PASS [expected FAIL] subtest: A 'frame-ancestors' CSP directive with a URL that includes a path should be ignored.
  • FAIL [expected PASS] /css/css-ui/webkit-appearance-menulist-001.html
  • OK [expected ERROR] /fetch/fetch-later/quota/same-origin-iframe/accumulated-oversized-payload.https.window.html (#41705)
  • TIMEOUT /fetch/metadata/generated/css-images.https.sub.tentative.html (#42229)
    • FAIL [expected PASS] subtest: content sec-fetch-site - Cross-Site -&gt; Same-Site

      assert_unreached: Reached unreachable code
      

    • FAIL [expected PASS] subtest: content sec-fetch-site - Cross-Site -&gt; Cross-Site

      assert_unreached: Reached unreachable code
      

  • 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."
      

  • OK /html/browsers/browsing-the-web/navigating-across-documents/005.html (#27062)
    • PASS [expected FAIL] subtest: Link with onclick navigation and href navigation
  • OK /html/browsers/browsing-the-web/navigating-across-documents/navigation-unload-cross-origin.sub.window.html (#29056)
    • PASS [expected FAIL] subtest: Cross-origin navigation started from unload handler must be ignored
  • OK /html/browsers/history/the-history-interface/traverse_the_history_2.html (#21383)
    • PASS [expected FAIL] subtest: Multiple history traversals, last would be aborted
  • CRASH [expected TIMEOUT] /html/browsers/history/the-location-interface/location_replace_session_history.html (#41896)
  • OK /html/semantics/embedded-content/media-elements/media_fragment_seek.html (#24114)
    • FAIL [expected PASS] subtest: Video should seek to time specified in media fragment syntax

      assert_equals: expected 3 but got 0
      

  • OK [expected TIMEOUT] /html/semantics/embedded-content/media-elements/src_object_blob.html (#40340)
    • PASS [expected TIMEOUT] subtest: HTMLMediaElement.srcObject blob
  • OK /html/semantics/embedded-content/the-iframe-element/iframe-loading-lazy-nav-location-replace-set-src.html (#32697)
    • PASS [expected FAIL] subtest: Navigating iframe loading='lazy' and then setting src: location.replace
  • 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
      

  • CRASH [expected ERROR] /html/semantics/forms/the-input-element/anchor-active-contenteditable.html
  • TIMEOUT [expected OK] /html/user-activation/navigation-state-reset-sameorigin.html
    • TIMEOUT [expected PASS] subtest: Post-navigation state reset.

      Test timed out
      

  • CRASH [expected OK] /html/webappapis/timers/type-long-settimeout.any.html
  • OK /preload/prefetch-document.html (#37210)
    • FAIL [expected PASS] subtest: different-site document prefetch with 'as=document' should not be consumed

      assert_equals: expected 2 but got 1
      

  • OK [expected CRASH] /resource-timing/render-blocking-status-link.html (#41664)
  • OK [expected TIMEOUT] /trusted-types/trusted-types-navigation.html?31-35 (#38034)
    • PASS [expected TIMEOUT] subtest: Navigate a frame via form-submission with javascript:-urls w/ default policy in report-only mode.
    • FAIL [expected NOTRUN] subtest: Navigate a window via form-submission with javascript:-urls w/ a default policy throwing an exception in enforcing mode.

      promise_test: Unhandled rejection with value: "Unexpected message received: \"No securitypolicyviolation reported!\""
      

    • FAIL [expected NOTRUN] subtest: Navigate a window via form-submission with javascript:-urls w/ a default policy throwing an exception in report-only mode.

      promise_test: Unhandled rejection with value: "Unexpected message received: \"No securitypolicyviolation reported!\""
      

    • FAIL [expected NOTRUN] subtest: Navigate a window via form-submission with javascript:-urls w/ a default policy making the URL invalid in enforcing mode.

      promise_test: Unhandled rejection with value: "Unexpected message received: \"No securitypolicyviolation reported!\""
      

  • PASS [expected TIMEOUT] /visual-viewport/viewport-apply-initial-scale-after-navigation.html (#41582)
  • CRASH [expected OK] /webaudio/the-audio-api/the-mediastreamaudiodestinationnode-interface/closed-audiocontext-construction.html
  • OK [expected ERROR] /webxr/render_state_update.https.html (#27535)
Stable unexpected results that are known to be intermittent (21)
  • ERROR [expected OK] /IndexedDB/cursor-overloads.any.html (#42437)
  • OK /IndexedDB/transaction-deactivation-timing.any.worker.html (#38808)
    • PASS [expected FAIL] subtest: New transactions are deactivated before next task
    • PASS [expected FAIL] subtest: New transactions from microtask are deactivated before next task
  • 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
      

  • CRASH [expected PASS] /_mozilla/shadow-dom/move-element-with-ua-shadow-tree-crash.html (#39473)
  • OK /_webgl/conformance/textures/misc/texture-upload-size.html (#21770)
    • FAIL [expected PASS] subtest: WebGL test #45

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

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

      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 #49

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

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

      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 #53
    • PASS [expected FAIL] subtest: WebGL test #55
    • PASS [expected FAIL] subtest: WebGL test #57
    • PASS [expected FAIL] subtest: WebGL test #59
    • FAIL [expected PASS] subtest: WebGL test #61

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

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

      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
      

    • And 14 more unexpected results...
  • OK /css/css-cascade/layer-font-face-override.html (#35935)
    • FAIL [expected PASS] subtest: @font-face override update with appended sheet 2

      assert_equals: expected "80px" but got "38.3166666666667px"
      

  • OK /css/css-fonts/generic-family-keywords-001.html (#37467)
    • PASS [expected FAIL] subtest: @font-face matching for quoted and unquoted generic(fangsong)
    • 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)
    • PASS [expected FAIL] subtest: @font-face matching for quoted and unquoted math (drawing text in a canvas)
    • PASS [expected FAIL] subtest: @font-face matching for quoted and unquoted generic(fangsong) (drawing text in a canvas)
    • PASS [expected FAIL] subtest: @font-face matching for quoted and unquoted generic(kai) (drawing text in a canvas)
    • PASS [expected FAIL] subtest: @font-face matching for quoted and unquoted generic(khmer-mul) (drawing text in a canvas)
    • PASS [expected FAIL] subtest: @font-face matching for quoted and unquoted generic(nastaliq) (drawing text in a canvas)
    • PASS [expected FAIL] subtest: @font-face matching for quoted and unquoted ui-serif (drawing text in a canvas)
  • OK /fetch/metadata/generated/css-font-face.https.sub.tentative.html (#32732)
    • FAIL [expected PASS] subtest: sec-fetch-mode

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

    • PASS [expected FAIL] subtest: sec-fetch-dest
  • 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
  • TIMEOUT /fetch/metadata/generated/css-images.sub.tentative.html (#29047)
    • PASS [expected TIMEOUT] subtest: background-image sec-fetch-mode - Not sent to non-trustworthy same-site destination
  • OK /html/browsers/history/the-history-interface/traverse_the_history_4.html (#21383)
    • FAIL [expected PASS] subtest: Multiple history traversals, last would be aborted

      assert_array_equals: Pages opened during history navigation expected property 1 to be 5 but got 3 (expected array [6, 5] got [6, 3])
      

  • OK /html/semantics/scripting-1/the-script-element/module/dynamic-import/blob-url.any.worker.html (#33909)
    • FAIL [expected PASS] subtest: Revoking a blob URL immediately after calling import will not fail

      promise_test: Unhandled rejection with value: object "TypeError: Module fetching failed"
      

  • OK /navigation-timing/test-navigation-type-reload.html (#33334)
    • FAIL [expected PASS] subtest: Reload domInteractive &gt; Original domInteractive

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

  • TIMEOUT [expected OK] /paint-timing/fcp-only/fcp-with-rtl.html (#42357)
    • TIMEOUT [expected PASS] subtest: FCP should fire when coordinates are negative, if within document scrollable area

      Test timed out
      

  • OK /paint-timing/first-image-child.html (#42455)
    • FAIL [expected PASS] subtest: Child iframe ignores paint-timing events fired from parent image rendering.

      assert_equals: expected "0" but got "1 paint first-paint"
      

  • OK /pointerevents/compat/pointerevent_touch-action_two-finger_interaction.html (#40418)
    • PASS [expected FAIL] subtest: touch two-finger pan on 'touch-action: pan-x pan-y'
  • OK /resource-timing/buffer-full-add-then-clear.html (#40819)
    • FAIL [expected PASS] subtest: Test that if the buffer is cleared after entries were added to the secondary buffer, those entries make it into the primary one

      assert_equals: Number of entries does not match the expected value. expected 3 but got 0
      

  • OK /resource-timing/test_resource_timing.html (#25720)
    • PASS [expected FAIL] subtest: PerformanceEntry has correct name, initiatorType, startTime, and duration (img)
  • TIMEOUT [expected OK] /trusted-types/trusted-types-navigation.html?01-05 (#38975)
    • TIMEOUT [expected PASS] subtest: Navigate a window via anchor with javascript:-urls in report-only mode.

      Test timed out
      

    • NOTRUN [expected PASS] subtest: Navigate a window via anchor with javascript:-urls w/ default policy in report-only mode.
    • NOTRUN [expected PASS] subtest: Navigate a frame via anchor with javascript:-urls in enforcing mode.

@github-actions
Copy link
Copy Markdown

⚠️ Try run (#21994097619) failed!

@simonwuelker simonwuelker force-pushed the inspector-attribute-modifications branch 2 times, most recently from 9b578d9 to b1552f7 Compare February 13, 2026 17:39
@simonwuelker
Copy link
Copy Markdown
Member Author

I'm pinging you two because you reviewed my previous devtools PRs and there are not many maintainers familiar with this area of the code. Feel free to tell me if you dont want to be pinged on future PRs ^^

Copy link
Copy Markdown
Member

@eerii eerii left a comment

Choose a reason for hiding this comment

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

Looks nice! And feel free to ping me for devtools code :)

Comment thread components/devtools/actors/inspector.rs Outdated
@servo-highfive servo-highfive removed the S-awaiting-review There is new code that needs to be reviewed. label Feb 17, 2026
Copy link
Copy Markdown
Member

@atbrakhi atbrakhi left a comment

Choose a reason for hiding this comment

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

Thank you for adding tests, love it :)

Signed-off-by: Simon Wülker <simon.wuelker@arcor.de>
@simonwuelker simonwuelker force-pushed the inspector-attribute-modifications branch from b1552f7 to 8e0d714 Compare February 17, 2026 12:57
@servo-highfive servo-highfive added the S-awaiting-review There is new code that needs to be reviewed. label Feb 17, 2026
@simonwuelker simonwuelker added this pull request to the merge queue Feb 17, 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 Feb 17, 2026
Merged via the queue into servo:main with commit a60f937 Feb 17, 2026
33 checks passed
@simonwuelker simonwuelker deleted the inspector-attribute-modifications branch February 17, 2026 13:42
@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 Feb 17, 2026
github-merge-queue Bot pushed a commit that referenced this pull request Feb 25, 2026
Currently, `script` and `devtools` use a node's unique id to identify it
across requests. The unique ID is part of a node's rare data field and
is really only meant for debugging. Instantiating it on a node causes
it's memory usage to go up significantly. Now, when the devtools ask for
information about a specific `Node` then they send the unique ID to
`script`, and the script thread then walks the whole DOM tree searching
for that specific ID. This happens here:

https://github.com/servo/servo/blob/6d0b65121852e75ad9aea5cdf3f04ca186cc67f9/components/script/devtools.rs#L142-L153

So, in the worst case, all of the nodes in the tree now have a unique
ID. That's not great!

Also, when `script` notifies `devtools` about changes to a DOM node then
`devtools` expects a `NodeActor` to exist for that Node. The actor might
not exist if the inspector doesn't know about that node yet - that
happens when the user hasn't expanded their parent yet.
That is an oversight from #42601 and
causes problems now(#42784) because
for the longest time, `devtools` was mostly the one sending requests. Of
course, we could make `devtools` simply ignore updates for nodes that it
doesn't know about, but ideally we shouldn't send these updates in the
first place.

This change implements a lookup map on the `ScriptThread` that contains
all nodes that have been sent to the devtools inspector. The map allows
us to efficiently resolve a unique ID to a `Node` in O(1), without
creating unique IDs for the whole tree. It also allows us to only send
DOM updates for nodes that the inspector cares about.

For now, entries from the cache are not evicted unless the relevant
pipeline is closed. That reflects reality, because the inspector also
keeps using them forever.
In the future we will tell the inspector when nodes are removed from the
tree - then it can't interact with them anymore, and we can remove them
from the script-side map.

This is change is not all that complicated but it involves moving a lot
of code around, so feel free to ask for clarification when something is
unclear!





Testing: This change adds a test
Fixes part of #42784

---------

Signed-off-by: Simon Wülker <simon.wuelker@arcor.de>
shubhamg13 pushed a commit to shubhamg13/servo that referenced this pull request Mar 2, 2026
Currently, `script` and `devtools` use a node's unique id to identify it
across requests. The unique ID is part of a node's rare data field and
is really only meant for debugging. Instantiating it on a node causes
it's memory usage to go up significantly. Now, when the devtools ask for
information about a specific `Node` then they send the unique ID to
`script`, and the script thread then walks the whole DOM tree searching
for that specific ID. This happens here:

https://github.com/servo/servo/blob/6d0b65121852e75ad9aea5cdf3f04ca186cc67f9/components/script/devtools.rs#L142-L153

So, in the worst case, all of the nodes in the tree now have a unique
ID. That's not great!

Also, when `script` notifies `devtools` about changes to a DOM node then
`devtools` expects a `NodeActor` to exist for that Node. The actor might
not exist if the inspector doesn't know about that node yet - that
happens when the user hasn't expanded their parent yet.
That is an oversight from servo#42601 and
causes problems now(servo#42784) because
for the longest time, `devtools` was mostly the one sending requests. Of
course, we could make `devtools` simply ignore updates for nodes that it
doesn't know about, but ideally we shouldn't send these updates in the
first place.

This change implements a lookup map on the `ScriptThread` that contains
all nodes that have been sent to the devtools inspector. The map allows
us to efficiently resolve a unique ID to a `Node` in O(1), without
creating unique IDs for the whole tree. It also allows us to only send
DOM updates for nodes that the inspector cares about.

For now, entries from the cache are not evicted unless the relevant
pipeline is closed. That reflects reality, because the inspector also
keeps using them forever.
In the future we will tell the inspector when nodes are removed from the
tree - then it can't interact with them anymore, and we can remove them
from the script-side map.

This is change is not all that complicated but it involves moving a lot
of code around, so feel free to ask for clarification when something is
unclear!





Testing: This change adds a test
Fixes part of servo#42784

---------

Signed-off-by: Simon Wülker <simon.wuelker@arcor.de>
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.

4 participants