Fix HTML loading stalls on large modern pages

- batch asynchronous HTTP stylesheets and apply them in a single reload
  - skip unsupported block at-rules without leaking nested rules
  - prevent scripts nested in unknown custom elements from entering the render tree
  - add regression coverage for unsupported at-rules and nested scripts
  - document the Ninja release workflow for performance testing
This commit is contained in:
Martín Lucas Golini
2026-08-02 00:55:03 -03:00
parent fe8417bdc7
commit eb7398f9ec
6 changed files with 105 additions and 3 deletions

View File

@@ -1,4 +1,23 @@
# Build Instructions (Debug Mode) # Build Instructions
The build configurations in `.ecode/project_build.json` are the source of truth for the
developer's local build workflows. Check that file before selecting a generator, backend, or
build flags. In particular, do not use an AddressSanitizer build to evaluate runtime performance.
## Release Performance Builds (Linux)
For performance investigations, use the `eepp-linux-ninja` configuration from
`.ecode/project_build.json`. At the time of writing, its commands are:
`premake5 --disable-static-build --with-debug-symbols --with-backend=SDL3 ninja`
`ninja -C make/linux release`
This produces an optimized release build with debug symbols and without AddressSanitizer. Run the
release executable (for example, `bin/eepp-ui-html`) when measuring performance. Recheck
`.ecode/project_build.json` before use because the local configuration may change.
## Debug and Unit-Test Builds
All build commands must be executed from the **root project directory**. Follow these steps to build the project: All build commands must be executed from the **root project directory**. Follow these steps to build the project:

View File

