Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve paint/repaint behavior #10449

Merged
merged 2 commits into from
Mar 19, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/e2e-tests/helpers/screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ export async function getGraphicsExecutionPoint(page: Page): Promise<string | nu
return await element.getAttribute("data-execution-point");
}

export async function getGraphicsScreenshotType(page: Page): Promise<string | null> {
const element = getVideoElement(page);
return await element.getAttribute("data-screenshot-type");
}

export async function getGraphicsStatus(page: Page): Promise<string | null> {
const element = getVideoElement(page);
return await element.getAttribute("data-status");
Expand Down Expand Up @@ -135,3 +140,11 @@ export async function waitForGraphicsToLoad(page: Page) {
await expect(await getGraphicsStatus(page)).toBe("loaded");
});
}

export async function waitForRepaintGraphicsToLoad(page: Page) {
await debugPrint(page, `Waiting for repaint graphics to load...`, "waitForRepaintGraphicsToLoad");

await waitFor(async () => {
await expect(await getGraphicsScreenshotType(page)).toBe("repaint");
});
}
126 changes: 126 additions & 0 deletions packages/e2e-tests/tests/repaint-06.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { openDevToolsTab, startTest } from "../helpers";
import { findConsoleMessage, seekToConsoleMessage } from "../helpers/console-panel";
import { stepOver } from "../helpers/pause-information-panel";
import { getGraphicsDataUrl, waitForRepaintGraphicsToLoad } from "../helpers/screenshot";
import { debugPrint } from "../helpers/utils";
import { expect, test } from "../testFixture";

test.use({ exampleKey: "doc_control_flow.html" });

test.skip("repaint-06: repaints the screen screen when stepping over code that modifies the DOM", async ({
pageWithMeta: { page, recordingId },
exampleKey,
}) => {
await startTest(page, recordingId);
await openDevToolsTab(page);

const printedStrings = [
"catch",
"afterCatch",
"finally",
"yield 1",
"generated 1",
"yield 2",
"generated 2",
"after timer 1",
"after timer 2",
"after throw timer 1",
"after throw timer 2",
"within iterator",
];

const paints: {
[key: string]: {
before: string | null;
after: string | null;
};
} = {};

{
await debugPrint(page, "Testing repaint graphics");

let graphicsDataUrl: string | null = null;
let previousGraphicsDataUrl: string | null = null;

for (let index = 0; index < printedStrings.length; index++) {
const string = printedStrings[index];
const text = `updateText("${string}")`;

const locator = await findConsoleMessage(page, text, "console-log");
await seekToConsoleMessage(page, locator);

// Step to "Element text before..."
await stepOver(page);
await stepOver(page);

await waitForRepaintGraphicsToLoad(page);

graphicsDataUrl = await getGraphicsDataUrl(page);
expect(
graphicsDataUrl,
`Expected graphics before "${string}" not to match previous after graphics`
).not.toEqual(previousGraphicsDataUrl);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is correct: why should the screenshot change in between updates?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, you're right about that.

previousGraphicsDataUrl = graphicsDataUrl;

const cachedPaints = {
before: graphicsDataUrl,
after: "" as string | null,
};

// Step to "Element text after..."
await stepOver(page);
await stepOver(page);

await waitForRepaintGraphicsToLoad(page);

graphicsDataUrl = await getGraphicsDataUrl(page);
expect(
graphicsDataUrl,
`Expected graphics after "${string}" not to match the graphics before`
).not.toEqual(previousGraphicsDataUrl);
previousGraphicsDataUrl = graphicsDataUrl;

cachedPaints.after = graphicsDataUrl;

paints[string] = cachedPaints;
}
}

{
await debugPrint(page, "Testing screenshot caching");

let graphicsDataUrl: string | null = null;

for (let index = 0; index < printedStrings.length; index++) {
const string = printedStrings[index];
const text = `updateText("${string}")`;

const locator = await findConsoleMessage(page, text, "console-log");
await seekToConsoleMessage(page, locator);

// Step to "Element text before..."
await stepOver(page);
await stepOver(page);

await waitForRepaintGraphicsToLoad(page);

graphicsDataUrl = await getGraphicsDataUrl(page);
expect(
graphicsDataUrl,
`Expected graphics before "${string}" to match previous cached graphics`
).toEqual(paints[string]?.before);

// Step to "Element text after..."
await stepOver(page);
await stepOver(page);

await waitForRepaintGraphicsToLoad(page);

graphicsDataUrl = await getGraphicsDataUrl(page);
expect(
graphicsDataUrl,
`Expected graphics after "${string}" to match previous cached graphics`
).toEqual(paints[string]?.after);
}
}
});
17 changes: 16 additions & 1 deletion packages/protocol/RepaintGraphicsCache.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { PauseId, repaintGraphicsResult } from "@replayio/protocol";
import { Cache, createCache } from "suspense";

import { paintHashCache } from "replay-next/src/suspense/ScreenshotCache";
import { ReplayClientInterface } from "shared/client/types";

