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

Guard against matchMedia access during initial render #1459

Merged
merged 2 commits into from
Jul 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions .changeset/quiet-moose-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": patch
---

Guard against executing matchMedia during initial render
15 changes: 12 additions & 3 deletions packages/perseus/src/widgets/explanation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@
state: State = {
expanded: false,
};
_mounted: boolean = false;

componentDidMount() {
this._mounted = true;
}

Check warning on line 74 in packages/perseus/src/widgets/explanation.tsx

View check run for this annotation

Codecov / codecov/patch

packages/perseus/src/widgets/explanation.tsx#L74

Added line #L74 was not covered by tests
componentWillUnmount() {
this._mounted = false;
}

change: (arg1: any, arg2: any, arg3: any) => any = (...args) => {
return Changeable.change.apply(this, args);
Expand All @@ -85,9 +94,9 @@

const caretIcon = this.state.expanded ? caretUp : caretDown;

const allowTransition = mediaQueryIsMatched(
"(prefers-reduced-motion: no-preference)",
);
const allowTransition =
this._mounted &&
mediaQueryIsMatched("(prefers-reduced-motion: no-preference)");
Comment on lines +98 to +99
Copy link
Member Author

Choose a reason for hiding this comment

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

note: We don't want animation on by default on initial render in case the user has turned it off (long term, it would be useful if this also could be overridden by webapp profile setting too).

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm confused how this fixes things. The mediaQueryIsMatched function guards against window.matchMedia not being a function. Is that helper written wrong or is the fix more about only running on a mounted component?

Copy link
Member Author

Choose a reason for hiding this comment

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

This is a subtle thing, but we don't just want to guard against it being accessed; the initial render on both server and client must be identical, so we don't want to use matchMedia at all on first render as the client could give a different rendered result than the server.


// Special styling is needed to fit the button in a block of text without throwing off the line spacing.
// While the button is not normally included in a block of text, it needs to be able to accommodate such a case.
Expand Down
27 changes: 18 additions & 9 deletions packages/perseus/src/widgets/label-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@

// The rendered markers on the question image for labeling.
_markers: Array<Marker | null | undefined>;
_mounted: boolean = false;

Check warning on line 112 in packages/perseus/src/widgets/label-image.tsx

View check run for this annotation

Codecov / codecov/patch

packages/perseus/src/widgets/label-image.tsx#L112

Added line #L112 was not covered by tests

static gradeMarker(marker: InteractiveMarkerType): InteractiveMarkerScore {
const score = {
Expand Down Expand Up @@ -375,6 +376,14 @@
};
}

componentDidMount() {
this._mounted = true;
}

Check warning on line 382 in packages/perseus/src/widgets/label-image.tsx

View check run for this annotation

Codecov / codecov/patch

packages/perseus/src/widgets/label-image.tsx#L382

Added line #L382 was not covered by tests
componentWillUnmount() {
this._mounted = false;
}

Check warning on line 386 in packages/perseus/src/widgets/label-image.tsx

View check run for this annotation

Codecov / codecov/patch

packages/perseus/src/widgets/label-image.tsx#L386

Added line #L386 was not covered by tests
simpleValidate(rubric: LabelImageProps): PerseusScore {
return LabelImage.validate(this.getUserInput(), rubric);
}
Expand Down Expand Up @@ -503,17 +512,17 @@

const {activeMarkerIndex, markersInteracted} = this.state;

// Render all markers for widget.
return markers.map((marker, index): React.ReactElement => {
// Determine whether page is rendered in a narrow browser window.
const isNarrowPage = window.matchMedia(
mediaQueries.xsOrSmaller.replace("@media ", ""),
).matches;
// Determine whether page is rendered in a narrow browser window.
const isNarrowPage =
this._mounted &&
window.matchMedia(mediaQueries.xsOrSmaller.replace("@media ", ""))
Copy link
Collaborator

Choose a reason for hiding this comment

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

This usage doesn't use a helper to guard window.matchMedia being undefined. I wonder if we should move all these checks to call a helper that guards usage properly. Would that alleviate us needing the _mounted flag in each component?

Copy link
Member Author

@somewhatabstract somewhatabstract Jul 30, 2024

Choose a reason for hiding this comment

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

Guarding against it being undefined is really a red herring; I think perhaps that check should be removed where we have it. I don't think we support any browsers that won't have it at this point, and we shouldn't be determining by that measure if we call it or not on initial render as we need that to match on both client (where it exists) and server (where it doesn't exist or might give a different response than on the client).

The _mounted flag is the important bit to avoid having a different initial render on the client than on the server (both initial renders have to match or hydration fails)

.matches;
Comment on lines +517 to +519
Copy link
Member Author

Choose a reason for hiding this comment

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

note: Moved out of the loop since it won't change for each item, and modified to return false on initial render so that it will SSR and hydrate correctly.


// Determine whether the image is wider than it is tall.
const isWideImage =
this.props.imageWidth / 2 > this.props.imageHeight;
// Determine whether the image is wider than it is tall.
const isWideImage = this.props.imageWidth / 2 > this.props.imageHeight;
Copy link
Member Author

Choose a reason for hiding this comment

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

note: Moved out of the loop; doesn't need recalculating multiple times.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Thanks for fixing this up!


// Render all markers for widget.
return markers.map((marker, index): React.ReactElement => {
let side: "bottom" | "left" | "right" | "top";
let markerPosition;
// Position popup closest to the center, preferring it renders
Expand Down
15 changes: 14 additions & 1 deletion packages/perseus/src/widgets/label-image/marker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
};

function shouldReduceMotion(): boolean {
// We cannot use matchMedia during SSR.
if (typeof window.matchMedia !== "function") {
return true;
}

Check warning on line 43 in packages/perseus/src/widgets/label-image/marker.tsx

View check run for this annotation

Codecov / codecov/patch

packages/perseus/src/widgets/label-image/marker.tsx#L42-L43

Added lines #L42 - L43 were not covered by tests
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
return !mediaQuery || mediaQuery.matches;
}
Expand All @@ -49,13 +53,22 @@

// The marker icon element.
_icon: HTMLElement | null | undefined;
_mounted: boolean = false;

Check warning on line 56 in packages/perseus/src/widgets/label-image/marker.tsx

View check run for this annotation

Codecov / codecov/patch

packages/perseus/src/widgets/label-image/marker.tsx#L56

Added line #L56 was not covered by tests

static defaultProps: {
selected: ReadonlyArray<any>;
} = {
selected: [],
};

componentDidMount() {
this._mounted = true;
}

Check warning on line 67 in packages/perseus/src/widgets/label-image/marker.tsx

View check run for this annotation

Codecov / codecov/patch

packages/perseus/src/widgets/label-image/marker.tsx#L67

Added line #L67 was not covered by tests
componentWillUnmount() {
this._mounted = false;
}

Check warning on line 71 in packages/perseus/src/widgets/label-image/marker.tsx

View check run for this annotation

Codecov / codecov/patch

packages/perseus/src/widgets/label-image/marker.tsx#L71

Added line #L71 was not covered by tests
renderIcon() {
const {selected, showCorrectness, showSelected, showPulsate} =
this.props;
Expand Down Expand Up @@ -106,7 +119,7 @@
} else if (showPulsate) {
iconStyles = [
styles.markerPulsateBase,
shouldReduceMotion()
this._mounted && shouldReduceMotion()
Copy link
Member Author

Choose a reason for hiding this comment

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

note: We don't want animation on by default on initial render in case the user has turned it off (long term, it would be useful if this also could be overridden by webapp profile setting too).

? showPulsate && styles.markerUnfilledPulsateOnce
: showPulsate && styles.markerUnfilledPulsateInfinite,
];
Expand Down