Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/withhold-paint-until-translate-settles.md
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 51 additions & 0 deletions packages/react-lightning/src/element/LightningViewElement.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -49,6 +50,18 @@ function createElement(style: Partial<LightningViewElementStyle>) {
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<LightningViewElementStyle>);
}

// setProps stages the update and flushes on a microtask.
const flush = () => Promise.resolve();

Expand Down Expand Up @@ -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', () => {
Expand Down
42 changes: 25 additions & 17 deletions packages/react-lightning/src/element/LightningViewElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean> = {};
let __bannedPropsInitialized = false;
Expand Down Expand Up @@ -145,6 +148,7 @@ export class LightningViewElement<
/** Last requested animation target per style key, for the no-op-skip guard. */
private _animTargets = new Map<PropertyKey, unknown>();
private _paintWithheld = false;
private _unsettledLayouts = 0;
private _reactHidden = false;
private _withheldAlpha = 1;
private _eventEmitter = new EventEmitter<LightningElementEvents>();
Expand Down Expand Up @@ -645,6 +649,7 @@ export class LightningViewElement<
}

this._paintWithheld = true;
this._unsettledLayouts = 0;
this._withheldAlpha = node.alpha;
node.alpha = 0;
this.recalculateVisibility();
Expand Down Expand Up @@ -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++;
}
}

Expand Down
8 changes: 8 additions & 0 deletions packages/react-lightning/src/element/isTranslateSettled.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading