Skip to content

Commit d70b215

Browse files
committed
perf(flexbox): seed the grow-only text width from the first layout so mount runs one pass
1 parent 48d72d6 commit d70b215

4 files changed

Lines changed: 239 additions & 20 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@plextv/react-lightning-plugin-flexbox': patch
3+
---
4+
5+
lay out a new text node once on mount instead of re-measuring it in a second pass
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"home": { "nodeCount": 875, "layoutPasses": 50, "settles": 25, "maxPassesToSettle": 2, "reflows": 0, "textRemeasures": 425 },
3-
"details": { "nodeCount": 670, "layoutPasses": 40, "settles": 20, "maxPassesToSettle": 2, "reflows": 0, "textRemeasures": 326 },
4-
"grid": { "nodeCount": 570, "layoutPasses": 60, "settles": 30, "maxPassesToSettle": 2, "reflows": 0, "textRemeasures": 180 },
5-
"epg": { "nodeCount": 832, "layoutPasses": 64, "settles": 32, "maxPassesToSettle": 2, "reflows": 0, "textRemeasures": 512 }
2+
"home": { "nodeCount": 875, "layoutPasses": 25, "settles": 25, "maxPassesToSettle": 1, "reflows": 0, "textRemeasures": 0 },
3+
"details": { "nodeCount": 670, "layoutPasses": 20, "settles": 20, "maxPassesToSettle": 1, "reflows": 0, "textRemeasures": 0 },
4+
"grid": { "nodeCount": 570, "layoutPasses": 30, "settles": 30, "maxPassesToSettle": 1, "reflows": 0, "textRemeasures": 0 },
5+
"epg": { "nodeCount": 832, "layoutPasses": 32, "settles": 32, "maxPassesToSettle": 1, "reflows": 0, "textRemeasures": 0 }
66
}
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import type { LightningElement } from '@plextv/react-lightning';
4+
5+
import { LightningManager } from './LightningManager';
6+
import type { AtlasData } from './text/FontMetricsStore';
7+
import type { YogaManager } from './YogaManager';
8+
9+
// Every glyph 10 units wide at size 10: at fontSize 20 a character is 20px and
10+
// a line is 20px tall.
11+
const atlas: AtlasData = {
12+
info: { size: 10, face: 'Test' },
13+
common: { lineHeight: 12, base: 8 },
14+
lightningMetrics: { ascender: 800, descender: -200, lineGap: 0, unitsPerEm: 1000 },
15+
chars: Array.from({ length: 95 }, (_, i) => ({
16+
id: 32 + i,
17+
xadvance: 10,
18+
xoffset: 0,
19+
yoffset: 0,
20+
width: 8,
21+
height: 8,
22+
})),
23+
kernings: [],
24+
};
25+
26+
const atlasDataUrl = `data:application/json;base64,${Buffer.from(JSON.stringify(atlas)).toString('base64')}`;
27+
28+
type Handler = (...args: unknown[]) => void;
29+
30+
class FakeElement {
31+
public parent: FakeElement | null = null;
32+
public children: FakeElement[] = [];
33+
public isTextElement = false;
34+
public isImageElement = false;
35+
public text?: string;
36+
public props: { style: Record<string, unknown> };
37+
public rawProps: { style: Record<string, unknown> };
38+
public node: Record<string, unknown> = {};
39+
public hasLayout = false;
40+
41+
private _handlers = new Map<string, Set<Handler>>();
42+
43+
constructor(
44+
public id: number,
45+
public style: Record<string, unknown>,
46+
text?: string,
47+
) {
48+
this.props = { style };
49+
this.rawProps = { style };
50+
51+
if (text !== undefined) {
52+
this.isTextElement = true;
53+
this.text = text;
54+
}
55+
}
56+
57+
public on(event: string, handler: Handler): () => void {
58+
let set = this._handlers.get(event);
59+
60+
if (!set) {
61+
set = new Set();
62+
this._handlers.set(event, set);
63+
}
64+
65+
set.add(handler);
66+
67+
return () => set.delete(handler);
68+
}
69+
70+
public emit(event: string, ...args: unknown[]): void {
71+
for (const handler of this._handlers.get(event) ?? []) {
72+
handler(...args);
73+
}
74+
}
75+
76+
public withholdPaintUntilLayout(): void {}
77+
78+
public releaseWithheldPaint(): void {}
79+
80+
public setNodeProp(key: string, value: unknown): boolean {
81+
if (this.node[key] === value) {
82+
return false;
83+
}
84+
85+
this.node[key] = value;
86+
87+
return true;
88+
}
89+
90+
public emitLayoutEvent(): void {
91+
this.hasLayout = true;
92+
}
93+
}
94+
95+
const asElement = (fake: FakeElement) => fake as unknown as LightningElement;
96+
const textStyle = { fontFamily: 'Test', fontSize: 20, maxLines: 0 };
97+
98+
// A hero block: a 1000px column holding a title container whose width starts
99+
// narrow and resolves wider later (the collapsed-then-expanded hero title).
100+
async function setup(containerStyle: Record<string, unknown>, extra?: FakeElement) {
101+
const manager = new LightningManager();
102+
await manager.init({ fonts: [{ fontFamily: 'Test', atlasDataUrl }] });
103+
104+
const yoga = (manager as unknown as { _yogaManager: YogaManager })._yogaManager;
105+
const setTextMeasure = vi.spyOn(yoga, 'setTextMeasure');
106+
let passes = 0;
107+
let settles = 0;
108+
109+
yoga.on('render', () => passes++);
110+
yoga.on('settled', () => settles++);
111+
112+
const root = new FakeElement(1, {
113+
w: 1000,
114+
h: 600,
115+
flexDirection: 'column',
116+
alignItems: 'flex-start',
117+
});
118+
const container = new FakeElement(2, containerStyle);
119+
const title = new FakeElement(3, textStyle, 'aaaa aaaa aaaa aaaa');
120+
const tree: [FakeElement, FakeElement][] = [
121+
[root, container],
122+
[container, title],
123+
];
124+
125+
if (extra) {
126+
tree.push([container, extra]);
127+
}
128+
129+
for (const el of [root, container, title, extra]) {
130+
if (el) {
131+
manager.trackElement(asElement(el));
132+
manager.applyStyle(el.id, el.style, true);
133+
}
134+
}
135+
136+
for (const [parent, child] of tree) {
137+
child.parent = parent;
138+
parent.children.push(child);
139+
parent.emit('childAdded', child, parent.children.length - 1);
140+
}
141+
142+
manager.markFlexRoot(asElement(root));
143+
144+
const settle = async () => {
145+
passes = 0;
146+
settles = 0;
147+
setTextMeasure.mockClear();
148+
149+
for (let i = 0; i < 20; i++) {
150+
await Promise.resolve();
151+
}
152+
153+
return { passes, settles, forced: setTextMeasure.mock.calls.filter((call) => call[3]).length };
154+
};
155+
156+
const resize = (el: FakeElement, style: Record<string, unknown>) => {
157+
el.style = style;
158+
el.props.style = style;
159+
el.rawProps.style = style;
160+
manager.applyStyle(el.id, style, false, true);
161+
};
162+
163+
return { manager, container, title, settle, resize, mount: await settle() };
164+
}
165+
166+
describe('LightningManager grow-only text re-measure', () => {
167+
it('lays a new text node out once on mount', async () => {
168+
const { title, mount } = await setup({ w: 200, flexDirection: 'column' });
169+
170+
expect(mount).toEqual({ passes: 1, settles: 1, forced: 0 });
171+
// "aaaa aaaa" fits 200px: two lines of 180px.
172+
expect(title.node.h).toBe(40);
173+
});
174+
175+
it('re-measures the text when its container grows past the width it was measured at', async () => {
176+
const { container, title, settle, resize } = await setup({ w: 200, flexDirection: 'column' });
177+
178+
resize(container, { w: 400, flexDirection: 'column' });
179+
180+
expect((await settle()).forced).toBe(1);
181+
expect(title.node.h).toBe(20);
182+
expect(title.node.w).toBe(400 + 2);
183+
});
184+
185+
it('does not re-measure when the container shrinks, or regrows to a width it already had', async () => {
186+
const { container, settle, resize } = await setup({ w: 400, flexDirection: 'column' });
187+
188+
resize(container, { w: 200, flexDirection: 'column' });
189+
expect((await settle()).forced).toBe(0);
190+
191+
resize(container, { w: 400, flexDirection: 'column' });
192+
expect((await settle()).forced).toBe(0);
193+
});
194+
195+
it('converges when the container width is derived from the text and a sibling', async () => {
196+
const warn = vi.spyOn(console, 'warn');
197+
const logo = new FakeElement(4, { w: 120, h: 40 });
198+
const { settle, resize } = await setup({ flexDirection: 'row', alignSelf: 'flex-start' }, logo);
199+
200+
for (const w of [500, 120, 700, 60]) {
201+
resize(logo, { w, h: 40 });
202+
203+
const { settles } = await settle();
204+
205+
expect(settles).toBe(1);
206+
}
207+
208+
expect(warn).not.toHaveBeenCalled();
209+
warn.mockRestore();
210+
});
211+
});

