perf(tui): skip rounding unchanged layout subtrees

This commit is contained in:
fangliquan
2026-08-17 02:43:15 +08:00
committed by kshitij
parent 8b681f70ea
commit bbbd3b100f
2 changed files with 251 additions and 14 deletions
@@ -436,6 +436,21 @@ export class Node {
_cGen = -1
_cN = 0
_cWr = 0
_rLayoutGen = -1
_rSubtreeLayoutGen = -1
_rValid = false
_rLeft = NaN
_rTop = NaN
_rWidth = NaN
_rHeight = NaN
_rRoundedLeft = NaN
_rRoundedTop = NaN
_rRoundedWidth = NaN
_rRoundedHeight = NaN
_rParentAbsLeft = NaN
_rParentAbsTop = NaN
_rScale = NaN
_rIsText = false
constructor(config?: Config) {
this.style = defaultStyle()
this.layout = {
@@ -509,6 +524,7 @@ export class Node {
this._cN = 0
this._cWr = 0
this._fbBasis = NaN
this._rValid = false
}
markDirty(): void {
this.isDirty_ = true
@@ -842,6 +858,8 @@ export class Node {
_yogaNodesVisited = 0
_yogaMeasureCalls = 0
_yogaCacheHits = 0
_yogaRoundedNodes = 0
_yogaRoundSkips = 0
_generation++
const w = ownerWidth === undefined ? NaN : ownerWidth
const h = ownerHeight === undefined ? NaN : ownerHeight
@@ -924,18 +942,24 @@ let _yogaNodesVisited = 0
let _yogaMeasureCalls = 0
let _yogaCacheHits = 0
let _yogaLiveNodes = 0
let _yogaRoundedNodes = 0
let _yogaRoundSkips = 0
export function getYogaCounters(): {
visited: number
measured: number
cacheHits: number
live: number
rounded: number
roundSkips: number
} {
return {
visited: _yogaNodesVisited,
measured: _yogaMeasureCalls,
cacheHits: _yogaCacheHits,
live: _yogaLiveNodes
live: _yogaLiveNodes,
rounded: _yogaRoundedNodes,
roundSkips: _yogaRoundSkips
}
}
@@ -952,6 +976,18 @@ function layoutNode(
forceHeight = false
): void {
_yogaNodesVisited++
if (performLayout) {
node._rLayoutGen = _generation
let ancestor: Node | null = node
while (ancestor && ancestor._rSubtreeLayoutGen !== _generation) {
ancestor._rSubtreeLayoutGen = _generation
ancestor = ancestor.parent
}
}
const style = node.style
const layout = node.layout
const sameGen = node._cGen === _generation && !performLayout
@@ -2191,28 +2227,83 @@ function collectLayoutChildren(node: Node, flow: Node[], abs: Node[]): void {
}
function roundLayout(node: Node, scale: number, absLeft: number, absTop: number): void {
if (scale === 0) {
const l = node.layout
const isText = node.measureFunc !== null
const wasLaidOut = node._rLayoutGen === _generation
if (
node._rValid &&
node._rSubtreeLayoutGen !== _generation &&
sameFloat(node._rParentAbsLeft, absLeft) &&
sameFloat(node._rParentAbsTop, absTop) &&
sameFloat(node._rScale, scale) &&
node._rIsText === isText &&
sameFloat(node._rRoundedLeft, l.left) &&
sameFloat(node._rRoundedTop, l.top) &&
sameFloat(node._rRoundedWidth, l.width) &&
sameFloat(node._rRoundedHeight, l.height)
) {
_yogaRoundSkips++
return
}
const l = node.layout
if (
node._rValid &&
!wasLaidOut &&
sameFloat(l.left, node._rRoundedLeft) &&
sameFloat(l.top, node._rRoundedTop) &&
sameFloat(l.width, node._rRoundedWidth) &&
sameFloat(l.height, node._rRoundedHeight)
) {
l.left = node._rLeft
l.top = node._rTop
l.width = node._rWidth
l.height = node._rHeight
}
_yogaRoundedNodes++
const nodeLeft = l.left
const nodeTop = l.top
const nodeWidth = l.width
const nodeHeight = l.height
const absNodeLeft = absLeft + nodeLeft
const absNodeTop = absTop + nodeTop
const isText = node.measureFunc !== null
l.left = roundValue(nodeLeft, scale, false, isText)
l.top = roundValue(nodeTop, scale, false, isText)
const absRight = absNodeLeft + nodeWidth
const absBottom = absNodeTop + nodeHeight
const hasFracW = !isWholeNumber(nodeWidth * scale)
const hasFracH = !isWholeNumber(nodeHeight * scale)
l.width =
roundValue(absRight, scale, isText && hasFracW, isText && !hasFracW) - roundValue(absNodeLeft, scale, false, isText)
l.height =
roundValue(absBottom, scale, isText && hasFracH, isText && !hasFracH) - roundValue(absNodeTop, scale, false, isText)
node._rValid = true
node._rLeft = nodeLeft
node._rTop = nodeTop
node._rWidth = nodeWidth
node._rHeight = nodeHeight
node._rParentAbsLeft = absLeft
node._rParentAbsTop = absTop
node._rScale = scale
node._rIsText = isText
if (scale === 0) {
l.left = nodeLeft
l.top = nodeTop
l.width = nodeWidth
l.height = nodeHeight
} else {
l.left = roundValue(nodeLeft, scale, false, isText)
l.top = roundValue(nodeTop, scale, false, isText)
const absRight = absNodeLeft + nodeWidth
const absBottom = absNodeTop + nodeHeight
const hasFracW = !isWholeNumber(nodeWidth * scale)
const hasFracH = !isWholeNumber(nodeHeight * scale)
l.width =
roundValue(absRight, scale, isText && hasFracW, isText && !hasFracW) -
roundValue(absNodeLeft, scale, false, isText)
l.height =
roundValue(absBottom, scale, isText && hasFracH, isText && !hasFracH) -
roundValue(absNodeTop, scale, false, isText)
}
node._rRoundedLeft = l.left
node._rRoundedTop = l.top
node._rRoundedWidth = l.width
node._rRoundedHeight = l.height
for (const c of node.children) {
roundLayout(c, scale, absNodeLeft, absNodeTop)
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest'
import Yoga, { FlexDirection, getYogaCounters, type Node } from './index.js'
const snapshot = (node: Node): number[] => {
const result = [node.getComputedLeft(), node.getComputedTop(), node.getComputedWidth(), node.getComputedHeight()]
for (let index = 0; index < node.getChildCount(); index++) {
result.push(...snapshot(node.getChild(index)))
}
return result
}
const buildTree = (rootWidth: number, widths: number[], scale: number) => {
const config = Yoga.Config.create()
config.setPointScaleFactor(scale)
const root = Yoga.Node.create(config)
root.setFlexDirection(FlexDirection.Column)
root.setWidth(rootWidth)
root.setHeight(20)
const leaves: Node[] = []
for (let groupIndex = 0; groupIndex < 4; groupIndex++) {
const group = Yoga.Node.create(config)
group.setFlexDirection(FlexDirection.Row)
group.setHeight(3.125)
root.insertChild(group, groupIndex)
for (let leafIndex = 0; leafIndex < 8; leafIndex++) {
const leaf = Yoga.Node.create(config)
leaf.setWidth(widths[groupIndex * 8 + leafIndex]!)
leaf.setHeight(1.125 + (leafIndex % 3) * 0.25)
group.insertChild(leaf, leafIndex)
leaves.push(leaf)
}
}
return { config, leaves, root }
}
describe('incremental layout rounding', () => {
it('skips an unchanged transcript subtree when only the clock changes', () => {
const config = Yoga.Config.create()
config.setPointScaleFactor(2)
const root = Yoga.Node.create(config)
root.setWidth(80)
root.setHeight(40)
const transcript = Yoga.Node.create(config)
transcript.setHeight(39)
root.insertChild(transcript, 0)
for (let index = 0; index < 500; index++) {
const row = Yoga.Node.create(config)
row.setWidth(20.25)
row.setHeight(0.25)
transcript.insertChild(row, index)
}
const clock = Yoga.Node.create(config)
clock.setWidth(5.25)
clock.setHeight(1)
root.insertChild(clock, 1)
root.calculateLayout(80, 40)
const transcriptWidth = transcript.getComputedWidth()
clock.setWidth(6.25)
root.calculateLayout(80, 40)
const counters = getYogaCounters()
expect(clock.getComputedWidth()).toBe(6.5)
expect(transcript.getComputedWidth()).toBe(transcriptWidth)
expect(counters.rounded).toBeLessThanOrEqual(4)
expect(counters.roundSkips).toBeGreaterThanOrEqual(1)
root.freeRecursive()
Yoga.Config.destroy(config)
})
it('re-rounds cached raw geometry when the point scale changes', () => {
const config = Yoga.Config.create()
config.setPointScaleFactor(2)
const root = Yoga.Node.create(config)
root.setWidth(20)
root.setHeight(10)
const child = Yoga.Node.create(config)
child.setWidth(10.25)
child.setHeight(1)
root.insertChild(child, 0)
root.calculateLayout(20, 10)
expect(child.getComputedWidth()).toBe(10.5)
config.setPointScaleFactor(4)
child.setWidth(10.125)
root.calculateLayout(20, 10)
expect(child.getComputedWidth()).toBe(10.25)
config.setPointScaleFactor(0)
root.calculateLayout(20, 10)
expect(child.getComputedWidth()).toBe(10.125)
root.freeRecursive()
Yoga.Config.destroy(config)
})
it('matches a fresh full layout across leaf, root, and scale changes', () => {
const widths = Array.from({ length: 32 }, (_, index) => 1.125 + (index % 5) * 0.375)
let rootWidth = 40.25
let scale = 2
const incremental = buildTree(rootWidth, widths, scale)
for (let step = 0; step < 24; step++) {
if (step % 6 === 0) {
scale = scale === 2 ? 4 : 2
incremental.config.setPointScaleFactor(scale)
} else if (step % 5 === 0) {
rootWidth += 0.375
incremental.root.setWidth(rootWidth)
} else {
const leafIndex = (step * 7) % widths.length
widths[leafIndex]! += 0.125
incremental.leaves[leafIndex]!.setWidth(widths[leafIndex]!)
}
incremental.root.calculateLayout(rootWidth, 20)
const fresh = buildTree(rootWidth, widths, scale)
fresh.root.calculateLayout(rootWidth, 20)
expect(snapshot(incremental.root), `step ${step}`).toEqual(snapshot(fresh.root))
fresh.root.freeRecursive()
Yoga.Config.destroy(fresh.config)
}
incremental.root.freeRecursive()
Yoga.Config.destroy(incremental.config)
})
})