Skip to content

Commit c5d808e

Browse files
perf(library): keep route previews within frame budget
1 parent e2f0d76 commit c5d808e

4 files changed

Lines changed: 137 additions & 4 deletions

File tree

library/lib/utils/geometry/edgeGeometrySubscriptions.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,52 @@ const registryRouteBounds = (route: IPoint[]): GeometryRect => {
4747
return result
4848
}
4949

50+
type DisplayedRouteChange = {
51+
id: string
52+
before: IPoint[] | undefined
53+
after: IPoint[] | undefined
54+
}
55+
56+
// Every rendered edge owns a selector, but all selectors observe the same
57+
// geometry-store transition. Cache that transition's changed display routes
58+
// once so a preview write costs O(route count + subscribers * changed routes),
59+
// rather than making every edge diff the complete route registry independently.
60+
const displayedRouteChangesCache = new WeakMap<
61+
Readonly<Record<string, IPoint[]>>,
62+
WeakMap<
63+
Readonly<Record<string, IPoint[]>>,
64+
WeakMap<Readonly<Record<string, IPoint[]>>, DisplayedRouteChange[]>
65+
>
66+
>()
67+
68+
const displayedRouteChanges = (
69+
geometryById: Readonly<Record<string, IPoint[]>>,
70+
previousPreview: Readonly<Record<string, IPoint[]>>,
71+
nextPreview: Readonly<Record<string, IPoint[]>>
72+
): DisplayedRouteChange[] => {
73+
let byPrevious = displayedRouteChangesCache.get(geometryById)
74+
if (!byPrevious) {
75+
byPrevious = new WeakMap()
76+
displayedRouteChangesCache.set(geometryById, byPrevious)
77+
}
78+
let byNext = byPrevious.get(previousPreview)
79+
if (!byNext) {
80+
byNext = new WeakMap()
81+
byPrevious.set(previousPreview, byNext)
82+
}
83+
const cached = byNext.get(nextPreview)
84+
if (cached) return cached
85+
86+
const changes: DisplayedRouteChange[] = []
87+
for (const [id, exact] of Object.entries(geometryById)) {
88+
const before = previousPreview[id] ?? exact
89+
const after = nextPreview[id] ?? exact
90+
if (before !== after) changes.push({ id, before, after })
91+
}
92+
byNext.set(nextPreview, changes)
93+
return changes
94+
}
95+
5096
/**
5197
* Broad-phase route selection. It deliberately permits boundary-touching false
5298
* positives; downstream jump/label geometry retains its exact intersection
@@ -133,6 +179,22 @@ export const createDisplayedRouteEntriesSelector = (
133179
return (geometryById, previewById) => {
134180
if (geometryById === previousGeometry && previewById === previousPreview)
135181
return previousSelection
182+
if (geometryById === previousGeometry && previousPreview) {
183+
const relevantRouteChanged = displayedRouteChanges(
184+
geometryById,
185+
previousPreview,
186+
previewById
187+
).some(
188+
({ id, before, after }) =>
189+
id !== excludeId &&
190+
((before && mayIntersect(query, registryRouteBounds(before))) ||
191+
(after && mayIntersect(query, registryRouteBounds(after))))
192+
)
193+
if (!relevantRouteChanged) {
194+
previousPreview = previewById
195+
return previousSelection
196+
}
197+
}
136198
previousGeometry = geometryById
137199
previousPreview = previewById
138200
previousSelection = selectDisplayedRouteEntriesIntersectingRect(

library/tests/unit/edgeGeometrySubscriptions.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,24 @@ describe("selectRouteEntriesIntersectingRect", () => {
112112

113113
expect(before).toEqual(["near", near])
114114
expect(shallow(before, after)).toBe(true)
115+
expect(after).toBe(before)
116+
})
117+
118+
it("recomputes a displayed-route selection when a preview enters or leaves the query", () => {
119+
const far = [p(500, 0), p(500, 100)]
120+
const entering = [p(80, 0), p(80, 100)]
121+
const exact = { route: far }
122+
const select = createDisplayedRouteEntriesSelector(query)
123+
124+
const outside = select(exact, { route: far })
125+
const inside = select(exact, { route: entering })
126+
const left = select(exact, { route: [p(510, 0), p(510, 100)] })
127+
128+
expect(outside).toEqual([])
129+
expect(inside).toEqual(["route", entering])
130+
expect(left).toEqual([])
131+
expect(inside).not.toBe(outside)
132+
expect(left).not.toBe(inside)
115133
})
116134

117135
it("changes when a route enters, changes within, or leaves the query", () => {

standalone/webapp/playwright.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ export default defineConfig({
131131
use: {
132132
...devices["Desktop Firefox"],
133133
viewport: { width: 1280, height: 720 },
134+
// Trace screenshots run after every Playwright action and compete
135+
// with the rAF probe for paint time. A performance project must
136+
// measure the application, not continuous diagnostic capture.
137+
trace: "off",
134138
},
135139
},
136140
]
@@ -152,6 +156,9 @@ export default defineConfig({
152156
use: {
153157
...devices["Desktop Chrome"],
154158
viewport: { width: 1280, height: 720 },
159+
// Keep the timing probe isolated from per-action trace screenshots.
160+
// Failure screenshots and the HTML report remain available globally.
161+
trace: "off",
155162
},
156163
},
157164
],

standalone/webapp/tests/perf/edge-routing.spec.ts

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,48 @@ const renderedEdgePaths = async (
6464
) as Record<string, string>
6565
)
6666

67+
/**
68+
* Pick nodes whose centres are real pointer targets, ordered from the viewport
69+
* centre outwards. The standalone header and palette overlay the canvas; using
70+
* fixture indices assumes those controls do not exist and can leave Playwright
71+
* sending a measured drag to a negative or obscured viewport coordinate.
72+
*/
73+
const unobscuredNodesNearestViewportCenter = async (
74+
editor: Locator,
75+
count: number
76+
): Promise<string[]> =>
77+
editor
78+
.locator('.react-flow__node[data-id^="perf-node-"]')
79+
.evaluateAll((elements, desiredCount) => {
80+
const viewportCenter = {
81+
x: window.innerWidth / 2,
82+
y: window.innerHeight / 2,
83+
}
84+
return elements
85+
.flatMap((element) => {
86+
const id = element.getAttribute("data-id")
87+
const rect = element.getBoundingClientRect()
88+
const center = {
89+
x: rect.left + rect.width / 2,
90+
y: rect.top + rect.height / 2,
91+
}
92+
const pointerTarget = document.elementFromPoint(center.x, center.y)
93+
if (!id || !pointerTarget || !element.contains(pointerTarget))
94+
return []
95+
return [
96+
{
97+
id,
98+
distance:
99+
(center.x - viewportCenter.x) ** 2 +
100+
(center.y - viewportCenter.y) ** 2,
101+
},
102+
]
103+
})
104+
.sort((a, b) => a.distance - b.distance)
105+
.slice(0, desiredCount)
106+
.map(({ id }) => id)
107+
}, count)
108+
67109
const hasMultipleDirectionChanges = (path: string): boolean => {
68110
const commands = [
69111
...path.matchAll(/([ML])\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g),
@@ -511,11 +553,15 @@ test("large-diagram interaction sustains a 30 fps p95 frame budget", async ({
511553
await openLocalWithPerf(page, fixture)
512554
const editor = page.locator(`#react-flow-library-${String(fixture.id)}`)
513555
const frameDeltas: number[] = []
556+
const nodeIds = await unobscuredNodesNearestViewportCenter(editor, 4)
514557

515-
for (let index = 0; index < 4; index++) {
516-
const node = editor.locator(
517-
`.react-flow__node[data-id="perf-node-${String(index).padStart(2, "0")}"]`
518-
)
558+
expect(
559+
nodeIds,
560+
"performance fixture must expose four unobscured nodes"
561+
).toHaveLength(4)
562+
563+
for (const [index, nodeId] of nodeIds.entries()) {
564+
const node = editor.locator(`.react-flow__node[data-id="${nodeId}"]`)
519565
frameDeltas.push(
520566
...(await dragNodeBy(node, page, index % 2 === 0 ? 40 : -40, 30, {
521567
steps: 12,

0 commit comments

Comments
 (0)