Skip to content

Commit 488b979

Browse files
Bound nested context menu ancestry (#530)
1 parent 9f2d483 commit 488b979

6 files changed

Lines changed: 295 additions & 0 deletions

File tree

‎experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4821,6 +4821,7 @@
48214821
return;
48224822
}
48234823
const auto compiled = self->cached_css_selector_list(selector);
4824+
auto* document_root = self->css_cascade_root_for_node(*node);
48244825
for (auto* candidate = node; candidate != nullptr;
48254826
candidate = self->document.dom_parent(*candidate)) {
48264827
if (self->compiled_css_selector_list_matches(
@@ -4829,6 +4830,11 @@
48294830
info.GetReturnValue().Set(self->wrap_node(*candidate));
48304831
return;
48314832
}
4833+
// Retained layout nests a child document below its iframe owner,
4834+
// but DOM ancestry APIs remain scoped to the node's Document.
4835+
// In particular, Code OSS repeatedly calls closest() while
4836+
// collecting data-vscode-context values for a webview menu.
4837+
if (candidate == document_root) break;
48324838
}
48334839
info.GetReturnValue().Set(v8::Null(info.GetIsolate()));
48344840
}

‎experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2036,9 +2036,18 @@
20362036
return read_event_boolean("__websceneImmediatePropagationStopped");
20372037
};
20382038

