diff --git a/bridge/core/dom/element.cc b/bridge/core/dom/element.cc index fef40f2c4e..57380e8e5b 100644 --- a/bridge/core/dom/element.cc +++ b/bridge/core/dom/element.cc @@ -1363,6 +1363,14 @@ void Element::AttributeChanged(const AttributeModificationParams& params) { } const AtomicString& name = params.name; + const bool attribute_value_changed = params.old_value != params.new_value; + // Many frameworks (e.g. React) re-apply attributes even when their value + // is unchanged. Avoid forcing expensive style invalidation work when there + // is no effective attribute change. + if (!attribute_value_changed) { + return; + } + if (name == CheckedAttrName()) { // HTML boolean attributes are true by presence, even when the value is empty. checked_state_ = !params.new_value.IsNull(); diff --git a/integration_tests/snapshots/css/css-flexbox/flex-shorthand-calc.ts.569527c71.png b/integration_tests/snapshots/css/css-flexbox/flex-shorthand-calc.ts.569527c71.png new file mode 100644 index 0000000000..adc3713162 Binary files /dev/null and b/integration_tests/snapshots/css/css-flexbox/flex-shorthand-calc.ts.569527c71.png differ diff --git a/integration_tests/snapshots/css/css-flexbox/flex-shorthand-calc.ts.72d5760d1.png b/integration_tests/snapshots/css/css-flexbox/flex-shorthand-calc.ts.72d5760d1.png new file mode 100644 index 0000000000..8c4819c795 Binary files /dev/null and b/integration_tests/snapshots/css/css-flexbox/flex-shorthand-calc.ts.72d5760d1.png differ diff --git a/integration_tests/snapshots/css/css-flexbox/flex-shorthand-calc.ts.e52e92a31.png b/integration_tests/snapshots/css/css-flexbox/flex-shorthand-calc.ts.e52e92a31.png new file mode 100644 index 0000000000..8c4819c795 Binary files /dev/null and b/integration_tests/snapshots/css/css-flexbox/flex-shorthand-calc.ts.e52e92a31.png differ diff --git a/integration_tests/snapshots/css/css-flexbox/flex-shorthand-calc.ts.f95ec0611.png b/integration_tests/snapshots/css/css-flexbox/flex-shorthand-calc.ts.f95ec0611.png new file mode 100644 index 0000000000..25c90e2700 Binary files /dev/null and b/integration_tests/snapshots/css/css-flexbox/flex-shorthand-calc.ts.f95ec0611.png differ diff --git a/integration_tests/specs/css/css-flexbox/flex-shorthand-calc.ts b/integration_tests/specs/css/css-flexbox/flex-shorthand-calc.ts new file mode 100644 index 0000000000..6313b45459 --- /dev/null +++ b/integration_tests/specs/css/css-flexbox/flex-shorthand-calc.ts @@ -0,0 +1,158 @@ +describe('flex shorthand with calc()', () => { + it('should accept calc() as flex-basis in flex shorthand', async () => { + let flexbox; + let item1; + let item2; + flexbox = createElement( + 'div', + { + style: { + display: 'flex', + width: '300px', + height: '100px', + 'background-color': 'red', + 'box-sizing': 'border-box', + }, + }, + [ + (item1 = createElement('div', { + style: { + flex: '0 1 calc(100% - 100px)', + height: '100px', + 'background-color': 'green', + 'box-sizing': 'border-box', + }, + })), + (item2 = createElement('div', { + style: { + width: '100px', + height: '100px', + 'background-color': 'blue', + 'box-sizing': 'border-box', + }, + })), + ] + ); + BODY.appendChild(flexbox); + + await snapshot(); + }); + + it('should accept calc() with px values as flex-basis', async () => { + let flexbox; + let item1; + let item2; + flexbox = createElement( + 'div', + { + style: { + display: 'flex', + width: '300px', + height: '100px', + 'background-color': 'red', + 'box-sizing': 'border-box', + }, + }, + [ + (item1 = createElement('div', { + style: { + flex: '0 0 calc(150px + 50px)', + height: '100px', + 'background-color': 'green', + 'box-sizing': 'border-box', + }, + })), + (item2 = createElement('div', { + style: { + flex: '0 0 calc(150px - 50px)', + height: '100px', + 'background-color': 'blue', + 'box-sizing': 'border-box', + }, + })), + ] + ); + BODY.appendChild(flexbox); + + await snapshot(); + }); + + it('should accept calc() with grow and shrink in flex shorthand', async () => { + let flexbox; + let item1; + let item2; + flexbox = createElement( + 'div', + { + style: { + display: 'flex', + width: '300px', + height: '100px', + 'background-color': 'red', + 'box-sizing': 'border-box', + }, + }, + [ + (item1 = createElement('div', { + style: { + flex: '1 1 calc(50% - 10px)', + height: '100px', + 'background-color': 'green', + 'box-sizing': 'border-box', + }, + })), + (item2 = createElement('div', { + style: { + flex: '1 1 calc(50% - 10px)', + height: '100px', + 'background-color': 'blue', + 'box-sizing': 'border-box', + }, + })), + ] + ); + BODY.appendChild(flexbox); + + await snapshot(); + }); + + it('should layout correctly with flex calc basis in column direction', async () => { + let flexbox; + let item1; + let item2; + flexbox = createElement( + 'div', + { + style: { + display: 'flex', + 'flex-direction': 'column', + width: '100px', + height: '300px', + 'background-color': 'red', + 'box-sizing': 'border-box', + }, + }, + [ + (item1 = createElement('div', { + style: { + flex: '0 0 calc(100% - 100px)', + width: '100px', + 'background-color': 'green', + 'box-sizing': 'border-box', + }, + })), + (item2 = createElement('div', { + style: { + width: '100px', + height: '100px', + 'background-color': 'blue', + 'box-sizing': 'border-box', + }, + })), + ] + ); + BODY.appendChild(flexbox); + + await snapshot(); + }); +}); diff --git a/webf/lib/src/css/render_style.dart b/webf/lib/src/css/render_style.dart index 7e1f32c6f8..337bd8ca8f 100644 --- a/webf/lib/src/css/render_style.dart +++ b/webf/lib/src/css/render_style.dart @@ -42,6 +42,10 @@ class UpdateTransformReason extends AdapterUpdateReason {} class UpdateChildNodeUpdateReason extends AdapterUpdateReason {} +// Used by Blink style-sync to reveal elements that were temporarily hidden to +// avoid 1-frame unstyled flickers after DOM insertion. +class BlinkFirstPaintReadyReason extends AdapterUpdateReason {} + class UpdateRenderReplacedUpdateReason extends AdapterUpdateReason {} class ToRepaintBoundaryUpdateReason extends AdapterUpdateReason {} diff --git a/webf/lib/src/css/style_property.dart b/webf/lib/src/css/style_property.dart index d103158f0a..0aaef433d7 100644 --- a/webf/lib/src/css/style_property.dart +++ b/webf/lib/src/css/style_property.dart @@ -1086,12 +1086,15 @@ class CSSStyleProperty { } final bool isValueVariableFunction = CSSFunction.isFunction(value, functionName: VAR); + final bool isValueCalcFunction = CSSFunction.isFunction(value, functionName: CALC); if (grow == null && (isValueVariableFunction || CSSNumber.isNumber(value))) { grow = value; } else if (shrink == null && (isValueVariableFunction || CSSNumber.isNumber(value))) { shrink = value; } else if (basis == null && ((isValueVariableFunction || + // Accept function notations like `calc(...)` and `var(...)`. + isValueCalcFunction || CSSLength.isNonNegativeLength(value) || CSSPercentage.isPercentage(value) || value == AUTO))) { diff --git a/webf/lib/src/dom/element.dart b/webf/lib/src/dom/element.dart index de9a1ee7d9..6cdb9b31d1 100644 --- a/webf/lib/src/dom/element.dart +++ b/webf/lib/src/dom/element.dart @@ -96,6 +96,49 @@ abstract class Element extends ContainerNode // Default to unknown, assign by [createElement], used by inspector. String tagName = unknown; + // --------------------------------------------------------------------------- + // Blink style-sync first-paint gate + // + // When Blink is enabled, DOM insertion UICommands can arrive one frame earlier + // than the style-sync UICommands for the inserted element, causing a brief + // unstyled flash. We gate the first paint for newly inserted elements by + // hiding them from widget building until their first Blink style-sync starts. + bool _blinkDeferFirstPaint = false; + bool _blinkHasSeenStyleSync = false; + + bool get blinkDeferFirstPaint => _blinkDeferFirstPaint; + bool get blinkHasSeenStyleSync => _blinkHasSeenStyleSync; + + void markBlinkDeferFirstPaint() { + if (_blinkHasSeenStyleSync) return; + if (_blinkDeferFirstPaint) return; + _blinkDeferFirstPaint = true; + } + + void notifyBlinkStyleSyncStarted() { + final bool wasDeferred = _blinkDeferFirstPaint; + _blinkHasSeenStyleSync = true; + if (!wasDeferred) return; + + _blinkDeferFirstPaint = false; + + // If we were previously hidden, there may be no paired render objects yet + // (because the adapter built a SizedBox.shrink). Force the adapter state to + // rebuild so the element can create its render tree in the next frame. + final reason = BlinkFirstPaintReadyReason(); + if (renderStyle.hasRenderBox()) { + renderStyle.requestWidgetToRebuild(reason); + } else { + forEachState((state) { + if (state is WebFElementWidgetState) { + state.requestForChildNodeUpdate(reason); + } else if (state is WebFReplacedElementWidgetState) { + state.requestForChildNodeUpdate(reason); + } + }); + } + } + final Set _intersectionObserverList = {}; List _thresholds = [0.0]; @@ -111,7 +154,7 @@ abstract class Element extends ContainerNode // Maintain attribute presence index for [id] selectors. // Treat any non-null assignment (including empty string) as presence=true. _updateAttrPresenceIndex(_idAttr, present: id != null); - if (DebugFlags.enableCssBatchRecalc) { + if (_shouldBatchRecalculateStyle) { ownerDocument.markElementStyleDirty(this, reason: 'batch:id'); if (oldId != id) { _markHasSelectorsDirty(); @@ -138,6 +181,9 @@ abstract class Element extends ContainerNode // The attrs. final Map attributes = {}; + bool get _shouldBatchRecalculateStyle => + DebugFlags.enableCssBatchRecalc && !ownerDocument.ownerView.enableBlink; + /// The style of the element, not inline style. late CSSStyleDeclaration style; @@ -203,7 +249,7 @@ abstract class Element extends ContainerNode _updateClassIndex(oldClasses, _classList); // Maintain attribute presence index for [class] selectors. _updateAttrPresenceIndex(_classNameAttr, present: true); - if (DebugFlags.enableCssBatchRecalc) { + if (_shouldBatchRecalculateStyle) { ownerDocument.markElementStyleDirty(this, reason: 'batch:class'); if (classChanged) { _markHasSelectorsDirty(); @@ -1495,7 +1541,7 @@ abstract class Element extends ContainerNode } } else { final isNeedRecalculate = _checkRecalculateStyle([qualifiedName]); - if (DebugFlags.enableCssBatchRecalc) { + if (_shouldBatchRecalculateStyle) { ownerDocument.markElementStyleDirty(this, reason: 'batch:attr:$qualifiedName'); } else { @@ -1539,7 +1585,7 @@ abstract class Element extends ContainerNode if (hasAttribute(qualifiedName)) { attributes.remove(qualifiedName); final isNeedRecalculate = _checkRecalculateStyle([qualifiedName]); - if (DebugFlags.enableCssBatchRecalc) { + if (_shouldBatchRecalculateStyle) { ownerDocument.markElementStyleDirty(this, reason: 'batch:remove:$qualifiedName'); } else { diff --git a/webf/lib/src/dom/element_widget_adapter.dart b/webf/lib/src/dom/element_widget_adapter.dart index 7ce7736876..ce34a757e9 100644 --- a/webf/lib/src/dom/element_widget_adapter.dart +++ b/webf/lib/src/dom/element_widget_adapter.dart @@ -221,7 +221,9 @@ class WebFElementWidgetState extends flutter.State with flutt WebFState? webFState; WebFRouterViewState? routerViewState; - if (this is WidgetElement || webFElement.renderStyle.display == CSSDisplay.none) { + if (webFElement.blinkDeferFirstPaint || + this is WidgetElement || + webFElement.renderStyle.display == CSSDisplay.none) { return flutter.SizedBox.shrink(); } @@ -316,7 +318,7 @@ class WebFElementWidgetState extends flutter.State with flutt if (overflowX == CSSOverflowType.scroll || overflowX == CSSOverflowType.auto || overflowX == CSSOverflowType.hidden) { - controllerX = _scrollControllerX ??= flutter.ScrollController(); + controllerX = _scrollControllerX ??= WebFScrollController(); final bool xScrollable = overflowX != CSSOverflowType.hidden; final bool isRTL = webFElement.renderStyle.direction == TextDirection.rtl; scrollableX = LayoutBoxWrapper( @@ -350,7 +352,7 @@ class WebFElementWidgetState extends flutter.State with flutt if (overflowY == CSSOverflowType.scroll || overflowY == CSSOverflowType.auto || overflowY == CSSOverflowType.hidden) { - final controllerY = _scrollControllerY ??= flutter.ScrollController(); + final controllerY = _scrollControllerY ??= WebFScrollController(); final bool yScrollable = overflowY != CSSOverflowType.hidden; final bool xScrollable = overflowX != CSSOverflowType.hidden; widget = LayoutBoxWrapper( @@ -369,7 +371,7 @@ class WebFElementWidgetState extends flutter.State with flutt if (scrollableX != null) { final bool isRTL = webFElement.renderStyle.direction == TextDirection.rtl; final controllerXForNested = - controllerX ?? (_scrollControllerX ??= flutter.ScrollController()); + controllerX ?? (_scrollControllerX ??= WebFScrollController()); return NestedScrollCoordinator( axis: flutter.Axis.horizontal, controller: controllerXForNested, @@ -803,7 +805,7 @@ class WebFReplacedElementWidgetState extends flutter.State{}; + // Reveal elements that were temporarily hidden to avoid 1-frame unstyled + // flickers after DOM insertion (Blink mode). + if (!hasBindingObject(selfPtr)) return; + final Node? target = getBindingObject(selfPtr); + if (target is Element) { + target.notifyBlinkStyleSyncStarted(); + } } void recordBlinkStyleSyncProperty(Pointer selfPtr, String property) { diff --git a/webf/lib/src/rendering/overflow.dart b/webf/lib/src/rendering/overflow.dart index 429a8dcede..45efc5939f 100644 --- a/webf/lib/src/rendering/overflow.dart +++ b/webf/lib/src/rendering/overflow.dart @@ -191,13 +191,7 @@ mixin RenderOverflowMixin on RenderBoxModelBase { _viewportSize = viewportSize; if (_scrollOffsetX != null) { _setUpScrollX(); - // Do not auto-jump scroll position for RTL containers. - // Per CSS/UA expectations, initial scroll position is the start edge - // of the scroll range, and user agent should not forcibly move it to - // the visual right edge for RTL. Keeping 0 preserves expected behavior - // for cases where overflow content lies entirely to the right. } - if (_scrollOffsetY != null) { _setUpScrollY(); } diff --git a/webf/lib/src/widget/webf_scroll_controller.dart b/webf/lib/src/widget/webf_scroll_controller.dart new file mode 100644 index 0000000000..2343b89f98 --- /dev/null +++ b/webf/lib/src/widget/webf_scroll_controller.dart @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2024-present The OpenWebF Company. All rights reserved. + * Licensed under GNU GPL with Enterprise exception. + */ + +import 'package:flutter/widgets.dart'; + +/// A [ScrollController] used by WebF overflow scroll containers. +/// +/// This controller creates a custom [ScrollPosition] that avoids triggering a +/// ballistic activity during the layout phase when scroll metrics change but +/// the position is already in range. This prevents some third-party physics +/// implementations from calling setState during layout via +/// `createBallisticSimulation()`. +class WebFScrollController extends ScrollController { + WebFScrollController({ + super.initialScrollOffset, + super.keepScrollOffset, + super.debugLabel, + super.onAttach, + super.onDetach, + }); + + @override + ScrollPosition createScrollPosition( + ScrollPhysics physics, + ScrollContext context, + ScrollPosition? oldPosition, + ) { + return WebFScrollPositionWithSingleContext( + physics: physics, + context: context, + initialPixels: initialScrollOffset, + keepScrollOffset: keepScrollOffset, + oldPosition: oldPosition, + debugLabel: debugLabel, + ); + } +} + +class WebFScrollPositionWithSingleContext + extends ScrollPositionWithSingleContext { + WebFScrollPositionWithSingleContext({ + required super.physics, + required super.context, + double? initialPixels = 0.0, + super.keepScrollOffset, + super.oldPosition, + super.debugLabel, + }) : super(initialPixels: initialPixels); + + @override + void goIdle() { + beginActivity(_WebFIdleScrollActivity(this)); + } +} + +class _WebFIdleScrollActivity extends IdleScrollActivity { + _WebFIdleScrollActivity(super.delegate); + + @override + void applyNewDimensions() { + final ScrollActivityDelegate activityDelegate = delegate; + final ScrollMetrics? metrics = activityDelegate is ScrollMetrics + ? (activityDelegate as ScrollMetrics) + : null; + if (metrics != null && metrics.outOfRange) { + activityDelegate.goBallistic(0.0); + } + } +} diff --git a/webf/lib/src/widget/widget_element.dart b/webf/lib/src/widget/widget_element.dart index ad0ccb75f3..01a4ab10f2 100644 --- a/webf/lib/src/widget/widget_element.dart +++ b/webf/lib/src/widget/widget_element.dart @@ -343,7 +343,7 @@ class WebFWidgetElementAdapterState extends dom.WebFElementWidgetState { Widget build(BuildContext context) { super.build(context); - if (widgetElement.renderStyle.display == CSSDisplay.none) { + if (widgetElement.blinkDeferFirstPaint || widgetElement.renderStyle.display == CSSDisplay.none) { return SizedBox.shrink(); } diff --git a/webf/lib/widget.dart b/webf/lib/widget.dart index 933dc8ef21..61b45a6b07 100644 --- a/webf/lib/widget.dart +++ b/webf/lib/widget.dart @@ -17,3 +17,4 @@ export 'src/widget/router_view.dart'; export 'src/widget/contentful_widget_detector.dart'; export 'src/widget/nested_scroll_forwarder.dart'; export 'src/widget/ensure_visible.dart'; +export 'src/widget/webf_scroll_controller.dart';