From 25052b971d03831dc6b4cfe544a80d4b5764919c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Thu, 17 Sep 2026 20:17:04 +0200 Subject: [PATCH 1/2] Batch mutable stylesheet publication --- .../webscene_stylesheet_cssom_compatibility.h | 59 ++++- .../native/webscene_v8_runtime.cpp | 14 ++ .../webscene_v8_runtime_css_cascade.inc | 89 ++++++- .../native/webscene_v8_runtime_dom_core.inc | 61 +++++ .../webscene_v8_runtime_dom_properties.inc | 11 + .../native/webscene_v8_runtime_lifecycle.inc | 3 + .../native/webscene_v8_runtime_navigation.inc | 3 + .../native/webscene_v8_runtime_state.inc | 10 + .../native_v8_runtime_browser_dom_tests.inc | 238 +++++++++++++++++- .../native_v8_runtime_css_layout_tests.inc | 19 +- .../tests/native_v8_runtime_tests.cpp | 12 + .../cssom-batched-rule-mutation.html | 128 ++++++++++ .../webscene-component-profile.json | 7 + 13 files changed, 635 insertions(+), 19 deletions(-) create mode 100644 tests/WebPlatformSubset/contracts/cssom-batched-rule-mutation.html diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_stylesheet_cssom_compatibility.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_stylesheet_cssom_compatibility.h index 0e45c0d46..851713a80 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_stylesheet_cssom_compatibility.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_stylesheet_cssom_compatibility.h @@ -4,8 +4,9 @@ namespace webscene_native { // The pinned WebScene exposes a stable native HTMLStyleElement.sheet object, // but no rule mutation methods. Its textContent setter *does* replace that -// owner's parsed native rules, recascade, and invalidate layout. Bridge the -// dynamic style-rule operations used by Code OSS to that existing renderer. +// owner's parsed native rules. Bridge dynamic CSSOM operations to a native +// staged rule-set replacement so one task publishes each owner's final rules +// and recascades once. // This is a bounded adapter, not a complete CSSOM implementation: constructed // sheets, imported-sheet inspection and nested rule mutation remain unsupported. // Semantics: https://www.w3.org/TR/cssom-1/ @@ -72,10 +73,19 @@ inline constexpr std::string_view cssCompatibilityScript = R"JS( return state; }; const publish = state => { - // Native textContent replacement is the rendering operation. Do not merely - // update JavaScript records: every insertion/deletion reaches the cascade. const source = state.rules.map(rule => rule.cssText).join('\n'); - state.owner.textContent = source; + const publishedSource = state.disabled ? '' + : state.mediaText.trim() ? `@media ${state.mediaText} {\n${source}\n}` + : source; + if (typeof state.sheet.__webSceneStageRules === 'function') { + // CSSOM mutation does not replace the style element's DOM text nodes. + // Stage the final serialized rule set in native state; synchronous + // style/layout reads and the browser-task boundary flush it. + state.sheet.__webSceneStageRules(publishedSource); + } else { + state.owner.textContent = publishedSource; + state.ownerSource = publishedSource; + } state.source = source; }; const makeRule = (state, parsed) => { @@ -129,15 +139,20 @@ inline constexpr std::string_view cssCompatibilityScript = R"JS( }; const synchronize = state => { const source = state.owner.textContent || ''; - if (source === state.source) return; + if (source === state.ownerSource) return; const parsed = splitRules(source); for (const rule of state.rules) rule.detach(); state.rules = parsed.map(rule => makeRule(state, rule)); state.source = source; + state.ownerSource = source; }; const augment = sheet => { if (!sheet || typeof sheet.insertRule === 'function') return sheet; - const state = { sheet, owner: sheet.ownerNode, source: undefined, rules: [] }; + const state = { + sheet, owner: sheet.ownerNode, + source: undefined, ownerSource: undefined, rules: [], disabled: false, + mediaText: sheet.ownerNode.getAttribute('media') || '' + }; const list = new Proxy({}, { get(_target, key) { synchronize(state); @@ -152,9 +167,39 @@ inline constexpr std::string_view cssCompatibilityScript = R"JS( deleteProperty() { return false; }, defineProperty() { return false; } }); + const media = {}; + Object.defineProperties(media, { + mediaText: { + enumerable: true, + get() { return state.mediaText; }, + set(value) { + state.mediaText = text(value).trim(); + if (state.mediaText) state.owner.setAttribute('media', state.mediaText); + else state.owner.removeAttribute('media'); + publish(state); + } + }, + length: { enumerable: true, get() { + return state.mediaText ? state.mediaText.split(',').length : 0; + } }, + item: { value(index) { + return state.mediaText.split(',').map(value => value.trim())[Number(index)] || null; + } } + }); sheets.set(sheet, state); Object.defineProperties(sheet, { cssRules: { configurable: true, enumerable: true, get() { stateFor(this); return list; } }, + disabled: { + configurable: true, enumerable: true, + get() { return stateFor(this).disabled; }, + set(value) { + const current = stateFor(this), disabled = Boolean(value); + if (current.disabled === disabled) return; + current.disabled = disabled; + publish(current); + } + }, + media: { configurable: true, enumerable: true, get() { stateFor(this); return media; } }, insertRule: { configurable: true, writable: true, value(rule, index = 0) { if (arguments.length === 0) throw new TypeError('A CSS rule is required'); const current = stateFor(this); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp index 3bf7ef565..959205e79 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp @@ -7145,13 +7145,27 @@ v8_dom_runtime::memory_metrics v8_dom_runtime::read_memory_metrics() const noexc + impl_->class_list_wrappers.size() + impl_->sandbox_token_list_wrappers.size() + impl_->style_wrappers.size() + + impl_->stylesheet_wrappers.size() + impl_->computed_style_wrappers.size(); result.native_wrapper_storage_bytes = wrapper_map_storage(impl_->node_wrappers) + wrapper_map_storage(impl_->class_list_wrappers) + wrapper_map_storage(impl_->sandbox_token_list_wrappers) + wrapper_map_storage(impl_->style_wrappers) + + wrapper_map_storage(impl_->stylesheet_wrappers) + wrapper_map_storage(impl_->computed_style_wrappers); + result.native_wrapper_storage_bytes += + impl_->stylesheet_cssom_sources.bucket_count() * sizeof(void*); + for (const auto& [owner_id, source] : impl_->stylesheet_cssom_sources) { + static_cast(owner_id); + result.native_wrapper_storage_bytes += + sizeof(std::pair) + + 2U * sizeof(void*) + source.capacity() + 1U; + } + result.native_wrapper_storage_bytes += + impl_->pending_stylesheet_replacements.capacity() + * sizeof(impl_->pending_stylesheet_replacements.front()) + + impl_->pending_stylesheet_replacement_bytes; const auto listener_map_storage = [](const auto& map) { using map_type = std::decay_t; using vector_type = typename map_type::mapped_type; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_cascade.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_cascade.inc index 720052881..8540573e4 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_cascade.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_cascade.inc @@ -1876,8 +1876,77 @@ std::move(custom_property)}); } + void erase_pending_stylesheet_replacement(uint32_t owner_id) + { + std::erase_if( + pending_stylesheet_replacements, + [&](const auto& pending) { + if (pending.owner_id != owner_id) return false; + pending_stylesheet_replacement_bytes -= std::min( + pending_stylesheet_replacement_bytes, + pending.source.size()); + return true; + }); + } + + void flush_pending_stylesheet_replacements() + { + if (pending_stylesheet_replacements.empty()) return; + auto pending = std::move(pending_stylesheet_replacements); + pending_stylesheet_replacements.clear(); + pending_stylesheet_replacement_bytes = 0U; + auto* previous_root = const_cast(active_css_cascade_root); + std::vector roots; + for (const auto& replacement : pending) { + auto* owner = document.find_by_native_id(replacement.owner_id); + auto* root = document.find_by_native_id(replacement.cascade_root_id); + if (owner == nullptr || root == nullptr || !is_connected(*owner)) { + continue; + } + if (std::find(roots.begin(), roots.end(), root) + == roots.end()) { + roots.push_back(root); + } + } + for (auto* root : roots) { + activate_css_cascade(root); + std::unordered_set owners; + for (const auto& replacement : pending) { + auto* owner = document.find_by_native_id(replacement.owner_id); + if (replacement.cascade_root_id == root->id + && owner != nullptr + && is_connected(*owner)) { + owners.insert(owner->id); + } + } + std::erase_if(css_rules, [&](const auto& rule) { + return owners.contains(rule.stylesheet_owner_id); + }); + for (auto& replacement : pending) { + auto* owner = document.find_by_native_id(replacement.owner_id); + if (replacement.cascade_root_id != root->id + || owner == nullptr + || !is_connected(*owner) + || replacement.source.empty()) { + continue; + } + const auto* shadow_root = + document.containing_shadow_root(*owner); + add_stylesheet( + std::move(replacement.source), + replacement.base_address, + owner->id, + shadow_root == nullptr ? 0U : shadow_root->id); + } + rebuild_css_rule_indexes_and_root_variables(); + schedule_style_recascade(*root, true, "stylesheet-change"); + } + if (previous_root != nullptr) activate_css_cascade(previous_root); + } + void flush_pending_style_recascades() { + flush_pending_stylesheet_replacements(); if (!pending_attribute_selector_transitions.empty()) { auto transitions = std::move(pending_attribute_selector_transitions); pending_attribute_selector_transitions.clear(); @@ -2293,6 +2362,14 @@ void recascade_after_stylesheet_change() { + if (style_recascade_batch_depth != 0U) { + schedule_style_recascade( + active_root(), + true, + "stylesheet-change"); + document.mark_dirty(); + return; + } apply_css_rules_subtree(active_root()); document.mark_dirty(); } @@ -2333,11 +2410,19 @@ }); if (styles.empty()) return; + for (const auto* style : styles) { + if (style != nullptr) erase_pending_stylesheet_replacement(style->id); + } for (auto* style : styles) deactivate_connected_stylesheets(*style, false); const auto& base = current_base_address(); for (auto* style : styles) { - std::string stylesheet; - append_node_text(*style, stylesheet); + auto stylesheet = std::string{}; + if (const auto staged = stylesheet_cssom_sources.find(style->id); + staged != stylesheet_cssom_sources.end()) { + stylesheet = staged->second; + } else { + append_node_text(*style, stylesheet); + } if (!stylesheet.empty()) { const auto* shadow_root = document.containing_shadow_root(*style); add_stylesheet( diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc index 76757a4ca..639e71d36 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc @@ -352,10 +352,61 @@ local_context, js_string(isolate, "ownerNode"), wrap_node(node)).Check(); + auto stage_rules = v8::Function::New( + local_context, + stage_style_sheet_rules, + wrap_node(node)).ToLocalChecked(); + result->DefineOwnProperty( + local_context, + js_string(isolate, "__webSceneStageRules"), + stage_rules, + static_cast( + v8::DontEnum | v8::DontDelete | v8::ReadOnly)) + .Check(); stylesheet_wrappers.emplace(key, v8::Global(isolate, result)); return augment_style_sheet(result); } + static void stage_style_sheet_rules( + const v8::FunctionCallbackInfo& info) + { + auto* self = current(info.GetIsolate()); + auto* owner = info.Data()->IsObject() + ? unwrap_node(info.Data().As()) + : nullptr; + if (self == nullptr || owner == nullptr || info.Length() < 1) return; + v8::Local converted; + if (!info[0]->ToString(info.GetIsolate()->GetCurrentContext()) + .ToLocal(&converted)) { + return; + } + auto source = to_wtf8(info.GetIsolate(), converted); + self->erase_pending_stylesheet_replacement(owner->id); + constexpr auto maximum_pending_owners = size_t{1024U}; + constexpr auto maximum_pending_bytes = size_t{32U * 1024U * 1024U}; + if (self->pending_stylesheet_replacements.size() >= maximum_pending_owners + || (!self->pending_stylesheet_replacements.empty() + && source.size() > maximum_pending_bytes + - std::min( + self->pending_stylesheet_replacement_bytes, + maximum_pending_bytes))) { + self->flush_pending_stylesheet_replacements(); + } + self->stylesheet_cssom_sources.insert_or_assign(owner->id, source); + auto* cascade_root = self->css_cascade_root_for_node(*owner); + const auto base_address = cascade_root != nullptr + && self->frame_base_addresses.contains(cascade_root) + ? self->frame_base_addresses.at(cascade_root) + : self->document_base_address; + self->pending_stylesheet_replacement_bytes += source.size(); + self->pending_stylesheet_replacements.push_back({ + owner->id, + cascade_root == nullptr ? 0U : cascade_root->id, + base_address, + std::move(source)}); + self->document.mark_dirty(); + } + v8::Local augment_style_sheet(v8::Local sheet) { const auto local_context = isolate->GetCurrentContext(); @@ -949,6 +1000,7 @@ collect(class_list_wrappers, key); collect(sandbox_token_list_wrappers, key); collect(style_wrappers, key); + collect(stylesheet_wrappers, key); collect(computed_style_wrappers, key); collect(canvas_contexts, key); }); @@ -987,6 +1039,7 @@ weaken(class_list_wrappers, key); weaken(sandbox_token_list_wrappers, key); weaken(style_wrappers, key); + weaken(stylesheet_wrappers, key); weaken(computed_style_wrappers, key); weaken(canvas_contexts, key); }); @@ -1012,6 +1065,7 @@ || weak(class_list_wrappers, key) || weak(sandbox_token_list_wrappers, key) || weak(style_wrappers, key) + || weak(stylesheet_wrappers, key) || weak(computed_style_wrappers, key) || weak(canvas_contexts, key)) { return true; @@ -1071,6 +1125,7 @@ retain(class_list_wrappers, key); retain(sandbox_token_list_wrappers, key); retain(style_wrappers, key); + retain(stylesheet_wrappers, key); retain(computed_style_wrappers, key); retain(canvas_contexts, key); }); @@ -1351,6 +1406,7 @@ || inspect(class_list_wrappers, key) || inspect(sandbox_token_list_wrappers, key) || inspect(style_wrappers, key) + || inspect(stylesheet_wrappers, key) || inspect(computed_style_wrappers, key) || inspect(canvas_contexts, key); } @@ -1383,6 +1439,7 @@ erase_wrappers(class_list_wrappers); erase_wrappers(sandbox_token_list_wrappers); erase_wrappers(style_wrappers); + erase_wrappers(stylesheet_wrappers); erase_wrappers(computed_style_wrappers); erase_wrappers(canvas_contexts); std::erase_if(detached_documents, [&](const auto& entry) { @@ -1391,6 +1448,10 @@ std::erase_if(detached_document_roots, [&](const auto id) { return node_ids.contains(static_cast(id)); }); + for (const auto id : node_ids) { + erase_pending_stylesheet_replacement(id); + stylesheet_cssom_sources.erase(id); + } std::erase_if(canvas_states, [&](const auto& entry) { return node_ids.contains(static_cast(entry.first)); }); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_properties.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_properties.inc index 9cc707e5d..09c7ea78b 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_properties.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_properties.inc @@ -265,6 +265,13 @@ // Stable nonempty text cannot change structural selector subjects, but // style element text must still refresh its stylesheet on every edit. if (node.kind == dom_node_kind::text) { + for (auto* ancestor = node.parent; ancestor != nullptr; + ancestor = ancestor->parent) { + if (ancestor->tag != "style") continue; + erase_pending_stylesheet_replacement(ancestor->id); + stylesheet_cssom_sources.erase(ancestor->id); + break; + } activate_connected_stylesheet(node); if (empty_state_changed && node.parent != nullptr) recascade_changed_child_list(*node.parent); @@ -498,6 +505,10 @@ return; } if (!self->blur_active_descendant_before_replacing_children(*node)) return; + if (node->tag == "style") { + self->erase_pending_stylesheet_replacement(node->id); + self->stylesheet_cssom_sources.erase(node->id); + } self->detach_all_children(*node); node->text_content.clear(); if (!text.empty()) { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc index 9a6ae48b8..f6642d752 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc @@ -258,6 +258,9 @@ sandbox_token_list_wrappers.clear(); style_wrappers.clear(); stylesheet_wrappers.clear(); + pending_stylesheet_replacements.clear(); + pending_stylesheet_replacement_bytes = 0U; + stylesheet_cssom_sources.clear(); detached_documents.clear(); node_wrappers.clear(); attr_wrappers.clear(); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc index 0411ec126..948e57a99 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc @@ -244,6 +244,9 @@ sandbox_token_list_wrappers.clear(); style_wrappers.clear(); stylesheet_wrappers.clear(); + pending_stylesheet_replacements.clear(); + pending_stylesheet_replacement_bytes = 0U; + stylesheet_cssom_sources.clear(); detached_documents.clear(); detached_document_roots.clear(); node_wrappers.clear(); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc index 686809daa..b30e7fbf2 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc @@ -112,6 +112,16 @@ std::unordered_map> sandbox_token_list_wrappers; std::unordered_map> style_wrappers; std::unordered_map> stylesheet_wrappers; + struct pending_stylesheet_replacement final + { + uint32_t owner_id{0U}; + uint32_t cascade_root_id{0U}; + std::string base_address; + std::string source; + }; + std::vector pending_stylesheet_replacements; + size_t pending_stylesheet_replacement_bytes{0U}; + std::unordered_map stylesheet_cssom_sources; std::unordered_map> computed_style_wrappers; std::unordered_map service_worker_registrations; diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_browser_dom_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_browser_dom_tests.inc index 1cbf18a02..950fe05ac 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_browser_dom_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_browser_dom_tests.inc @@ -1578,21 +1578,55 @@ void test_native_mutable_stylesheet_cssom(webscene_engine* engine) const style = document.createElement('style'); const target = document.createElement('div'); target.className = className; + style.textContent = `.${className} { display: block; color: rgb(1, 2, 3); }`; document.head.appendChild(style); document.body.appendChild(target); const sheet = style.sheet; const rules = sheet.cssRules; - const index = sheet.insertRule(`.${className} { display: none; }`, 0); - const rule = rules[0]; + const authoredText = style.textContent; + const observer = new MutationObserver(() => {}); + observer.observe(style, { childList: true, characterData: true, subtree: true }); + let parseError = ''; + try { sheet.insertRule(`.${className} {`); } + catch (error) { parseError = error.name; } + const index = sheet.insertRule(`.${className} { display: none; }`, rules.length); + const rule = rules[index]; const inserted = [index, rules.length, rule.selectorText, - view.getComputedStyle(target).display]; + view.getComputedStyle(target).display, style.textContent === authoredText, + style.sheet === sheet, target.matches(`.${className}`), parseError]; rule.style.setProperty('display', display); const mutated = [rules.length, rule.style.display, view.getComputedStyle(target).display]; - sheet.deleteRule(0); - const deleted = [rules.length, rule.parentStyleSheet === null, + sheet.deleteRule(index); + const mediaIndex = sheet.insertRule( + `@media (min-width: 1px) { .${className} { display: ${display}; } }`, + rules.length); + const media = [rules.length, rules[mediaIndex].cssText.startsWith('@media'), view.getComputedStyle(target).display]; - return { inserted, mutated, deleted }; + sheet.deleteRule(mediaIndex); + sheet.insertRule(`.${className} { display: grid; }`, rules.length); + style.remove(); + const detached = style.sheet === null; + document.head.appendChild(style); + const reconnected = [style.sheet === sheet, + view.getComputedStyle(target).display, style.textContent === authoredText]; + sheet.disabled = true; + const disabled = [sheet.disabled, view.getComputedStyle(target).display]; + sheet.disabled = false; + const enabled = [sheet.disabled, view.getComputedStyle(target).display]; + sheet.media.mediaText = '(max-width: 1px)'; + const mediaMiss = [sheet.media.mediaText, sheet.media.length, + sheet.media.item(0), view.getComputedStyle(target).display]; + sheet.media.mediaText = '(min-width: 1px)'; + const mediaMatch = [sheet.media.mediaText, + view.getComputedStyle(target).display]; + const deleted = [rules.length, rule.parentStyleSheet === null, + observer.takeRecords().length, detached]; + observer.disconnect(); + style.remove(); + target.remove(); + return { inserted, mutated, media, reconnected, disabled, enabled, + mediaMiss, mediaMatch, deleted }; }; const frame = document.createElement('iframe'); document.body.appendChild(frame); @@ -1607,11 +1641,201 @@ void test_native_mutable_stylesheet_cssom(webscene_engine* engine) })())JS", "native-mutable-stylesheet-cssom.js"); require( result - == R"JSON({"main":{"inserted":[0,1,".monaco-main","none"],"mutated":[1,"inline-block","inline-block"],"deleted":[0,true,"block"]},"frame":{"inserted":[0,1,".monaco-frame","none"],"mutated":[1,"inline-flex","inline-flex"],"deleted":[0,true,"block"]}})JSON", + == R"JSON({"main":{"inserted":[1,2,".monaco-main","none",true,true,true,"SyntaxError"],"mutated":[2,"inline-block","inline-block"],"media":[2,true,"inline-block"],"reconnected":[true,"grid",true],"disabled":[true,"block"],"enabled":[false,"grid"],"mediaMiss":["(max-width: 1px)",1,"(max-width: 1px)","block"],"mediaMatch":["(min-width: 1px)","grid"],"deleted":[2,true,0,true]},"frame":{"inserted":[1,2,".monaco-frame","none",true,true,true,"SyntaxError"],"mutated":[2,"inline-flex","inline-flex"],"media":[2,true,"inline-flex"],"reconnected":[true,"grid",true],"disabled":[true,"block"],"enabled":[false,"grid"],"mediaMiss":["(max-width: 1px)",1,"(max-width: 1px)","block"],"mediaMatch":["(min-width: 1px)","grid"],"deleted":[2,true,0,true]}})JSON", "mutable stylesheet CSSOM did not recascade main/frame styles: " + result); } +void test_batched_stylesheet_rule_mutation_performance() +{ + auto* engine = webscene_engine_create(0); + require(engine != nullptr, "stylesheet mutation performance engine creation failed"); + resize(engine, 800, 600, 1U); + wait_for_consumed_inputs( + engine, 1U, "stylesheet mutation performance viewport was not consumed"); + require(webscene_engine_set_runtime_work_metrics_enabled(engine, 1U) != 0, + "stylesheet mutation runtime-work metrics could not be enabled"); + webscene_engine_metrics engine_before{}; + webscene_engine_get_metrics(engine, &engine_before); + webscene_runtime_work_metrics work_before{sizeof(webscene_runtime_work_metrics)}; + require(webscene_engine_get_runtime_work_metrics(engine, &work_before) != 0, + "stylesheet mutation baseline work metrics were unavailable"); + execute_and_wait(engine, "globalThis.__stylesheetMutationBaseline = true", + "stylesheet-rule-mutation-baseline.js"); + webscene_engine_memory_metrics memory_before{sizeof(webscene_engine_memory_metrics)}; + for (auto attempt = 0; attempt < 500; ++attempt) { + require(webscene_engine_get_memory_metrics(engine, &memory_before) != 0, + "stylesheet mutation baseline memory metrics were unavailable"); + if (memory_before.v8_used_heap_bytes != 0U + && memory_before.native_dom_node_count != 0U) break; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + require(memory_before.v8_used_heap_bytes != 0U, + "stylesheet mutation baseline heap snapshot was unavailable"); + const auto rss_before = file_system_access_test_peak_rss_bytes(); + execute(engine, R"JS((() => { + const style = document.createElement('style'); + globalThis.__themeConnections = 0; + customElements.define('x-theme-target', class extends HTMLElement { + connectedCallback() { globalThis.__themeConnections++; } + }); + const target = document.createElement('x-theme-target'); + target.className = 'theme-target theme-rule-255'; + style.textContent = '.theme-seed { color: rgb(1, 2, 3); }'; + document.head.appendChild(style); + document.body.appendChild(target); + const authoredText = style.textContent; + const observer = new MutationObserver(() => {}); + observer.observe(style, { childList: true, characterData: true, subtree: true }); + globalThis.__stylesheetMutationResult = null; + setTimeout(() => { + try { + const sheet = style.sheet; + const rules = sheet.cssRules; + if (style.sheet !== sheet || rules !== sheet.cssRules) { + throw new Error('stylesheet or rule-list identity changed'); + } + const durations = []; + for (let cycle = 0; cycle < 20; cycle++) { + const started = performance.now(); + while (rules.length) sheet.deleteRule(0); + for (let index = 0; index < 256; index++) { + const red = (cycle + index) & 255; + sheet.insertRule(`.theme-rule-${index} { color: rgb(${red}, 7, 9); }`, 0); + } + if (rules.length !== 256 + || !rules[0].cssText.includes('.theme-rule-255') + || style.textContent !== authoredText) { + throw new Error('synchronous CSSOM state mismatch'); + } + const expected = `rgb(${(cycle + 255) & 255}, 7, 9)`; + if (getComputedStyle(target).color !== expected) { + throw new Error('computed style did not observe final rule set'); + } + if (!target.matches('.theme-target.theme-rule-255') + || target.getBoundingClientRect().width < 0) { + throw new Error('selector or layout read failed'); + } + durations.push(performance.now() - started); + } + const ordered = durations.slice().sort((left, right) => left - right); + const p95 = ordered[18], maximum = ordered[19]; + if (p95 > 16 || maximum > 500) { + throw new Error(`latency exceeded p95=${p95} max=${maximum}`); + } + while (rules.length) sheet.deleteRule(0); + sheet.insertRule('.theme-target { display: none; }', 0); + const order = ['sync']; + queueMicrotask(() => { + order.push('microtask'); + if (rules.length !== 1 || style.textContent !== authoredText) { + throw new Error('microtask observed stale CSSOM or changed DOM text'); + } + }); + setTimeout(() => { + try { + order.push('timer'); + if (getComputedStyle(target).display !== 'none' + || order.join(',') !== 'sync,microtask,timer' + || globalThis.__themeConnections !== 1 + || observer.takeRecords().length !== 0) { + throw new Error('checkpoint, observer, or custom-element ordering failed'); + } + observer.disconnect(); + style.remove(); + target.remove(); + globalThis.__stylesheetMutationResult = { + status: 'passed', cycles: durations.length, + p95, max: maximum, rules: style.sheet === null ? 0 : -1, + authoredTextStable: style.textContent === authoredText, + order + }; + } catch (error) { + globalThis.__stylesheetMutationResult = { + status: 'failed', error: String(error) + }; + } + }, 0); + } catch (error) { + globalThis.__stylesheetMutationResult = { + status: 'failed', error: String(error) + }; + } + }, 0); + })())JS", "stylesheet-rule-mutation-setup.js"); + const auto result = evaluate_until_equals( + engine, + "globalThis.__stylesheetMutationResult?.status === 'passed' ? 'passed' : " + "globalThis.__stylesheetMutationResult?.status ?? 'pending'", + "stylesheet-rule-mutation-status.js", + R"("passed")", + 500); + require(result == R"("passed")", + "stylesheet mutation performance workload failed: " + result); + const auto metrics = evaluate( + engine, + "globalThis.__stylesheetMutationResult", + "stylesheet-rule-mutation-result.js"); + require(webscene_engine_request_low_memory(engine) != 0, + "stylesheet mutation low-memory request was rejected"); + webscene_engine_memory_metrics memory_after{sizeof(webscene_engine_memory_metrics)}; + for (auto attempt = 0; attempt < 500; ++attempt) { + require(webscene_engine_get_memory_metrics(engine, &memory_after) != 0, + "stylesheet mutation final memory metrics were unavailable"); + if (memory_after.low_memory_notifications + > memory_before.low_memory_notifications + && memory_after.native_css_rule_count + <= memory_before.native_css_rule_count + && memory_after.native_dom_node_count + <= memory_before.native_dom_node_count + 8U + && memory_after.native_wrapper_handle_count + <= memory_before.native_wrapper_handle_count + 8U) break; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + webscene_engine_metrics engine_after{}; + webscene_engine_get_metrics(engine, &engine_after); + webscene_runtime_work_metrics work_after{sizeof(webscene_runtime_work_metrics)}; + require(webscene_engine_get_runtime_work_metrics(engine, &work_after) != 0, + "stylesheet mutation final work metrics were unavailable"); + const auto rss_after = file_system_access_test_peak_rss_bytes(); + const auto lifecycle_bounded = memory_after.low_memory_notifications + > memory_before.low_memory_notifications + && memory_after.v8_used_heap_bytes + <= memory_before.v8_used_heap_bytes + 8U * 1024U * 1024U + && memory_after.native_css_rule_count == memory_before.native_css_rule_count + && memory_after.native_dom_node_count + <= memory_before.native_dom_node_count + 8U + && memory_after.native_wrapper_handle_count + <= memory_before.native_wrapper_handle_count + 8U; + if (!lifecycle_bounded) { + fail("stylesheet mutation lifecycle retained heap, rules, nodes, or wrappers: " + + std::to_string(memory_before.low_memory_notifications) + "/" + + std::to_string(memory_after.low_memory_notifications) + " heap=" + + std::to_string(memory_before.v8_used_heap_bytes) + "/" + + std::to_string(memory_after.v8_used_heap_bytes) + " rules=" + + std::to_string(memory_before.native_css_rule_count) + "/" + + std::to_string(memory_after.native_css_rule_count) + " nodes=" + + std::to_string(memory_before.native_dom_node_count) + "/" + + std::to_string(memory_after.native_dom_node_count) + " wrappers=" + + std::to_string(memory_before.native_wrapper_handle_count) + "/" + + std::to_string(memory_after.native_wrapper_handle_count)); + } + require(rss_before == 0U || rss_after <= rss_before + 64U * 1024U * 1024U, + "stylesheet mutation workload exceeded the 64 MiB peak RSS budget"); + require(engine_after.layout_passes <= engine_before.layout_passes + 23U + && engine_after.published_scenes <= engine_before.published_scenes + 2U + && memory_after.pending_scene_count <= 2U + && work_after.timers_fired >= work_before.timers_fired + 2U, + "stylesheet mutation workload exceeded layout, scene, queue, or task bounds"); + std::cout << "stylesheet-mutation-profile=" << metrics + << " layoutDelta=" << (engine_after.layout_passes - engine_before.layout_passes) + << " sceneDelta=" << (engine_after.published_scenes - engine_before.published_scenes) + << " heapBefore=" << memory_before.v8_used_heap_bytes + << " heapAfter=" << memory_after.v8_used_heap_bytes + << " rssBefore=" << rss_before << " rssAfter=" << rss_after << '\n'; + webscene_engine_destroy(engine); +} + void test_node_has_child_nodes_contract_and_performance(webscene_engine* engine) { const auto result = evaluate(engine, R"JS( diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_css_layout_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_css_layout_tests.inc index b03b73193..4050ee039 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_css_layout_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_css_layout_tests.inc @@ -1324,9 +1324,12 @@ void test_monaco_view_line_dom_mutations(webscene_engine* engine) const auto result = evaluate(engine, R"JS( (() => { document.body.innerHTML = ''; + const editor = document.createElement('div'); + editor.className = 'monaco-editor'; const layer = document.createElement('div'); layer.className = 'view-lines'; - document.body.appendChild(layer); + editor.appendChild(layer); + document.body.appendChild(editor); layer.innerHTML = '
' + 'const answer = ' @@ -1350,6 +1353,11 @@ void test_monaco_view_line_dom_mutations(webscene_engine* engine) replacement.innerHTML = '// editable model'; layer.children[1].replaceWith(replacement); + const marker = 'SCENETECH_NATIVE_EDIT_OK \u202e'; + const modelText = replacement.firstChild.firstChild.firstChild; + modelText.replaceData(0, modelText.length, marker); + modelText.splitText(marker.length - 1); + modelText.parentNode.normalize(); return { initialCount: initial.length, @@ -1361,12 +1369,17 @@ void test_monaco_view_line_dom_mutations(webscene_engine* engine) === layer.firstChild, replacementParent: replacement.parentNode === layer, oldDetached: initial[1].parentNode === null, - tokenCount: layer.querySelectorAll('span').length + tokenCount: layer.querySelectorAll('span').length, + markerVisible: Array.from( + document.querySelectorAll('.monaco-editor .view-lines')) + .some(element => element.textContent.includes('SCENETECH_NATIVE_EDIT_OK')), + markerExact: replacement.textContent === marker, + normalizedTextNodes: replacement.firstChild.firstChild.childNodes.length }; })() )JS", "native-monaco-view-line-dom.js"); const auto expected = - R"({"initialCount":3,"backward":["function greet() {","// native WebScene","const answer = 42;"],"finalCount":4,"finalText":["const answer = 42;","// editable model","function greet() {","}"],"siblingChain":true,"replacementParent":true,"oldDetached":true,"tokenCount":12})"; + R"({"initialCount":3,"backward":["function greet() {","// native WebScene","const answer = 42;"],"finalCount":4,"finalText":["const answer = 42;","SCENETECH_NATIVE_EDIT_OK ‮","function greet() {","}"],"siblingChain":true,"replacementParent":true,"oldDetached":true,"tokenCount":12,"markerVisible":true,"markerExact":true,"normalizedTextNodes":1})"; require( result == expected, "Monaco view-line DOM mutation semantics regressed: " + result); diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp index dee6fe8e0..5495d94f4 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -166,6 +166,10 @@ int main() test_dom_token_list_collection_performance_gate(); return 0; } + if (selected == "stylesheet-mutation-performance") { + test_batched_stylesheet_rule_mutation_performance(); + return 0; + } if (selected == "iframe-sandbox") { test_iframe_sandbox_dom_token_list_security_and_lifecycle_gate(); return 0; @@ -434,6 +438,14 @@ int main() webscene_engine_destroy(focused_engine); return 0; } + if (selected == "monaco-view-lines") { + auto* focused_engine = webscene_engine_create(0); + require(focused_engine != nullptr, + "Monaco view-line engine creation failed"); + test_monaco_view_line_dom_mutations(focused_engine); + webscene_engine_destroy(focused_engine); + return 0; + } if (selected == "node-has-child-nodes") { auto* focused_engine = webscene_engine_create(0); require(focused_engine != nullptr, "Node.hasChildNodes engine creation failed"); diff --git a/tests/WebPlatformSubset/contracts/cssom-batched-rule-mutation.html b/tests/WebPlatformSubset/contracts/cssom-batched-rule-mutation.html new file mode 100644 index 000000000..a5a95d6c9 --- /dev/null +++ b/tests/WebPlatformSubset/contracts/cssom-batched-rule-mutation.html @@ -0,0 +1,128 @@ + + +CSSOM rule mutation remains synchronous while native style work coalesces +
+ diff --git a/tests/WebPlatformSubset/webscene-component-profile.json b/tests/WebPlatformSubset/webscene-component-profile.json index 6410e3935..56a2e27ad 100644 --- a/tests/WebPlatformSubset/webscene-component-profile.json +++ b/tests/WebPlatformSubset/webscene-component-profile.json @@ -1130,6 +1130,13 @@ "evidence": ["web-platform-cssom-semantics", "tradingview-symbol-search-regression"], "reason": "CSSOM computed-style and geometry reads are synchronous style-recalculation barriers. WebScene may coalesce repeated invalidations within one browser task, but declaration removal must restore stylesheet and inherited values before any observable read in that task." }, + { + "path": "contracts/cssom-batched-rule-mutation.html", + "type": "contract", + "capabilities": ["cssom-rule-list-identity", "cssom-synchronous-rule-mutation", "cssom-stylesheet-disabled", "cssom-stylesheet-media", "cssom-task-checkpoint-style-flush", "cssom-style-text-topology", "cssom-batched-mutation-performance"], + "evidence": ["cssom-cssstylesheet-insertrule", "cssom-cssstylesheet-deleterule", "cssom-cssstylesheet-disabled", "cssom-medialist-media-text", "html-style-element-text-content", "vscode-theme-rule-mutation"], + "reason": "CSSStyleSheet rule, enabled-state, and media mutations update live CSSOM and synchronous style/layout reads immediately without replacing the owner style element's DOM text. Native work may publish each owner's final rule set once at a browser-task checkpoint after microtasks. Twenty 256-rule replacement cycles retain exact CSSOM/DOM identity with warm p95 at most 16 ms and cold max at most 500 ms." + }, { "path": "contracts/cssom-computed-style-named-properties.html", "type": "contract", From fe4bba7daf48e12bca71798e9a39338c6ac52f3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Thu, 17 Sep 2026 21:11:50 +0200 Subject: [PATCH 2/2] Bound oversized stylesheet staging --- .../webscene_stylesheet_cssom_compatibility.h | 4 +++- .../native/webscene_v8_runtime_dom_core.inc | 23 +++++++++++++++---- .../native_v8_runtime_browser_dom_tests.inc | 16 +++++++++++++ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_stylesheet_cssom_compatibility.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_stylesheet_cssom_compatibility.h index 851713a80..f7a13c3d3 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_stylesheet_cssom_compatibility.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_stylesheet_cssom_compatibility.h @@ -151,7 +151,9 @@ inline constexpr std::string_view cssCompatibilityScript = R"JS( const state = { sheet, owner: sheet.ownerNode, source: undefined, ownerSource: undefined, rules: [], disabled: false, - mediaText: sheet.ownerNode.getAttribute('media') || '' + mediaText: typeof sheet.ownerNode?.getAttribute === 'function' + ? sheet.ownerNode.getAttribute('media') || '' + : '' }; const list = new Proxy({}, { get(_target, key) { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc index 639e71d36..d48dcdd80 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc @@ -384,12 +384,24 @@ self->erase_pending_stylesheet_replacement(owner->id); constexpr auto maximum_pending_owners = size_t{1024U}; constexpr auto maximum_pending_bytes = size_t{32U * 1024U * 1024U}; + if (source.size() > maximum_pending_bytes) { + // A single source cannot fit in the bounded coalescing queue. Keep + // the CSSOM source for disconnect/reconnect semantics, but publish + // connected owners immediately so the same bytes are never copied + // into pending replacement storage as well. + self->flush_pending_stylesheet_replacements(); + self->stylesheet_cssom_sources.insert_or_assign( + owner->id, std::move(source)); + self->activate_connected_stylesheet(*owner); + self->document.mark_dirty(); + info.GetReturnValue().Set(v8::False(info.GetIsolate())); + return; + } if (self->pending_stylesheet_replacements.size() >= maximum_pending_owners - || (!self->pending_stylesheet_replacements.empty() - && source.size() > maximum_pending_bytes - - std::min( - self->pending_stylesheet_replacement_bytes, - maximum_pending_bytes))) { + || source.size() > maximum_pending_bytes + - std::min( + self->pending_stylesheet_replacement_bytes, + maximum_pending_bytes)) { self->flush_pending_stylesheet_replacements(); } self->stylesheet_cssom_sources.insert_or_assign(owner->id, source); @@ -405,6 +417,7 @@ base_address, std::move(source)}); self->document.mark_dirty(); + info.GetReturnValue().Set(v8::True(info.GetIsolate())); } v8::Local augment_style_sheet(v8::Local sheet) diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_browser_dom_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_browser_dom_tests.inc index 950fe05ac..3484a93eb 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_browser_dom_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_browser_dom_tests.inc @@ -1653,6 +1653,22 @@ void test_batched_stylesheet_rule_mutation_performance() resize(engine, 800, 600, 1U); wait_for_consumed_inputs( engine, 1U, "stylesheet mutation performance viewport was not consumed"); + const auto oversized_result = evaluate(engine, R"JS((() => { + const style = document.createElement('style'); + document.head.appendChild(style); + const sheet = style.sheet; + const stage = sheet.__webSceneStageRules; + style.remove(); + // Detached owners retain their CSSOM source for a future reconnect, but + // an oversized first source must not enter the bounded pending queue. + const queued = stage('.oversized{' + 'x'.repeat(32 * 1024 * 1024) + '}'); + return [queued, style.isConnected, style.sheet === null]; + })())JS", "oversized-stylesheet-staging.js"); + require(oversized_result == R"([false,false,true])", + "oversized stylesheet source entered pending replacement storage: " + + oversized_result); + require(webscene_engine_request_low_memory(engine) != 0, + "oversized stylesheet source low-memory request was rejected"); require(webscene_engine_set_runtime_work_metrics_enabled(engine, 1U) != 0, "stylesheet mutation runtime-work metrics could not be enabled"); webscene_engine_metrics engine_before{};