From 280bcb9e63264e69448955aa4c1fbc9121ef677e Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Fri, 28 Aug 2026 15:13:33 +0200 Subject: [PATCH] fix(element): withhold paint until a pixel translate actually settles The withheld-paint reveal was bounded to one extra layout pass, so a translate that folds into the position later still painted the node at its untransformed origin for a frame. Wait for isTranslateSettled, bounded by a pass count so an undetectable translate can't strand the node invisible. --- .../withhold-paint-until-translate-settles.md | 5 ++ .../src/element/LightningViewElement.spec.ts | 51 +++++++++++++++++++ .../src/element/LightningViewElement.ts | 42 ++++++++------- .../src/element/isTranslateSettled.ts | 8 +++ 4 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 .changeset/withhold-paint-until-translate-settles.md diff --git a/.changeset/withhold-paint-until-translate-settles.md b/.changeset/withhold-paint-until-translate-settles.md new file mode 100644 index 00000000..7b3d932f --- /dev/null +++ b/.changeset/withhold-paint-until-translate-settles.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning': patch +--- + +A withheld node no longer reveals at its untransformed origin when a pixel translate takes more than one layout pass to fold into the position. The reveal was bounded to a single extra layout, so anything deeper in the async flex tree painted at its base position for a frame — a drawer laid out on screen and translated off would flash open at mount. The reveal now waits for the translate to actually settle, bounded so a translate that can't be detected still can't strand the node invisible. diff --git a/packages/react-lightning/src/element/LightningViewElement.spec.ts b/packages/react-lightning/src/element/LightningViewElement.spec.ts index 830213ea..d0e407d4 100644 --- a/packages/react-lightning/src/element/LightningViewElement.spec.ts +++ b/packages/react-lightning/src/element/LightningViewElement.spec.ts @@ -3,6 +3,7 @@ import type { Fiber } from 'react-reconciler'; import { describe, expect, it } from 'vitest'; import type { LightningViewElementProps, LightningViewElementStyle } from '../types'; +import { MAX_UNSETTLED_LAYOUTS } from './isTranslateSettled'; import { LightningViewElement } from './LightningViewElement'; type ThreeChildren = [ @@ -49,6 +50,18 @@ function createElement(style: Partial) { return new LightningViewElement(props, renderer, [], {} as Fiber); } +// `transform` reaches the element from the css-transform plugin rather than the +// public style type, so the style has to be cast in. +function createTranslatedElement(translateX: number) { + return createElement({ + w: 100, + h: 50, + alpha: 1, + x: 0, + transform: { translateX }, + } as Partial); +} + // setProps stages the update and flushes on a microtask. const flush = () => Promise.resolve(); @@ -139,6 +152,44 @@ describe('LightningViewElement paint withholding', () => { // Released without a layout — still not laid out. expect(el.hasLayout).toBe(false); }); + + it('stays withheld while a pixel translate takes more than one layout to settle', () => { + const el = createTranslatedElement(-100); + + el.withholdPaintUntilLayout(); + + // Base position only — the translate hasn't been folded in yet. + el.emitLayoutEvent(); + expect(el.paintWithheld).toBe(true); + + // Still unsettled. Revealing here paints the node at x=0, not x=-100. + el.emitLayoutEvent(); + expect(el.paintWithheld).toBe(true); + expect(el.node.alpha).toBe(0); + + el.node.x = -100; + el.emitLayoutEvent(); + + expect(el.paintWithheld).toBe(false); + expect(el.node.alpha).toBe(1); + }); + + it('reveals a never-settling translate rather than stranding it invisible', () => { + // Left unsettled: node.x is never moved to base + delta. + const el = createTranslatedElement(-100); + + el.withholdPaintUntilLayout(); + + for (let i = 0; i < MAX_UNSETTLED_LAYOUTS; i++) { + el.emitLayoutEvent(); + expect(el.paintWithheld).toBe(true); + } + + el.emitLayoutEvent(); + + expect(el.paintWithheld).toBe(false); + expect(el.node.alpha).toBe(1); + }); }); describe('LightningViewElement border shader', () => { diff --git a/packages/react-lightning/src/element/LightningViewElement.ts b/packages/react-lightning/src/element/LightningViewElement.ts index eac0c950..d2bddbbf 100644 --- a/packages/react-lightning/src/element/LightningViewElement.ts +++ b/packages/react-lightning/src/element/LightningViewElement.ts @@ -36,7 +36,10 @@ import { } from '../types'; import { AllStyleProps } from './AllStyleProps'; import { createFlattenedNode } from './FlattenedRendererNode'; -import { isTranslateSettled } from './isTranslateSettled'; +import { + isTranslateSettled, + MAX_UNSETTLED_LAYOUTS, +} from './isTranslateSettled'; const __bannedProps: Record = {}; let __bannedPropsInitialized = false; @@ -145,6 +148,7 @@ export class LightningViewElement< /** Last requested animation target per style key, for the no-op-skip guard. */ private _animTargets = new Map(); private _paintWithheld = false; + private _unsettledLayouts = 0; private _reactHidden = false; private _withheldAlpha = 1; private _eventEmitter = new EventEmitter(); @@ -645,6 +649,7 @@ export class LightningViewElement< } this._paintWithheld = true; + this._unsettledLayouts = 0; this._withheldAlpha = node.alpha; node.alpha = 0; this.recalculateVisibility(); @@ -1649,25 +1654,28 @@ export class LightningViewElement< }; private _onLayout = (dimensions: Rect) => { - const hadLayout = this._hasLayout; this._hasLayout = true; // Reveal a withheld node at its now-correct geometry. A pixel translate - // transform is resolved off the base position and lands a layout pass later, - // so hold the reveal past the first (pre-transform) layout — otherwise the - // node paints at its untransformed origin for a frame. Bounded to that one - // extra layout so a mis-detected translate can never strand it invisible. - // See {@link withholdPaintUntilLayout}. - if ( - this._paintWithheld && - (hadLayout || - isTranslateSettled(this.props.style, this.node.x, this.node.y)) - ) { - this._paintWithheld = false; - - if (this.node.alpha !== this._withheldAlpha) { - this.node.alpha = this._withheldAlpha; - this.recalculateVisibility(); + // transform is resolved off the base position and lands a later layout + // pass, so hold the reveal until it has — otherwise the node paints at its + // untransformed origin for a frame. How many passes that takes depends on + // how deep the node sits in the async flex tree, hence a bound rather than + // a single extra pass. See {@link withholdPaintUntilLayout}. + if (this._paintWithheld) { + const settled = + this._unsettledLayouts >= MAX_UNSETTLED_LAYOUTS || + isTranslateSettled(this.props.style, this.node.x, this.node.y); + + if (settled) { + this._paintWithheld = false; + + if (this.node.alpha !== this._withheldAlpha) { + this.node.alpha = this._withheldAlpha; + this.recalculateVisibility(); + } + } else { + this._unsettledLayouts++; } } diff --git a/packages/react-lightning/src/element/isTranslateSettled.ts b/packages/react-lightning/src/element/isTranslateSettled.ts index c7aa9b51..9d50fd16 100644 --- a/packages/react-lightning/src/element/isTranslateSettled.ts +++ b/packages/react-lightning/src/element/isTranslateSettled.ts @@ -1,3 +1,11 @@ +/** + * How many unsettled layout passes a withheld node waits through before being + * revealed anyway. {@link isTranslateSettled} only detects the base + delta + * case, so a translate resolved some other way never settles — the bound stops + * that from stranding the node invisible. + */ +export const MAX_UNSETTLED_LAYOUTS = 3; + type TranslatableStyle = { x?: number; y?: number;