Speed up compositing and gesture start without behavior changes

- Memoize each operation's choreography effect set in a computed, so
  nodes sharing an edge stop re-evaluating it (linear, not quadratic,
  in chain depth).
- Cache the scene viewport and refresh it from a ResizeObserver and at
  each begin(), instead of measuring the container per node per frame.
- Index operations by origin key so outgoing lookups no longer scan
  every operation; all map changes go through attach()/detach().
- Resolve px and % gesture edge distances arithmetically; only other
  CSS lengths use the off-screen probe. Verified in Chromium to match
  the probe within its 1/64px layout rounding.

The differential suites against main still match exactly. Benchmarks
vs main: styleForNode 2.0x (3 live ops) and 2.4x (10 live ops),
rendered frames 1.14x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
Claude
2026-10-16 03:26:51 +00:00
parent 8448e910ed
commit 46f030630b
3 changed files with 112 additions and 39 deletions
+15 -4
View File
@@ -103,12 +103,16 @@ export const pointOf = (
const nonNegative = (value: number) => const nonNegative = (value: number) =>
Number.isFinite(value) ? Math.max(0, value) : 0; Number.isFinite(value) ? Math.max(0, value) : 0;
const PIXELS = /^\s*(-?(?:\d+\.?\d*|\.\d+))px\s*$/;
const PERCENT = /^\s*(-?(?:\d+\.?\d*|\.\d+))%\s*$/;
/** /**
* Resolve an arbitrary CSS length against a box with the gesture host's size. * Resolve a CSS length against a box with the gesture host's size.
* *
* A short-lived off-screen box lets the browser handle `rem`, viewport units, * Numbers, pixel lengths, and percentages are computed directly. Anything
* percentages, `calc()`, and `clamp()` consistently. This runs only during * else (`rem`, viewport units, `calc()`, `clamp()`) is measured with a
* pointer-down for edge-constrained definitions. * short-lived off-screen box so the browser resolves it exactly. This runs
* only during pointer-down for edge-constrained definitions.
*/ */
export const resolveCssDistance = ( export const resolveCssDistance = (
distance: OriginGestureDistance, distance: OriginGestureDistance,
@@ -117,6 +121,13 @@ export const resolveCssDistance = (
bounds: OriginRect, bounds: OriginRect,
): number => { ): number => {
if (typeof distance === "number") return nonNegative(distance); if (typeof distance === "number") return nonNegative(distance);
const pixels = PIXELS.exec(distance);
if (pixels) return nonNegative(Number(pixels[1]));
const percent = PERCENT.exec(distance);
if (percent) {
const size = axis === "horizontal" ? bounds.width : bounds.height;
return nonNegative((Number(percent[1]) / 100) * size);
}
const document = host.ownerDocument; const document = host.ownerDocument;
if (!document.body) return Math.max(0, Number.parseFloat(distance) || 0); if (!document.body) return Math.max(0, Number.parseFloat(distance) || 0);
+33 -13
View File
@@ -76,6 +76,14 @@ export class SceneGraph {
* that back operation's id. Cancelling the back operation restores the edge. * that back operation's id. Cancelling the back operation restores the edge.
*/ */
private readonly suspendedEdges = new Map<number, MutableOriginOperation>(); private readonly suspendedEdges = new Map<number, MutableOriginOperation>();
/**
* Operations indexed by origin key, in insertion order. Every addition and
* removal goes through {@link attach} and {@link detach} to keep it in sync
* with `operations`.
*/
private readonly outgoing = shallowReactive(
new Map<string, readonly MutableOriginOperation[]>(),
);
private nodeSequence = 0; private nodeSequence = 0;
private operationSequence = 0; private operationSequence = 0;
@@ -117,10 +125,9 @@ export class SceneGraph {
/** The unfinished operation originated by a node, if any. */ /** The unfinished operation originated by a node, if any. */
liveOutgoingOf(nodeKey: string): MutableOriginOperation | undefined { liveOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
for (const operation of this.operations.values()) return this.outgoing
if (operation.originKey === nodeKey && operation.phase !== "finished") .get(nodeKey)
return operation; ?.find((operation) => operation.phase !== "finished");
return undefined;
} }
/** /**
@@ -128,9 +135,7 @@ export class SceneGraph {
* {@link liveOutgoingOf}, this includes a finished, connected resting edge. * {@link liveOutgoingOf}, this includes a finished, connected resting edge.
*/ */
presentedOutgoingOf(nodeKey: string): MutableOriginOperation | undefined { presentedOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
for (const operation of this.operations.values()) return this.outgoing.get(nodeKey)?.[0];
if (operation.originKey === nodeKey) return operation;
return undefined;
} }
/** Retained entries preceding `node`, oldest first. */ /** Retained entries preceding `node`, oldest first. */
@@ -257,7 +262,7 @@ export class SceneGraph {
this.transferPosition(origin, target); this.transferPosition(origin, target);
operation.phase = "finished"; operation.phase = "finished";
this.operations.delete(operation.id); this.detach(operation);
this.suspendedEdges.delete(operation.id); this.suspendedEdges.delete(operation.id);
if (operation.history === "push") { if (operation.history === "push") {
@@ -286,7 +291,7 @@ export class SceneGraph {
const target = this.nodes.get(operation.entryTargetKey); const target = this.nodes.get(operation.entryTargetKey);
const suspendedEdge = this.suspendedEdges.get(id); const suspendedEdge = this.suspendedEdges.get(id);
operation.phase = "finished"; operation.phase = "finished";
this.operations.delete(id); this.detach(operation);
this.suspendedEdges.delete(id); this.suspendedEdges.delete(id);
if (operation.history === "back") { if (operation.history === "back") {
@@ -294,7 +299,7 @@ export class SceneGraph {
// Put the retained page back into its former position, then reconnect // Put the retained page back into its former position, then reconnect
// the resting page→drawer edge. Both nodes keep their VNodes. // the resting page→drawer edge. Both nodes keep their VNodes.
this.transferPosition(origin, target); this.transferPosition(origin, target);
this.operations.set(suspendedEdge.id, suspendedEdge); this.attach(suspendedEdge);
origin.incomingOperationId = suspendedEdge.id; origin.incomingOperationId = suspendedEdge.id;
target.state = "exposed"; target.state = "exposed";
} else if (target) { } else if (target) {
@@ -362,7 +367,7 @@ export class SceneGraph {
const resting = this.operations.get(incoming.id); const resting = this.operations.get(incoming.id);
if (resting?.phase === "finished" && resting.choreography.persistAtRest) { if (resting?.phase === "finished" && resting.choreography.persistAtRest) {
// Re-root A→page→drawer as A→drawer before adding drawer→page. // Re-root A→page→drawer as A→drawer before adding drawer→page.
this.operations.delete(resting.id); this.detach(resting);
this.transferPosition(target, origin); this.transferPosition(target, origin);
target.incomingOperationId = undefined; target.incomingOperationId = undefined;
suspendedEdge = resting; suspendedEdge = resting;
@@ -461,10 +466,25 @@ export class SceneGraph {
intent: "undecided", intent: "undecided",
originRect: init.originRect, originRect: init.originRect,
}); });
this.operations.set(operation.id, operation); this.attach(operation);
return operation; return operation;
} }
private attach(operation: MutableOriginOperation): void {
this.operations.set(operation.id, operation);
const siblings = this.outgoing.get(operation.originKey) ?? [];
this.outgoing.set(operation.originKey, [...siblings, operation]);
}
private detach(operation: MutableOriginOperation): void {
this.operations.delete(operation.id);
const siblings = (this.outgoing.get(operation.originKey) ?? []).filter(
(sibling) => sibling.id !== operation.id,
);
if (siblings.length) this.outgoing.set(operation.originKey, siblings);
else this.outgoing.delete(operation.originKey);
}
private removeNode(nodeKey: string): void { private removeNode(nodeKey: string): void {
this.nodes.delete(nodeKey); this.nodes.delete(nodeKey);
this.roots.value = this.roots.value.filter((key) => key !== nodeKey); this.roots.value = this.roots.value.filter((key) => key !== nodeKey);
@@ -487,7 +507,7 @@ export class SceneGraph {
operation.targetKey === nodeKey || operation.targetKey === nodeKey ||
operation.entryTargetKey === nodeKey operation.entryTargetKey === nodeKey
) )
this.operations.delete(operation.id); this.detach(operation);
this.removeNode(nodeKey); this.removeNode(nodeKey);
} }
+64 -22
View File
@@ -3,6 +3,7 @@ import {
inject, inject,
markRaw, markRaw,
nextTick, nextTick,
shallowRef,
type ComputedRef, type ComputedRef,
type CSSProperties, type CSSProperties,
type InjectionKey, type InjectionKey,
@@ -66,24 +67,13 @@ const Z_INDEX_PER_LAYER = 10_000;
* Ancestors' source effects are intentionally excluded: only the initiating * Ancestors' source effects are intentionally excluded: only the initiating
* component receives the source side of an operation. * component receives the source side of an operation.
* *
* @param viewport - Measures the scene bounds supplied to choreographies. * @param effectsOf - The current effect set of an operation's choreography.
*/ */
export const nodeEffectLayers = ( export const nodeEffectLayers = (
graph: SceneGraph, graph: SceneGraph,
node: SceneNodeRecord, node: SceneNodeRecord,
viewport: () => OriginRect, effectsOf: (operation: MutableOriginOperation) => OriginEffectSet,
): OriginEffect[] => { ): OriginEffect[] => {
const effectsOf = (operation: MutableOriginOperation): OriginEffectSet =>
operation.choreography.effects({
progress: operation.progress,
velocity: operation.velocity,
phase: operation.phase,
intent: operation.intent,
originRect: operation.originRect,
targetRect: operation.targetRect,
viewport: viewport(),
});
const chain = graph.incomingChain(node); const chain = graph.incomingChain(node);
// Evaluate choreographies node-first, then apply them outermost-first. // Evaluate choreographies node-first, then apply them outermost-first.
const effectSets = chain.map(effectsOf); const effectSets = chain.map(effectsOf);
@@ -111,16 +101,13 @@ export const nodeEffectLayers = (
export const composeNodeStyle = ( export const composeNodeStyle = (
graph: SceneGraph, graph: SceneGraph,
nodeKey: string, nodeKey: string,
viewport: () => OriginRect, effectsOf: (operation: MutableOriginOperation) => OriginEffectSet,
): CSSProperties => { ): CSSProperties => {
const node = graph.node(nodeKey); const node = graph.node(nodeKey);
if (!node) return {}; if (!node) return {};
// Measure the scene at most once, and only when an edge needs it.
let measured: OriginRect | undefined;
const lazyViewport = () => (measured ??= viewport());
const { style, layer } = composeEffects( const { style, layer } = composeEffects(
nodeEffectLayers(graph, node, lazyViewport), nodeEffectLayers(graph, node, effectsOf),
NODE_BASE_STYLE, NODE_BASE_STYLE,
); );
style.zIndex = Z_INDEX_BASE + layer * Z_INDEX_PER_LAYER + node.sequence; style.zIndex = Z_INDEX_BASE + layer * Z_INDEX_PER_LAYER + node.sequence;
@@ -278,6 +265,22 @@ class Scene implements OriginScene {
/** DOM registered by the renderer, used only for measurement. */ /** DOM registered by the renderer, used only for measurement. */
private readonly hosts = new Map<string, HTMLElement>(); private readonly hosts = new Map<string, HTMLElement>();
private container: HTMLElement | null = null; private container: HTMLElement | null = null;
private resizeObserver: ResizeObserver | null = null;
/**
* Scene-container bounds, or the browser window before mounting. Cached and
* refreshed on resize and whenever an operation begins, so compositing
* never forces layout.
*/
private readonly viewport = shallowRef<OriginRect>(windowRect());
/**
* One memoized effect set per operation. It recomputes only when that
* operation's progress, phase, intent, rects, or the viewport change, so
* nodes sharing an edge do not evaluate its choreography again.
*/
private readonly effectSets = new WeakMap<
MutableOriginOperation,
ComputedRef<OriginEffectSet>
>();
constructor(options: CreateOriginSceneOptions) { constructor(options: CreateOriginSceneOptions) {
// Never proxy the scene: its methods rely on raw internal state. // Never proxy the scene: its methods rely on raw internal state.
@@ -307,6 +310,7 @@ class Scene implements OriginScene {
originKey: string, originKey: string,
action: OriginAction, action: OriginAction,
): Promise<OriginOperationHandle> { ): Promise<OriginOperationHandle> {
this.refreshViewport();
const operation = this.graph.begin( const operation = this.graph.begin(
originKey, originKey,
action, action,
@@ -357,11 +361,20 @@ class Scene implements OriginScene {
} }
registerContainer(element: HTMLElement | null): void { registerContainer(element: HTMLElement | null): void {
if (element === this.container) return;
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.container = element; this.container = element;
if (!element) return;
this.refreshViewport();
if (typeof ResizeObserver !== "undefined") {
this.resizeObserver = new ResizeObserver(() => this.refreshViewport());
this.resizeObserver.observe(element);
}
} }
styleForNode(nodeKey: string): CSSProperties { styleForNode(nodeKey: string): CSSProperties {
return composeNodeStyle(this.graph, nodeKey, () => this.viewport()); return composeNodeStyle(this.graph, nodeKey, this.effectsOf);
} }
isNodeInteractive(nodeKey: string): boolean { isNodeInteractive(nodeKey: string): boolean {
@@ -392,10 +405,39 @@ class Scene implements OriginScene {
return host ? measureRect(host) : undefined; return host ? measureRect(host) : undefined;
} }
/** Scene-container bounds, or the browser window before mounting. */ /** Re-measure the container, replacing the cached rect only on change. */
private viewport(): OriginRect { private refreshViewport(): void {
return this.container ? measureRect(this.container) : windowRect(); const next = this.container ? measureRect(this.container) : windowRect();
const current = this.viewport.value;
if (
next.top !== current.top ||
next.left !== current.left ||
next.width !== current.width ||
next.height !== current.height
)
this.viewport.value = next;
} }
private readonly effectsOf = (
operation: MutableOriginOperation,
): OriginEffectSet => {
let effects = this.effectSets.get(operation);
if (!effects) {
effects = computed(() =>
operation.choreography.effects({
progress: operation.progress,
velocity: operation.velocity,
phase: operation.phase,
intent: operation.intent,
originRect: operation.originRect,
targetRect: operation.targetRect,
viewport: this.viewport.value,
}),
);
this.effectSets.set(operation, effects);
}
return effects.value;
};
} }
/** /**