diff --git a/.agent/plans/resource_shared_ownership_architecture.md b/.agent/plans/resource_shared_ownership_architecture.md index e8d1ce752..f263f34a2 100644 --- a/.agent/plans/resource_shared_ownership_architecture.md +++ b/.agent/plans/resource_shared_ownership_architecture.md @@ -753,8 +753,9 @@ Exit criteria: ### Stage 6: WebResourceCache and document leases Status: complete, 2026-07-21. Each UISceneNode now owns a WebResourceCache document session with -an explicit cache partition and navigation generation. UIWebView advances that session at the -existing navigation boundary. Document, stylesheet, remote font, image, and CSS background image +an explicit cache partition and navigation generation. UIWebView advances that session when the +replacement document is installed, after the previous document has been detached. Document, +stylesheet, remote font, image, and CSS background image requests share canonical fragment-free keys that include the partition, request method/body and headers, resource kind, and image decode options. diff --git a/src/eepp/ui/tools/uitextureviewer.cpp b/src/eepp/ui/tools/uitextureviewer.cpp index d81c4440a..540016255 100644 --- a/src/eepp/ui/tools/uitextureviewer.cpp +++ b/src/eepp/ui/tools/uitextureviewer.cpp @@ -6,6 +6,59 @@ namespace EE { namespace UI { namespace Tools { +namespace { + +class UIWeakTexturePreview : public UIImage { + public: + static UIWeakTexturePreview* New( TextureWeakPtr texture ) { + return eeNew( UIWeakTexturePreview, ( std::move( texture ) ) ); + } + + virtual void draw() { + UINode::draw(); + + if ( !mVisible || mAlpha == 0.f ) + return; + + TexturePtr texture = mTexture.lock(); + if ( !texture ) + return; + + const Sizef textureSize( texture->getPixelsSize() ); + if ( textureSize.x <= 0.f || textureSize.y <= 0.f ) + return; + + const Sizef availableSize( mSize.x - mPaddingPx.Left - mPaddingPx.Right, + mSize.y - mPaddingPx.Top - mPaddingPx.Bottom ); + Float scale = eemax( 0.f, eemin( 1.f, eemin( availableSize.x / textureSize.x, + availableSize.y / textureSize.y ) ) ); + Sizef destSize( ( textureSize * scale ).floor() ); + Vector2f position( std::trunc( mScreenPos.x ) + mPaddingPx.Left, + std::trunc( mScreenPos.y ) + mPaddingPx.Top ); + + if ( Font::getHorizontalAlign( mFlags ) == UI_HALIGN_CENTER ) + position.x += ( availableSize.x - destSize.x ) * 0.5f; + else if ( Font::getHorizontalAlign( mFlags ) == UI_HALIGN_RIGHT ) + position.x += availableSize.x - destSize.x; + if ( Font::getVerticalAlign( mFlags ) == UI_VALIGN_CENTER ) + position.y += ( availableSize.y - destSize.y ) * 0.5f; + else if ( Font::getVerticalAlign( mFlags ) == UI_VALIGN_BOTTOM ) + position.y += availableSize.y - destSize.y; + + const Color previousColor( texture->getColor() ); + texture->setColor( mColor ); + texture->draw( position, destSize ); + texture->setColor( previousColor ); + } + + protected: + explicit UIWeakTexturePreview( TextureWeakPtr texture ) : mTexture( std::move( texture ) ) {} + + TextureWeakPtr mTexture; +}; + +} // namespace + UITextureViewer* UITextureViewer::New() { return eeNew( UITextureViewer, () ); } @@ -107,11 +160,10 @@ void UITextureViewer::insertTexture( const TextureRegistryRecord& record ) { if ( !texture ) return; - UIImage* img = UIImage::New(); + UIImage* img = UIWeakTexturePreview::New( record.texture ); std::string uid( String::format( "texture-%llu", static_cast( record.id.value() ) ) ); - img->setDrawable( texture ) - ->setScaleType( UIScaleType::FitInside ) + img->setScaleType( UIScaleType::FitInside ) ->setClasses( { "texture-preview", uid } ) ->setTooltipText( getTextureDescription( texture.get() ) ) ->setGravity( UI_HALIGN_CENTER | UI_VALIGN_CENTER ) diff --git a/src/eepp/ui/uiwebview.cpp b/src/eepp/ui/uiwebview.cpp index a913ade60..248b4ae40 100644 --- a/src/eepp/ui/uiwebview.cpp +++ b/src/eepp/ui/uiwebview.cpp @@ -296,8 +296,6 @@ void UIWebView::loadURI( URI uri, const std::string& method, const std::string& void UIWebView::loadURI( URI uri, bool isHistoryNav, const std::string& method, const std::string& body, const Http::Request::FieldTable& headers ) { Uint64 generation = beginNavigationLoad(); - if ( mDocumentScene ) - mDocumentScene->beginDocumentNavigation( uri ); mIsLoading = true; if ( !isHistoryNav ) @@ -415,6 +413,10 @@ void UIWebView::loadDocumentData( URI url, std::string data, Uint64 generation ) self->getHorizontalScrollBar()->setValue( 0 ); static_cast( self->mDocContainer )->clearDocumentChildren(); ui->invalidateAsyncResourceLoads(); + // The previous document remains active while its replacement is downloading. Advance the + // cache generation only now, after that document has been detached, so late updates from + // the visible document cannot lease resources as if they belonged to its replacement. + ui->beginDocumentNavigation( url ); ui->clearFontFaces(); ui->getStyleSheet().removeAllWithoutMarker( self->mStyleSheetDefaultMarker ); ui->setURIFromURL( url ); diff --git a/src/tests/unit_tests/uiwebview_tests.cpp b/src/tests/unit_tests/uiwebview_tests.cpp index b1f93470f..6373e45a5 100644 --- a/src/tests/unit_tests/uiwebview_tests.cpp +++ b/src/tests/unit_tests/uiwebview_tests.cpp @@ -3107,6 +3107,81 @@ UTEST( UIWebView, NewerNavigationSupersedesStartedLoad ) { Engine::destroySingleton(); } +UTEST( UIWebView, CacheGenerationAdvancesWhenReplacementDocumentIsInstalled ) { + auto win = Engine::instance()->createWindow( + WindowSettings( 800, 600, "UIWebView Cache Lease Boundary Test", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, true ), + ContextSettings( false, 0, 0, GLv_default, true, false ) ); + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + + FontTrueType* font = FontTrueType::New( "NotoSans-Regular" ); + font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" ); + ASSERT_TRUE( font != nullptr && font->loaded() ); + FontFamily::loadFromRegular( font ); + + UISceneNode* sceneNode = UISceneNode::New(); + SceneManager::instance()->add( sceneNode ); + sceneNode->getUIThemeManager()->setDefaultFont( font ); + + UIWebView* webView = UIWebView::New(); + webView->setParent( sceneNode->getRoot() ); + webView->setPixelsSize( 300, 200 ); + webView->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + + auto cache = WebResourceCache::New(); + cache->setTTL( Time::Zero ); + std::vector documentCompletions; + cache->setFetcher( [&documentCompletions]( const WebResourceRequest& request, + WebResourceCache::FetchCompletion completion ) { + if ( request.kind == WebResourceKind::Document ) { + documentCompletions.emplace_back( std::move( completion ) ); + } else { + Http::Response::FieldTable fields; + auto status = Http::Response::Status::Ok; + completion( Http::Response::createFakeResponse( fields, status, "resource" ) ); + } + } ); + webView->setWebResourceCache( cache ); + UISceneNode* documentScene = webView->getDocumentSceneNode(); + ASSERT_TRUE( documentScene != nullptr ); + + auto pump = [&]( int frames ) { + for ( int i = 0; i < frames; ++i ) { + win->getInput()->update(); + SceneManager::instance()->update( Seconds( 1.f / 60.f ) ); + } + }; + auto completeDocument = [&] { + ASSERT_FALSE( documentCompletions.empty() ); + auto completion = std::move( documentCompletions.front() ); + documentCompletions.erase( documentCompletions.begin() ); + Http::Response::FieldTable fields; + auto status = Http::Response::Status::Ok; + completion( + Http::Response::createFakeResponse( fields, status, "" ) ); + pump( 10 ); + }; + + webView->loadURI( URI( "https://first.example/" ) ); + EXPECT_EQ( 0u, documentScene->getDocumentGeneration() ); + completeDocument(); + EXPECT_EQ( 1u, documentScene->getDocumentGeneration() ); + + webView->loadURI( URI( "https://second.example/" ) ); + EXPECT_EQ( 1u, documentScene->getDocumentGeneration() ); + WebResourceRequest oldDocumentResource; + oldDocumentResource.uri = URI( "https://first.example/late.css" ); + oldDocumentResource.kind = WebResourceKind::StyleSheet; + documentScene->requestWebResource( std::move( oldDocumentResource ), {} ); + completeDocument(); + EXPECT_EQ( 2u, documentScene->getDocumentGeneration() ); + + pump( 61 ); + EXPECT_EQ( 0u, cache->getEntryCount() ); + + Engine::destroySingleton(); +} + UTEST( UIWebView, RepeatedRemoteNavigationHandlesSubresourceFanOut ) { constexpr int NavigationCount = 6; constexpr int ImagesPerDocument = 100;