‎packages/plugin-flexbox/src/LightningManager.ts‎

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,16 @@ export class LightningManager {
559559
}
560560
}
561561

562+
private _regrowText = (measuredAt: number, textId: number) => {
563+
const textEl = this._elements.get(textId);
564+
const contextWidth = textEl?.parent?.node.w ?? 0;
565+
566+
if (textEl && contextWidth > measuredAt) {
567+
this._textContextWidth.set(textId, contextWidth);
568+
this._syncTextMeasure(textEl, true);
569+
}
570+
};
571+
562572
private _applyUpdates = (buffer: ArrayBuffer) => {
563573
// Raw `DataView` over `SimpleDataView` — this is a per-frame hot path
564574
// that doesn't need overflow handling or write tracking.
@@ -614,6 +624,12 @@ export class LightningManager {
614624
// renderer wraps to the same width and textAlign has a box to align in.
615625
const isMeasuredText = isText && this._measuredText.has(elementId);
616626

627+
// Its first layout measured it against the parent's width (parents come
628+
// first in the buffer), so that's the baseline. Seeding 0 re-ran mount.
629+
if (isMeasuredText && !this._textContextWidth.has(elementId)) {
630+
this._textContextWidth.set(elementId, el.parent?.node.w ?? 0);
631+
}
632+
617633
if (width !== 0 && (!isText || isMeasuredText)) {
618634
if (isMeasuredText) {
619635
// `contain` lives on the text node, not the base node type — set it
@@ -669,21 +685,8 @@ export class LightningManager {
669685
// the widest the container ever offered, which never clips. New text on a
670686
// recycled node is handled separately by the `textChanged` → setTextMeasure
671687
// measure-func reinstall, so a narrower reuse still re-measures correctly.
672-
if (this._measuredText.size > 0) {
673-
for (const textId of this._measuredText) {
674-
const textEl = this._elements.get(textId);
675-
676-
if (!textEl) {
677-
continue;
678-
}
679-
680-
const contextWidth = textEl.parent?.node.w ?? 0;
681-
682-
if (contextWidth > (this._textContextWidth.get(textId) ?? 0)) {
683-
this._textContextWidth.set(textId, contextWidth);
684-
this._syncTextMeasure(textEl, true);
685-
}
686-
}
687-
}
688+
// Only texts laid out since they were measured have a baseline, the rest
689+
// measure fresh. forEach because for...of allocates per entry in lower JIT tiers.
690+
this._textContextWidth.forEach(this._regrowText);
688691
};
689692
}

0 commit comments

Comments
 (0)