From 74e6c35e40d3716fee90708da0cc4152ea9b74aa Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 27 Sep 2026 22:11:32 +0300 Subject: [PATCH] perf(motion): skip re-layout of hidden QueryaSwitchingBody layers (#984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RenderOffstage still calls child.layout(constraints) every frame while offstage, so a hidden layer (e.g. the empty-state widget behind an active SQL workspace) gets fully re-laid-out whenever a sibling's size change feeds it new constraints — as happens throughout the sidebar-toggle animation, where the workspace's Expanded width changes every frame. Added LazyOffstage: a drop-in replacement for Offstage that reuses the hidden child's last known size instead of re-laying it out, and forces a real layout (against then-current constraints) the moment it's shown again — so a hidden layer's stale width can never leak into what's displayed. Wired into QueryaSwitchingBody's _SwitchingLayer, which is what backs every empty<->connected and idle/loading/error/grid morph in the app (workspace panel, Redis/Mongo explorers). Verified correct with a targeted test that changes a sibling's width while a LazyOffstage child is hidden (asserting the child's layout stays frozen), then reveals it (asserting it now reflects the *current* constraints, not a stale one) — confirmed to fail without the fix by temporarily disabling the skip-layout branch. Benchmark caveat, reported honestly: benchmark/app_perf_bench.dart's sidebar_toggle scenario (single SQL workspace tab open, one simple hidden empty-state layer) shows no measurable difference (build p50 3.5ms before and after) — the dominant per-frame cost in that scenario is the *visible* tree's own LayoutBuilder rebuilds reacting to the changing width (the issue's other, still-open scope item), not the hidden layer's layout, which this fix addresses. The benefit should be more visible with heavier or multiple hidden layers (e.g. several open SQL tabs), which this benchmark scenario doesn't construct. Leaving #984 open rather than overclaiming. --- lib/core/motion/lazy_offstage.dart | 74 +++++++++++++++++++ lib/core/motion/querya_switching_body.dart | 6 +- test/core/motion/lazy_offstage_test.dart | 69 +++++++++++++++++ .../motion/querya_switching_body_test.dart | 12 +-- 4 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 lib/core/motion/lazy_offstage.dart create mode 100644 test/core/motion/lazy_offstage_test.dart diff --git a/lib/core/motion/lazy_offstage.dart b/lib/core/motion/lazy_offstage.dart new file mode 100644 index 0000000..de38051 --- /dev/null +++ b/lib/core/motion/lazy_offstage.dart @@ -0,0 +1,74 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Like [Offstage], but skips laying out the hidden child again while it +/// stays hidden, even if the incoming constraints keep changing (#984). +/// +/// [RenderOffstage] still calls `child.layout(constraints)` on every frame +/// while offstage, so an animation that changes a sibling's size (e.g. the +/// sidebar-toggle spring resizing the workspace's [Expanded]) forces a full +/// layout pass through every hidden [QueryaSwitchingBody] layer each frame, +/// even though none of that layout is ever painted. [LazyOffstage] instead +/// reuses the child's last known size while hidden, and lays it out for real +/// exactly once when it becomes visible again (with the then-current +/// constraints, which may have changed while it was hidden). +class LazyOffstage extends SingleChildRenderObjectWidget { + const LazyOffstage({super.key, this.offstage = true, super.child}); + + final bool offstage; + + @override + RenderLazyOffstage createRenderObject(BuildContext context) => + RenderLazyOffstage(offstage: offstage); + + @override + void updateRenderObject( + BuildContext context, + RenderLazyOffstage renderObject, + ) { + renderObject.offstage = offstage; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('offstage', offstage)); + } + + @override + SingleChildRenderObjectElement createElement() => _LazyOffstageElement(this); +} + +class _LazyOffstageElement extends SingleChildRenderObjectElement { + _LazyOffstageElement(LazyOffstage super.widget); + + @override + void debugVisitOnstageChildren(ElementVisitor visitor) { + if (!(widget as LazyOffstage).offstage) { + super.debugVisitOnstageChildren(visitor); + } + } +} + +class RenderLazyOffstage extends RenderOffstage { + RenderLazyOffstage({super.offstage, super.child}); + + @override + void performLayout() { + if (offstage) { + final child = this.child; + // A child that has already been laid out at least once keeps its + // last known size while hidden, instead of being re-laid-out with + // whatever constraints happen to arrive this frame. `offstage`'s own + // setter (in RenderOffstage) already forces a real layout the moment + // this flips back to false, so the child is never shown with a stale + // size for constraints that changed while it was hidden. + if (child != null && child.hasSize) { + return; + } + child?.layout(constraints); + return; + } + super.performLayout(); + } +} diff --git a/lib/core/motion/querya_switching_body.dart b/lib/core/motion/querya_switching_body.dart index 1ede498..b3b0cc4 100644 --- a/lib/core/motion/querya_switching_body.dart +++ b/lib/core/motion/querya_switching_body.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'lazy_offstage.dart'; import 'querya_motion.dart'; import 'querya_motion_context.dart'; @@ -108,10 +109,13 @@ class _SwitchingLayerState extends State<_SwitchingLayer> { // Isolate paint; pause child tickers when inactive (opacity anim still runs). // Inactive child is offstaged once the exit transition completes to avoid // redundant layout passes during desktop window resizing (#901). + // LazyOffstage (not Offstage) additionally skips re-laying the hidden + // child out on every frame a sibling's size changes, e.g. the sidebar + // toggle spring resizing the workspace's Expanded (#984). final content = TickerMode( enabled: widget.active, child: RepaintBoundary( - child: Offstage( + child: LazyOffstage( offstage: _offstage, child: widget.child, ), diff --git a/test/core/motion/lazy_offstage_test.dart b/test/core/motion/lazy_offstage_test.dart new file mode 100644 index 0000000..3c00f89 --- /dev/null +++ b/test/core/motion/lazy_offstage_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/lazy_offstage.dart'; + +/// #984: while [LazyOffstage.offstage] is true, the hidden child must keep +/// its last known size even if a sibling's size change (e.g. the sidebar +/// toggle animation resizing the workspace next to it) feeds it new +/// constraints every frame. Once it's made visible again, it must be laid +/// out for real against whatever the constraints are *at that point*, +/// never a stale value from while it was hidden. +void main() { + Future capturedWidth( + WidgetTester tester, { + required double siblingWidth, + required bool hidden, + }) async { + late double captured; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 400, + height: 50, + child: Row( + children: [ + SizedBox(width: siblingWidth), + Expanded( + child: LazyOffstage( + offstage: hidden, + child: LayoutBuilder( + builder: (context, constraints) { + captured = constraints.maxWidth; + return const SizedBox.expand(); + }, + ), + ), + ), + ], + ), + ), + ), + ), + ); + return captured; + } + + testWidgets( + 'keeps the stale width while hidden despite constraint changes, ' + 'then relays out with the fresh width once shown again', + (tester) async { + // Initial layout (never laid out before): 400 - 100 = 300. + final initial = + await capturedWidth(tester, siblingWidth: 100, hidden: true); + expect(initial, 300); + + // Sibling grows while still hidden: a plain Offstage would forward this + // straight through (captured -> 150); LazyOffstage must not. + final whileHidden = + await capturedWidth(tester, siblingWidth: 250, hidden: true); + expect(whileHidden, 300); + + // Made visible again: must now reflect the current constraints (150), + // not the stale 300 from while it was hidden. + final whenShown = + await capturedWidth(tester, siblingWidth: 250, hidden: false); + expect(whenShown, 150); + }); +} diff --git a/test/core/motion/querya_switching_body_test.dart b/test/core/motion/querya_switching_body_test.dart index feccd4c..13097a6 100644 --- a/test/core/motion/querya_switching_body_test.dart +++ b/test/core/motion/querya_switching_body_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/lazy_offstage.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; import 'package:querya_desktop/core/motion/querya_switching_body.dart'; @@ -368,7 +369,7 @@ void main() { }); testWidgets( - 'inactive layer configures Offstage(offstage: true) once exit transition completes', + 'inactive layer configures LazyOffstage(offstage: true) once exit transition completes', (tester) async { await tester.pumpWidget( wrap( @@ -385,10 +386,11 @@ void main() { final offstageFinder = find.descendant( of: find.byType(QueryaSwitchingBody), - matching: find.byType(Offstage, skipOffstage: false), + matching: find.byType(LazyOffstage, skipOffstage: false), ); - final offstagesInitial = tester.widgetList(offstageFinder).toList(); + final offstagesInitial = + tester.widgetList(offstageFinder).toList(); expect(offstagesInitial.length, 2); // Active child A is onstage expect(offstagesInitial[0].offstage, isFalse); @@ -411,14 +413,14 @@ void main() { // While animating, both are onstage await tester.pump(const Duration(milliseconds: 50)); final offstagesAnimating = - tester.widgetList(offstageFinder).toList(); + tester.widgetList(offstageFinder).toList(); expect(offstagesAnimating[0].offstage, isFalse); expect(offstagesAnimating[1].offstage, isFalse); // Once settled, child A is offstaged, child B is onstage await tester.pumpAndSettle(); final offstagesSettled = - tester.widgetList(offstageFinder).toList(); + tester.widgetList(offstageFinder).toList(); expect(offstagesSettled[0].offstage, isTrue); expect(offstagesSettled[1].offstage, isFalse); });