2039+
auto* event_root = static_cast<dom_node*>(
2040+
local_context->GetAlignedPointerFromEmbedderData(
2041+
1,
2042+
v8::kEmbedderDataTypeTagDefault));
2043+
if (event_root == nullptr) event_root = &document.body();
20392044
std::vector<dom_node*> ancestry;
20402045
for (auto* node = &target; node != nullptr; node = document.event_parent(*node)) {
20412046
ancestry.push_back(node);
2047+
// Iframe documents share the retained native layout tree with
2048+
// their owner, while DOM event paths terminate at the child
2049+
// Document/Window boundary.
2050+
if (node == event_root) break;
20422051
}
20432052
#if defined(WEBSCENE_NATIVE_ENGINE_CERTIFICATION)
20442053
if (std::string_view(type) == "mousemove") {

‎experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_resource_tests.inc‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,6 +1132,140 @@ void test_nested_pointer_coordinate_projection() {
11321132
webscene_engine_destroy(engine);
11331133
}
11341134

1135+
void test_nested_context_menu_handoff() {
1136+
constexpr std::string_view address{
1137+
"https://iframe-context-menu.test/index.html"};
1138+
resource_server server{
1139+
.content = {{std::string{address},
1140+
"<!doctype html><title>frame context menu</title><body></body>"},
1141+
{"https://iframe-context-menu.test/frame.html", R"HTML(
1142+
<!doctype html><style>html,body{margin:0}#ancestor{position:absolute;left:10px;top:8px;width:40px;height:30px}#target{display:block;width:40px;height:30px}</style>
1143+
<body data-vscode-context='{"scope":"body","shared":"body","bodyOnly":true}'>
1144+
<div id="ancestor" data-vscode-context='{"scope":"ancestor","shared":"ancestor","ancestorOnly":true}'>
1145+
<span id="target" data-vscode-context='{"shared":"target","targetOnly":true}'>target</span>
1146+
</div>
1147+
<script>
1148+
const state = parent.__nestedContextMenu;
1149+
const target = document.querySelector('#target');
1150+
target.addEventListener('contextmenu', event => event.preventDefault(), {once:true});
1151+
addEventListener('contextmenu', event => {
1152+
if (event.defaultPrevented) {
1153+
++state.cancelled;
1154+
return;
1155+
}
1156+
event.preventDefault();
1157+
let context = {};
1158+
let element = event.target;
1159+
while (true) {
1160+
if (!element) break;
1161+
element = element.closest('[data-vscode-context]');
1162+
if (!element) break;
1163+
context = {...JSON.parse(element.dataset.vscodeContext), ...context};
1164+
element = element.parentElement;
1165+
}
1166+
state.pathCrossedDocument = event.composedPath().includes(frameElement);
1167+
parent.__receiveContextMenu({
1168+
clientX:event.clientX,
1169+
clientY:event.clientY,
1170+
context,
1171+
defaultPrevented:event.defaultPrevented
1172+
});
1173+
});
1174+
state.ready = true;
1175+
</script>
1176+
</body>)HTML"}};
1177+
webscene_engine_options options{};
1178+
options.struct_size = sizeof(options);
1179+
options.resource_load_callback = load_test_resource;
1180+
options.resource_load_user_data = &server;
1181+
auto* engine = webscene_engine_create_with_options(&options);
1182+
require(engine != nullptr, "nested context menu engine creation failed");
1183+
require(webscene_engine_load_url(engine, address.data(), address.size()) != 0U,
1184+
"nested context menu outer document failed");
1185+
require(evaluate_until_equals(
1186+
engine, "location.href", "nested-context-menu-ready.js",
1187+
R"("https://iframe-context-menu.test/index.html")", 2'500)
1188+
== R"("https://iframe-context-menu.test/index.html")",
1189+
"nested context menu outer document did not become ready");
1190+
1191+
execute(engine, R"JS(
1192+
globalThis.__nestedContextMenu = {
1193+
ready:false, cancelled:0, requests:[], visibleDuringHandoff:false,
1194+
pathCrossedDocument:null
1195+
};
1196+
globalThis.__receiveContextMenu = data => {
1197+
__nestedContextMenu.requests.push(data);
1198+
const body = document.querySelector('#webview').contentDocument.body;
1199+
body.classList.toggle('vscode-context-menu-visible', true);
1200+
__nestedContextMenu.visibleDuringHandoff =
1201+
body.classList.contains('vscode-context-menu-visible');
1202+
};
1203+
document.body.style.margin = '0';
1204+
const frame = document.createElement('iframe');
1205+
frame.id = 'webview';
1206+
frame.dataset.vscodeContext = JSON.stringify({
1207+
scope:'outer', outerOnly:true
1208+
});
1209+
frame.setAttribute('sandbox', 'allow-same-origin allow-scripts');
1210+
frame.style.cssText =
1211+
'position:absolute;left:80px;top:60px;width:200px;height:120px;border:4px solid black';
1212+
frame.src = './frame.html';
1213+
document.body.appendChild(frame);
1214+
)JS", "nested-context-menu-setup.js");
1215+
require(evaluate_until_equals(
1216+
engine, "__nestedContextMenu.ready",
1217+
"nested-context-menu-frame-ready.js", "true", 3'000) == "true",
1218+
"nested context menu frame did not become ready: "
1219+
+ evaluate(engine, "JSON.stringify(__nestedContextMenu)",
1220+
"nested-context-menu-timeout.js"));
1221+
1222+
const auto secondary_click = [&](uint64_t sequence) {
1223+
const webscene_input_event down{
1224+
WEBSCENE_INPUT_POINTER_DOWN, 2U | (3U << 8U), sequence,
1225+
99, 77, 0, 0};
1226+
const webscene_input_event up{
1227+
WEBSCENE_INPUT_POINTER_UP, (3U << 8U), sequence + 1U,
1228+
99, 77, 0, 0};
1229+
const auto consumed_before = consumed_input_count(engine);
1230+
require(webscene_engine_enqueue(engine, &down) != 0,
1231+
"nested context menu pointer down was rejected");
1232+
require(webscene_engine_enqueue(engine, &up) != 0,
1233+
"nested context menu pointer up was rejected");
1234+
wait_for_consumed_inputs(
1235+
engine, consumed_before + 2U,
1236+
"nested context menu pointer inputs were not consumed");
1237+
};
1238+
1239+
secondary_click(9501U);
1240+
require(
1241+
evaluate(engine, R"JS(JSON.stringify({
1242+
cancelled:__nestedContextMenu.cancelled,
1243+
requestCount:__nestedContextMenu.requests.length,
1244+
visible:document.querySelector('#webview').contentDocument.body.classList
1245+
.contains('vscode-context-menu-visible')
1246+
}))JS", "nested-context-menu-cancelled.js")
1247+
== R"({"cancelled":1,"requestCount":0,"visible":false})",
1248+
"a child-cancelled context menu escaped to the Code-shaped handler");
1249+
1250+
secondary_click(9503U);
1251+
const auto result = evaluate(engine, R"JS(JSON.stringify({
1252+
request:__nestedContextMenu.requests[0],
1253+
visibleDuringHandoff:__nestedContextMenu.visibleDuringHandoff,
1254+
pathCrossedDocument:__nestedContextMenu.pathCrossedDocument
1255+
}))JS", "nested-context-menu-result.js");
1256+
require(
1257+
result == R"JSON({"request":{"clientX":15,"clientY":13,"context":{"scope":"ancestor","shared":"target","bodyOnly":true,"ancestorOnly":true,"targetOnly":true},"defaultPrevented":true},"visibleDuringHandoff":true,"pathCrossedDocument":false})JSON",
1258+
"nested Code-shaped context menu handoff diverged: " + result);
1259+
1260+
require(evaluate(engine, R"JS((() => {
1261+
const body=document.querySelector('#webview').contentDocument.body;
1262+
body.classList.toggle('vscode-context-menu-visible', false);
1263+
return !body.classList.contains('vscode-context-menu-visible');
1264+
})())JS", "nested-context-menu-hidden.js") == "true",
1265+
"context menu visibility did not clear in the child document");
1266+
webscene_engine_destroy(engine);
1267+
}
1268+
11351269
void test_nested_keyboard_handoff_and_focus_traversal() {
11361270
constexpr std::string_view address{
11371271
"https://iframe-keyboard.test/index.html"};

‎experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,10 @@ int main()
194194
test_nested_pointer_coordinate_projection();
195195
return 0;
196196
}
197+
if (selected == "nested-context-menu") {
198+
test_nested_context_menu_handoff();
199+
return 0;
200+
}
197201
if (selected == "nested-keyboard-handoff") {
198202
test_nested_keyboard_handoff_and_focus_traversal();
199203
return 0;
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<!doctype html>
2+
<meta charset="utf-8">
3+
<title>Nested context menu handoff contract</title>
4+
<script src="/resources/testharness.js"></script>
5+
<script src="/resources/testharnessreport.js"></script>
6+
<script src="/resources/testdriver.js"></script>
7+
<script src="/resources/testdriver-actions.js"></script>
8+
<script src="/resources/testdriver-vendor.js"></script>
9+
<style>
10+
html, body { margin: 0; width: 800px; height: 600px; }
11+
#webview {
12+
position: absolute;
13+
left: 80px;
14+
top: 60px;
15+
width: 200px;
16+
height: 120px;
17+
border: 4px solid black;
18+
}
19+
</style>
20+
<iframe id="webview" data-vscode-context='{"scope":"outer","outerOnly":true}'></iframe>
21+
<script>
22+
const frame = document.querySelector("#webview");
23+
const childDocument = frame.contentDocument;
24+
childDocument.body.style.margin = "0";
25+
childDocument.body.dataset.vscodeContext = JSON.stringify({
26+
scope: "body", shared: "body", bodyOnly: true
27+
});
28+
const ancestor = childDocument.createElement("div");
29+
ancestor.dataset.vscodeContext = JSON.stringify({
30+
scope: "ancestor", shared: "ancestor", ancestorOnly: true
31+
});
32+
ancestor.style.cssText = [
33+
"position:absolute", "left:10px", "top:8px", "width:40px", "height:30px"
34+
].join(";");
35+
const target = childDocument.createElement("button");
36+
target.textContent = "target";
37+
target.dataset.vscodeContext = JSON.stringify({
38+
shared: "target", targetOnly: true
39+
});
40+
target.style.cssText = "width:40px;height:30px;padding:0;border:0";
41+
ancestor.appendChild(target);
42+
childDocument.body.appendChild(ancestor);
43+
44+
const state = {
45+
cancelled: 0,
46+
requests: [],
47+
visibleDuringHandoff: false,
48+
pathCrossedDocument: null
49+
};
50+
window.receiveContextMenu = data => {
51+
state.requests.push(data);
52+
childDocument.body.classList.toggle("vscode-context-menu-visible", true);
53+
state.visibleDuringHandoff =
54+
childDocument.body.classList.contains("vscode-context-menu-visible");
55+
};
56+
target.addEventListener("contextmenu", event => event.preventDefault(), {once: true});
57+
frame.contentWindow.addEventListener("contextmenu", event => {
58+
if (event.defaultPrevented) {
59+
++state.cancelled;
60+
return;
61+
}
62+
event.preventDefault();
63+
let context = {};
64+
let element = event.target;
65+
while (true) {
66+
if (!element) break;
67+
element = element.closest("[data-vscode-context]");
68+
if (!element) break;
69+
context = {...JSON.parse(element.dataset.vscodeContext), ...context};
70+
element = element.parentElement;
71+
}
72+
state.pathCrossedDocument = event.composedPath().includes(frame);
73+
receiveContextMenu({
74+
clientX: event.clientX,
75+
clientY: event.clientY,
76+
context,
77+
defaultPrevented: event.defaultPrevented
78+
});
79+
});
80+
81+
const secondaryClick = element => new test_driver.Actions()
82+
.pointerMove(0, 0, {origin: element})
83+
.pointerDown({button: 2})
84+
.pointerUp({button: 2})
85+
.send();
86+
87+
promise_test(async () => {
88+
await secondaryClick(target);
89+
assert_equals(state.cancelled, 1, "the inner cancellation reaches the host listener");
90+
assert_equals(state.requests.length, 0, "a cancelled event does not request a menu");
91+
assert_false(childDocument.body.classList.contains("vscode-context-menu-visible"));
92+
93+
await secondaryClick(target);
94+
assert_equals(state.requests.length, 1, "the uncancelled event requests one menu");
95+
const request = state.requests[0];
96+
assert_equals(request.clientX, 30, "the handoff uses child viewport X");
97+
assert_equals(request.clientY, 23, "the handoff uses child viewport Y");
98+
assert_true(request.defaultPrevented, "the host handler suppresses the browser default");
99+
assert_object_equals(request.context, {
100+
scope: "ancestor",
101+
shared: "target",
102+
bodyOnly: true,
103+
ancestorOnly: true,
104+
targetOnly: true
105+
}, "target-to-body context values merge without crossing the iframe boundary");
106+
assert_false(state.pathCrossedDocument, "the child event path excludes its iframe owner");
107+
assert_true(state.visibleDuringHandoff, "the visible-state class reaches the child body");
108+
109+
childDocument.body.classList.toggle("vscode-context-menu-visible", false);
110+
assert_false(childDocument.body.classList.contains("vscode-context-menu-visible"),
111+
"the visible-state class clears when the menu hides");
112+
}, "nested context menus preserve cancellation, ancestry, visibility, and coordinates");
113+
</script>
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"profile": "webscene-nested-context-menu-1",
3+
"wptRevision": "2c705104a295c48053eeddf7fe0170d790a4e853",
4+
"runtime": "v8",
5+
"viewport": {
6+
"width": 800,
7+
"height": 600,
8+
"deviceScaleFactor": 1
9+
},
10+
"required": [
11+
{
12+
"path": "contracts/nested-context-menu-handoff.html",
13+
"type": "contract",
14+
"capabilities": [
15+
"nested-context-menu-cancellation",
16+
"document-bounded-context-ancestry",
17+
"data-context-ancestor-merge",
18+
"nested-context-menu-visibility",
19+
"nested-context-menu-coordinate-handoff"
20+
],
21+
"evidence": [
22+
"code-oss-webview-context-menu"
23+
],
24+
"reason": "Chrome/native oracle for the unchanged Code OSS webview context-menu listener: prior cancellation, target-to-body data context merging, child-document event paths, visibility class propagation, and child-local menu coordinates."
25+
}
26+
],
27+
"harnessBlocked": [],
28+
"excluded": []
29+
}

0 commit comments

Comments
 (0)