diff --git a/.changeset/quiet-moose-arrive.md b/.changeset/quiet-moose-arrive.md new file mode 100644 index 0000000000..9339b7b1b7 --- /dev/null +++ b/.changeset/quiet-moose-arrive.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Guard against executing matchMedia during initial render diff --git a/packages/perseus/src/widgets/explanation.tsx b/packages/perseus/src/widgets/explanation.tsx index 2a17b6b135..44edc1f183 100644 --- a/packages/perseus/src/widgets/explanation.tsx +++ b/packages/perseus/src/widgets/explanation.tsx @@ -66,6 +66,15 @@ class Explanation extends React.Component { state: State = { expanded: false, }; + _mounted: boolean = false; + + componentDidMount() { + this._mounted = true; + } + + componentWillUnmount() { + this._mounted = false; + } change: (arg1: any, arg2: any, arg3: any) => any = (...args) => { return Changeable.change.apply(this, args); @@ -85,9 +94,9 @@ class Explanation extends React.Component { 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)"); // 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. diff --git a/packages/perseus/src/widgets/label-image.tsx b/packages/perseus/src/widgets/label-image.tsx index 3dbe0c6220..14fc2477b2 100644 --- a/packages/perseus/src/widgets/label-image.tsx +++ b/packages/perseus/src/widgets/label-image.tsx @@ -109,6 +109,7 @@ export class LabelImage extends React.Component< // The rendered markers on the question image for labeling. _markers: Array; + _mounted: boolean = false; static gradeMarker(marker: InteractiveMarkerType): InteractiveMarkerScore { const score = { @@ -375,6 +376,14 @@ export class LabelImage extends React.Component< }; } + componentDidMount() { + this._mounted = true; + } + + componentWillUnmount() { + this._mounted = false; + } + simpleValidate(rubric: LabelImageProps): PerseusScore { return LabelImage.validate(this.getUserInput(), rubric); } @@ -503,17 +512,17 @@ export class LabelImage extends React.Component< 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 ", "")) + .matches; - // 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; + // 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 diff --git a/packages/perseus/src/widgets/label-image/marker.tsx b/packages/perseus/src/widgets/label-image/marker.tsx index fec794e2b7..79d4089e9f 100644 --- a/packages/perseus/src/widgets/label-image/marker.tsx +++ b/packages/perseus/src/widgets/label-image/marker.tsx @@ -37,6 +37,10 @@ type Props = InteractiveMarkerType & { }; function shouldReduceMotion(): boolean { + // We cannot use matchMedia during SSR. + if (typeof window.matchMedia !== "function") { + return true; + } const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); return !mediaQuery || mediaQuery.matches; } @@ -49,6 +53,7 @@ export default class Marker extends React.Component { // The marker icon element. _icon: HTMLElement | null | undefined; + _mounted: boolean = false; static defaultProps: { selected: ReadonlyArray; @@ -56,6 +61,14 @@ export default class Marker extends React.Component { selected: [], }; + componentDidMount() { + this._mounted = true; + } + + componentWillUnmount() { + this._mounted = false; + } + renderIcon() { const {selected, showCorrectness, showSelected, showPulsate} = this.props; @@ -106,7 +119,7 @@ export default class Marker extends React.Component { } else if (showPulsate) { iconStyles = [ styles.markerPulsateBase, - shouldReduceMotion() + this._mounted && shouldReduceMotion() ? showPulsate && styles.markerUnfilledPulsateOnce : showPulsate && styles.markerUnfilledPulsateInfinite, ];