export const RepaintGraphicsCache: Cache<
Expand All @@ -11,6 +12,20 @@ export const RepaintGraphicsCache: Cache<
debugLabel: "RepaintGraphicsCache",
getKey: ([replayClient, pauseId]) => pauseId,
load: async ([replayClient, pauseId]) => {
return replayClient.repaintGraphics(pauseId);
const result = await replayClient.repaintGraphics(pauseId);

let { description, screenShot } = result;

// The backend won't return a screenshot for a given hash more than once; see FE-2357
if (screenShot) {
paintHashCache.cacheValue(screenShot, description.hash);
} else {
screenShot = paintHashCache.getValueIfCached(description.hash);
}
Comment on lines +19 to +24
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DOM.repaintGraphics API docs say this about the screenShot value:

Contents of the screen shot. If an identical screen shot was previously returned, this will be omitted.


return {
description,
screenShot,
};
},
});
17 changes: 15 additions & 2 deletions packages/replay-next/src/suspense/ScreenshotCache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ExecutionPoint, ScreenShot } from "@replayio/protocol";
import { createCache } from "suspense";
import { createCache, createExternallyManagedCache } from "suspense";

import { ReplayClientInterface } from "shared/client/types";

Expand All @@ -10,5 +10,18 @@ export const screenshotCache = createCache<
config: { immutable: true },
debugLabel: "ScreenshotCache",
getKey: ([client, point, paintHash]) => paintHash,
load: ([client, point]) => client.getScreenshot(point),
load: async ([client, point]) => {
const screenShot = await client.getScreenshot(point);

paintHashCache.cacheValue(screenShot, screenShot.hash);

return screenShot;
Comment on lines +13 to +18
Copy link
Contributor Author

@bvaughn bvaughn Mar 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this is prone to the same case mentioned in the repaint cache. Seems like it could be, depending on how RUN keys screenshots (by point or by hash)?

Edit I don't think this is a problem, after re-reading the docs for Graphics.getPaintContents. Unlike DOM.repaintGraphics, this endpoint doesn't mention anything about returning an optional value. 👍🏼

},
});

// The backend won't return a screenshot for a given hash more than once; see FE-2357
export const paintHashCache = createExternallyManagedCache<[paintHash: string], ScreenShot>({
config: { immutable: true },
debugLabel: "paintHashCache",
getKey: ([paintHash]) => paintHash,
});
1 change: 1 addition & 0 deletions src/ui/components/Video/Video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export default function Video() {

// The data attributes are sed for e2e tests
containerElement.setAttribute("data-execution-point", nextState.currentExecutionPoint ?? "");
containerElement.setAttribute("data-screenshot-type", "" + nextState.screenShotType);
containerElement.setAttribute("data-status", "" + nextState.status);
containerElement.setAttribute("data-time", "" + nextState.currentTime);
graphicsElement.setAttribute("data-scale", nextState.localScale.toString());
Expand Down
19 changes: 19 additions & 0 deletions src/ui/components/Video/imperative/MutableGraphicsState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ interface Rect {
}

export type Status = "failed" | "loaded" | "loading";
export type ScreenShotType = "cached-paint" | "repaint";

export interface State {
currentExecutionPoint: ExecutionPoint | null;
Expand All @@ -33,6 +34,7 @@ export interface State {
localScale: number;
recordingScale: number;
screenShot: ScreenShot | undefined;
screenShotType: ScreenShotType | undefined;
status: Status;
}

Expand All @@ -48,25 +50,33 @@ export const state = createState<State>({
localScale: 1,
recordingScale: 1,
screenShot: undefined,
screenShotType: undefined,
status: "loading",
});

let lock = new Object();

export async function updateState(
containerElement: HTMLElement,
options: Partial<{
didResize?: boolean;
executionPoint: ExecutionPoint | null;
screenShot: ScreenShot | null;
screenShotType: ScreenShotType | null;
status: Status;
time: number;
}> = {}
) {
const localLock = new Object();
lock = localLock;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was a potential race here before when e.g. scrubbing the timeline.


const prevState = state.read();

let {
didResize,
executionPoint = prevState.currentExecutionPoint,
screenShot = prevState.screenShot,
screenShotType = prevState.screenShotType,
status = prevState.status,
time = prevState.currentTime,
} = options;
Expand All @@ -76,6 +86,10 @@ export async function updateState(
let recordingScale = prevState.recordingScale;
if (screenShot && (screenShot != prevState.screenShot || didResize)) {
const naturalDimensions = await getDimensions(screenShot.data, screenShot.mimeType);
if (lock !== localLock) {
return;
}

const naturalHeight = naturalDimensions.height;
const naturalWidth = naturalDimensions.width;

Expand All @@ -86,6 +100,10 @@ export async function updateState(
imageHeight: naturalHeight,
imageWidth: naturalWidth,
});
if (lock !== localLock) {
return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: fitImageToContainer() isn't async so we can remove the await and this check.

}

const clientHeight = scaledDimensions.height;
const clientWidth = scaledDimensions.width;

Expand All @@ -107,6 +125,7 @@ export async function updateState(
localScale,
recordingScale,
screenShot: screenShot || undefined,
screenShotType: screenShotType || undefined,
status,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,9 @@ export function subscribeToMutableSources({
});
}
} else {
if (didStopPlaying) {
if (abortController != null) {
abortController.abort();
abortController = null;
}
if (abortController != null) {
abortController.abort();
abortController = null;
}

abortController = new AbortController();
Expand Down
Loading
Loading