@@ -958,6 +958,8 @@ class EE_API UISceneNode : public SceneNode {
bool mIsLoading{ false }; bool mIsLoading{ false };
bool mUpdatingLayouts{ false }; bool mUpdatingLayouts{ false };
bool mStyleDuringLoad{ false }; bool mStyleDuringLoad{ false };
Uint32 mPendingHTTPStyleSheetLoads{ 0 };
bool mHTTPStyleSheetChanged{ false };
UIThemeManager* mUIThemeManager{ nullptr }; UIThemeManager* mUIThemeManager{ nullptr };
UIIconThemeManager* mUIIconThemeManager{ nullptr }; UIIconThemeManager* mUIIconThemeManager{ nullptr };
std::vector<Graphics::FontPtr> mFontFaces; std::vector<Graphics::FontPtr> mFontFaces;
@@ -1183,6 +1185,11 @@ class EE_API UISceneNode : public SceneNode {
*/ */
void loadCSS( URI uri, std::optional<Time> defer, CSS::StyleSheet::SourceOrder sourceOrder ); void loadCSS( URI uri, std::optional<Time> defer, CSS::StyleSheet::SourceOrder sourceOrder );
void combineHTTPStyleSheet( const std::string& css, const std::string& url, URI baseURI,
CSS::StyleSheet::SourceOrder sourceOrder );
void finishHTTPStyleSheetLoad();
/** /**
* @brief Loads glyph icons from @glyph-icon rules. * @brief Loads glyph icons from @glyph-icon rules.
* *

View File

@@ -135,6 +135,17 @@ bool StyleSheetParser::parse( std::string& css, std::vector<std::string>& import
} else if ( String::startsWith( trimBuf, "@keyframes" ) || } else if ( String::startsWith( trimBuf, "@keyframes" ) ||
String::startsWith( trimBuf, "@-webkit-keyframes" ) ) { String::startsWith( trimBuf, "@-webkit-keyframes" ) ) {
keyframesParse( css, rs, pos, buffer ); keyframesParse( css, rs, pos, buffer );
} else if ( !String::startsWith( trimBuf, "@font-face" ) &&
!String::startsWith( trimBuf, "@glyph-icon" ) ) {
// Unsupported block at-rules must not leak their nested contents into the
// top-level rule stream. We cannot preserve their conditional or cascade
// semantics, so skip the complete balanced block.
std::size_t closePos = String::findCloseBracket( css, pos - 1, '{', '}' );
if ( closePos != std::string::npos ) {
rs = ReadingSelector;
pos = closePos + 1;
buffer.clear();
}
} }
} }

View File

@@ -797,6 +797,8 @@ UISceneNode* UISceneNode::setWebResourceCache( WebResourceCachePtr cache,
} }
Uint64 UISceneNode::beginDocumentNavigation( const URI& uri ) { Uint64 UISceneNode::beginDocumentNavigation( const URI& uri ) {
mPendingHTTPStyleSheetLoads = 0;
mHTTPStyleSheetChanged = false;
return mWebResourceCache && mDocumentSessionId return mWebResourceCache && mDocumentSessionId
? mWebResourceCache->beginNavigation( mDocumentSessionId, uri ) ? mWebResourceCache->beginNavigation( mDocumentSessionId, uri )
: 0; : 0;
@@ -1916,6 +1918,7 @@ void UISceneNode::loadCSS( URI uri, std::optional<Time> defer,
} }
} }
} else if ( "http" == uri.getScheme() || "https" == uri.getScheme() ) { } else if ( "http" == uri.getScheme() || "https" == uri.getScheme() ) {
mPendingHTTPStyleSheetLoads++;
auto resourceState = mAsyncResourceLoadState; auto resourceState = mAsyncResourceLoadState;
Uint64 resourceGeneration = Uint64 resourceGeneration =
resourceState ? resourceState->generation.load( std::memory_order_acquire ) : 0; resourceState ? resourceState->generation.load( std::memory_order_acquire ) : 0;
@@ -1935,12 +1938,14 @@ void UISceneNode::loadCSS( URI uri, std::optional<Time> defer,
resourceState, resourceGeneration, resourceState, resourceGeneration,
[css = std::move( css ), url, baseURL, [css = std::move( css ), url, baseURL,
sourceOrder]( UISceneNode* scene ) mutable { sourceOrder]( UISceneNode* scene ) mutable {
scene->combineStyleSheet( css, true, String::hash( url ), baseURL, scene->combineHTTPStyleSheet( css, url, baseURL, sourceOrder );
sourceOrder );
Log::debug( "UISceneNode::loadCSS: Loaded - %s", url ); Log::debug( "UISceneNode::loadCSS: Loaded - %s", url );
} ); } );
} else { } else {
Log::debug( "UISceneNode::loadCSS: Failed to load %s - %s", url, result.error ); Log::debug( "UISceneNode::loadCSS: Failed to load %s - %s", url, result.error );
UISceneNode::runAsyncResourceOnMainThread(
resourceState, resourceGeneration,
[]( UISceneNode* scene ) { scene->finishHTTPStyleSheetLoad(); } );
} }
} ); } );
} else if ( VFS::instance()->fileExists( uri.getPath() ) ) { } else if ( VFS::instance()->fileExists( uri.getPath() ) ) {
@@ -1956,6 +1961,30 @@ void UISceneNode::loadCSS( URI uri, std::optional<Time> defer,
} }
} }
void UISceneNode::combineHTTPStyleSheet( const std::string& css, const std::string& url,
URI baseURI, CSS::StyleSheet::SourceOrder sourceOrder ) {
CSS::StyleSheetParser parser;
parser.setBaseURI( baseURI );
if ( parser.loadFromString( css ) ) {
parser.getStyleSheet().setMarker( String::hash( url ) );
resolveStyleSheetRelativeURLs( parser.getStyleSheet(), baseURI.empty() ? mURI : baseURI );
mStyleSheet.combineStyleSheet( parser.getStyleSheet(), sourceOrder );
processStyleSheetAtRules( parser.getStyleSheet(), baseURI );
mHTTPStyleSheetChanged = true;
}
finishHTTPStyleSheetLoad();
}
void UISceneNode::finishHTTPStyleSheetLoad() {
if ( mPendingHTTPStyleSheetLoads > 0 )
mPendingHTTPStyleSheetLoads--;
if ( mPendingHTTPStyleSheetLoads == 0 && mHTTPStyleSheetChanged ) {
mHTTPStyleSheetChanged = false;
updateStyleSheet( true );
refreshWebViewDocumentLayoutAfterStyleChange( mRoot );
}
}
void UISceneNode::setInternalPixelsSize( const Sizef& size ) { void UISceneNode::setInternalPixelsSize( const Sizef& size ) {
Sizef s( size ); Sizef s( size );
if ( s != mSize ) { if ( s != mSize ) {

View File

@@ -523,6 +523,12 @@ void UITextSpan::loadFromXmlNode( const pugi::xml_node& node ) {
if ( hasElements ) { if ( hasElements ) {
for ( pugi::xml_node child = node.first_child(); child; child = child.next_sibling() ) { for ( pugi::xml_node child = node.first_child(); child; child = child.next_sibling() ) {
if ( child.type() == pugi::node_element ) { if ( child.type() == pugi::node_element ) {
// Unknown HTML elements are represented by UITextSpan. Keep script elements and
// their potentially very large data payloads out of the render tree even when
// they are nested below one of those custom elements.
if ( String::iequals( child.name(), "script" ) )
continue;
UIWidget* widget = UIWidgetCreator::createFromName( child.name() ); UIWidget* widget = UIWidgetCreator::createFromName( child.name() );
if ( widget == nullptr ) if ( widget == nullptr )

View File

@@ -3003,6 +3003,36 @@ UTEST( UIHTML, MarkdownViewLoadsBodyChildrenIntoNativeTree ) {
Engine::destroySingleton(); Engine::destroySingleton();
} }
UTEST( UIHTML, ScriptNestedInUnknownElementIsNotRendered ) {
init_ui_test();
auto* sceneNode = SceneManager::instance()->getUISceneNode();
sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html(
<html><body><react-app id="app">
<script type="application/json">{"payload":"must-not-render"}</script>
<span id="visible">Visible</span>
</react-app></body></html>
)html" ) );
auto* app = sceneNode->getRoot()->find( "app" );
ASSERT_TRUE( app != nullptr );
EXPECT_TRUE( app->asType<UIWidget>()->findByTag( "script" ) == nullptr );
EXPECT_TRUE( app->find( "visible" ) != nullptr );
Engine::destroySingleton();
}
UTEST( UIHTML, UnsupportedBlockAtRulesDoNotLeakNestedRules ) {
StyleSheetParser parser;
ASSERT_TRUE( parser.loadFromString( std::string_view{
"@layer framework { .layered { color: red; } @supports (display: grid) { .nested { "
"display: grid; } } } .visible { color: blue; }" } ) );
EXPECT_TRUE( parser.getStyleSheet().findStyleFromSelectorName( ".layered" ).empty() );
EXPECT_TRUE( parser.getStyleSheet().findStyleFromSelectorName( ".nested" ).empty() );
EXPECT_EQ( 1u, parser.getStyleSheet().findStyleFromSelectorName( ".visible" ).size() );
}
UTEST( UIHTML, StyleSheetSiblingCombinators ) { UTEST( UIHTML, StyleSheetSiblingCombinators ) {
Engine::instance()->createWindow( WindowSettings( 1024, 768, "CSS Sibling Test", Engine::instance()->createWindow( WindowSettings( 1024, 768, "CSS Sibling Test",
WindowStyle::Default, WindowBackend::Default, WindowStyle::Default, WindowBackend::Default,