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:
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user