diff --git a/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx b/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx index 4e073cc771..3ad5a4a203 100644 --- a/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx +++ b/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx @@ -726,7 +726,12 @@ const CertificatePage: React.FC<{ }, [print]) const [shareOpen, setShareOpen] = useState(false) - const shareButtonRef = useRef(null) + const [shareAnchorEl, setShareAnchorEl] = useState( + null, + ) + const shareButtonRef = (node: HTMLDivElement | null) => { + setShareAnchorEl(node) + } if (isCourseLoading || isProgramLoading) { return @@ -776,7 +781,7 @@ const CertificatePage: React.FC<{ setShareOpen(false)} pageUrl={pageUrl} /> diff --git a/frontends/main/src/app-pages/ChannelPage/ChannelPageTemplate.tsx b/frontends/main/src/app-pages/ChannelPage/ChannelPageTemplate.tsx index 1c7c3d48b7..a4ddd05b03 100644 --- a/frontends/main/src/app-pages/ChannelPage/ChannelPageTemplate.tsx +++ b/frontends/main/src/app-pages/ChannelPage/ChannelPageTemplate.tsx @@ -72,23 +72,18 @@ const ChannelPageTemplate: React.FC = ({ channelType, name, }) => { - const getChannelTemplate = (channelType: string) => { - switch (channelType) { - case ChannelTypeEnum.Unit: - return UnitChannelTemplate - case ChannelTypeEnum.Topic: - return TopicChannelTemplate - default: - return DefaultChannelTemplate - } + switch (channelType) { + case ChannelTypeEnum.Unit: + return {children} + case ChannelTypeEnum.Topic: + return {children} + default: + return ( + + {children} + + ) } - const ChannelTemplate = getChannelTemplate(channelType) - - return ( - - {children} - - ) } export { diff --git a/frontends/main/src/app-pages/HomePage/TestimonialsSection.tsx b/frontends/main/src/app-pages/HomePage/TestimonialsSection.tsx index df64b67c20..7a426a879a 100644 --- a/frontends/main/src/app-pages/HomePage/TestimonialsSection.tsx +++ b/frontends/main/src/app-pages/HomePage/TestimonialsSection.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react" +import React, { useMemo } from "react" import { shuffle } from "lodash" import { Container, @@ -11,7 +11,6 @@ import { } from "ol-components" import { ActionButton } from "@mitodl/smoot-design" import { useTestimonialList } from "api/hooks/testimonials" -import type { Attestation } from "api/v0" import { RiArrowRightLine, RiArrowLeftLine } from "@remixicon/react" import Slider from "react-slick" import AttestantBlock from "@/page-components/TestimonialDisplay/AttestantBlock" @@ -231,14 +230,12 @@ const TestimonialTruncateText = styled(TruncateText)({ const SlickCarousel = () => { const { data } = useTestimonialList({ position: 1 }) const [slick, setSlick] = React.useState(null) - const [shuffled, setShuffled] = useState() - const [imageSequence, setImageSequence] = useState() - - useEffect(() => { - if (!data) return - setShuffled(shuffle(data?.results)) - setImageSequence(shuffle([1, 2, 3, 4, 5, 6])) - }, [data]) + const results = data?.results + const shuffled = useMemo( + () => (results ? shuffle(results) : undefined), + [results], + ) + const imageSequence = useMemo(() => shuffle([1, 2, 3, 4, 5, 6]), []) if (!data?.results?.length || !shuffled?.length) { return null @@ -277,7 +274,7 @@ const SlickCarousel = () => { > Math.floor(Math.random() * 5) + 1 + export async function generateMetadata({ searchParams, }: PageProps<"/">): Promise { @@ -101,9 +103,11 @@ const Page: React.FC> = async () => { ), ]) + const heroImageIndex = getRandomHeroImageIndex() + return ( - + ) } diff --git a/frontends/main/src/page-components/ItemsListing/ItemsListing.tsx b/frontends/main/src/page-components/ItemsListing/ItemsListing.tsx index 42cf2ba161..593b2fab49 100644 --- a/frontends/main/src/page-components/ItemsListing/ItemsListing.tsx +++ b/frontends/main/src/page-components/ItemsListing/ItemsListing.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from "react" +import React, { useCallback, useEffect, useRef } from "react" import type { LearningResource } from "api" import { SortableItem, @@ -56,7 +56,10 @@ const ItemsListingSortable: React.FC<{ const moveLearningPathListItem = useLearningPathListItemMove() const moveUserListListItem = useUserListListItemMove() - const [sorted, setSorted] = React.useState([]) + const prevItemsRef = useRef(undefined) + const [sorted, setSorted] = React.useState( + () => items, + ) const ListCardComponent = condensed ? LearningResourceListCardCondensed @@ -69,7 +72,15 @@ const ItemsListingSortable: React.FC<{ * - `items` is the source of truth (most likely, this is coming from an API) * so sync `items` -> `sorted` when `items` changes. */ - useEffect(() => setSorted(items), [items]) + useEffect(() => { + if (prevItemsRef.current !== items) { + prevItemsRef.current = items + // Defer setState to avoid synchronous setState in effect + queueMicrotask(() => { + setSorted(items) + }) + } + }, [items]) const renderDragging: RenderActive = useCallback( (active) => { diff --git a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx index 6d6721ec20..8cb6b14aa4 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx @@ -166,6 +166,7 @@ const LearningResourceExpanded: React.FC = ({ const titleSectionRef = useRef(null) const [titleSectionHeight, setTitleSectionHeight] = useState(0) const [scrollPosition, setScrollPosition] = useState(0) + const scrollElementRef = useRef(null) const [scrollElement, setScrollElement] = useState(null) const [chatExpanded, setChatExpanded] = useToggle(initialChatExpanded) @@ -173,12 +174,12 @@ const LearningResourceExpanded: React.FC = ({ if (outerContainerRef?.current?.scrollTo) { outerContainerRef.current.scrollTo(0, 0) } - if (scrollElement) { + if (scrollElementRef.current) { requestAnimationFrame(() => { - scrollElement.scrollTop = 0 + scrollElementRef.current!.scrollTop = 0 }) } - }, [resourceId, scrollElement]) + }, [resourceId]) useEffect(() => { const updateHeight = () => { @@ -202,20 +203,26 @@ const LearningResourceExpanded: React.FC = ({ const drawerPaper = outerContainerRef.current?.closest( ".MuiDrawer-paper", ) as HTMLElement - setScrollElement(drawerPaper) + scrollElementRef.current = drawerPaper + queueMicrotask(() => { + setScrollElement(drawerPaper) + }) } - }, [outerContainerRef]) + }, []) useEffect(() => { - if (scrollElement && chatTransitionState === ChatTransitionState.Closing) { - scrollElement.scrollTop = scrollPosition + if ( + scrollElementRef.current && + chatTransitionState === ChatTransitionState.Closing + ) { + scrollElementRef.current.scrollTop = scrollPosition } - }, [chatTransitionState, scrollElement, scrollPosition]) + }, [chatTransitionState, scrollPosition]) const onChatOpenerToggle = (open: boolean) => { if (open) { setChatTransitionState(ChatTransitionState.Opening) - setScrollPosition(scrollElement?.scrollTop ?? 0) + setScrollPosition(scrollElementRef.current?.scrollTop ?? 0) openChat() } else { setChatTransitionState(ChatTransitionState.Closing) diff --git a/frontends/main/src/page-components/LearningResourceExpanded/ResourceDescription.tsx b/frontends/main/src/page-components/LearningResourceExpanded/ResourceDescription.tsx index 56b69564dc..c26b4f339c 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/ResourceDescription.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/ResourceDescription.tsx @@ -46,7 +46,7 @@ const DescriptionExpanded = styled(Description)({ const ResourceDescription = ({ resource }: { resource?: LearningResource }) => { const firstRender = useRef(true) - const clampedOnFirstRender = useRef(false) + const [clampedOnFirstRender, setClampedOnFirstRender] = useState(false) const [isClamped, setClamped] = useState(false) const [isExpanded, setExpanded] = useState(false) const descriptionRendered = useCallback((node: HTMLDivElement) => { @@ -55,7 +55,7 @@ const ResourceDescription = ({ resource }: { resource?: LearningResource }) => { setClamped(clamped) if (firstRender.current) { firstRender.current = false - clampedOnFirstRender.current = clamped + setClampedOnFirstRender(clamped) return } } @@ -87,7 +87,7 @@ const ResourceDescription = ({ resource }: { resource?: LearningResource }) => { dangerouslySetInnerHTML={{ __html: resource.description || "" }} lang={getResourceLanguage(resource)} /> - {(isClamped || clampedOnFirstRender.current) && ( + {(isClamped || clampedOnFirstRender) && ( { diff --git a/frontends/main/src/page-components/ResourceCard/ResourceCard.tsx b/frontends/main/src/page-components/ResourceCard/ResourceCard.tsx index fa45036067..9b18bef2d5 100644 --- a/frontends/main/src/page-components/ResourceCard/ResourceCard.tsx +++ b/frontends/main/src/page-components/ResourceCard/ResourceCard.tsx @@ -25,31 +25,36 @@ export const useResourceCard = (resource?: LearningResource | null) => { const handleClosePopover = useCallback(() => { setAnchorEl(null) }, []) + const handleAddToLearningPathClickCallback = useCallback( + (event: React.MouseEvent, resourceId: number) => { + NiceModal.show(AddToLearningPathDialog, { resourceId }) + }, + [], + ) const handleAddToLearningPathClick: LearningResourceCardProps["onAddToLearningPathClick"] = - useMemo(() => { - if (user?.is_authenticated && user?.is_learning_path_editor) { - return (event, resourceId: number) => { - NiceModal.show(AddToLearningPathDialog, { resourceId }) - } - } - return null - }, [user]) + useMemo( + () => + user?.is_authenticated && user?.is_learning_path_editor + ? handleAddToLearningPathClickCallback + : null, + [user, handleAddToLearningPathClickCallback], + ) const handleAddToUserListClick: LearningResourceCardProps["onAddToUserListClick"] = - useMemo(() => { - if (!user) { - // user info is still loading - return null - } - if (user.is_authenticated) { - return (event, resourceId: number) => { - NiceModal.show(AddToUserListDialog, { resourceId }) + useCallback( + (event: React.MouseEvent, resourceId?: number) => { + if (!user) { + // user info is still loading + return + } + if (user.is_authenticated) { + NiceModal.show(AddToUserListDialog, { resourceId: resourceId! }) + } else { + setAnchorEl(event.currentTarget as HTMLElement) } - } - return (event) => { - setAnchorEl(event.currentTarget) - } - }, [user]) + }, + [user], + ) const onClick = useLearningResourceDetailSetCache(resource) diff --git a/frontends/main/src/page-components/TiptapEditor/useSchema.ts b/frontends/main/src/page-components/TiptapEditor/useSchema.ts index 0bc15cb6e2..8fbf008ab0 100644 --- a/frontends/main/src/page-components/TiptapEditor/useSchema.ts +++ b/frontends/main/src/page-components/TiptapEditor/useSchema.ts @@ -25,7 +25,9 @@ export const useSchema = ({ const docType = schema.nodes.doc if (!docType) { - setSchemaError("Document node type not found in schema") + queueMicrotask(() => { + setSchemaError("Document node type not found in schema") + }) return } @@ -41,32 +43,40 @@ export const useSchema = ({ if (typeof nodeTypeName !== "string") { const errorMessage = "Invalid content for node doc: node type must be a string" - setSchemaError(`Document schema check failed: ${errorMessage}`) + queueMicrotask(() => { + setSchemaError(`Document schema check failed: ${errorMessage}`) + }) return } const nodeType = schema.nodes[nodeTypeName] if (!nodeType) { const errorMessage = `Invalid content for node doc: node type "${nodeTypeName}" not found in schema` - setSchemaError(`Document schema check failed: ${errorMessage}`) + queueMicrotask(() => { + setSchemaError(`Document schema check failed: ${errorMessage}`) + }) return } const nextMatch = match.matchType(nodeType) if (!nextMatch) { - setSchemaError( - `Document schema check failed: Invalid content for node doc: ${jsonNode.type} is not allowed in this position`, - ) + queueMicrotask(() => { + setSchemaError( + `Document schema check failed: Invalid content for node doc: ${jsonNode.type} is not allowed in this position`, + ) + }) return } match = nextMatch } if (!match.validEnd) { - setSchemaError( - "Document schema check failed: Invalid content for node doc: content specification not satisfied", - ) + queueMicrotask(() => { + setSchemaError( + "Document schema check failed: Invalid content for node doc: content specification not satisfied", + ) + }) return } @@ -74,18 +84,24 @@ export const useSchema = ({ try { contentNode = Node.fromJSON(schema, content) } catch (parseError) { - setSchemaError( - `Failed to parse content: ${parseError instanceof Error ? parseError.message : String(parseError)}`, - ) + queueMicrotask(() => { + setSchemaError( + `Failed to parse content: ${parseError instanceof Error ? parseError.message : String(parseError)}`, + ) + }) return } try { contentNode.check() - setSchemaError(null) + queueMicrotask(() => { + setSchemaError(null) + }) } catch (error) { const errorMessage = `Document schema check failed: ${error instanceof Error ? error.message : String(error)}` - setSchemaError(errorMessage) + queueMicrotask(() => { + setSchemaError(errorMessage) + }) } }, [schema, content, enabled]) diff --git a/frontends/ol-components/src/components/Dialog/Dialog.stories.tsx b/frontends/ol-components/src/components/Dialog/Dialog.stories.tsx index ae2343c9ac..c0d4ff3781 100644 --- a/frontends/ol-components/src/components/Dialog/Dialog.stories.tsx +++ b/frontends/ol-components/src/components/Dialog/Dialog.stories.tsx @@ -7,7 +7,9 @@ const StateWrapper = (props: DialogProps) => { const [open, setOpen] = useState(props.open) useEffect(() => { - setOpen(props.open) + queueMicrotask(() => { + setOpen(props.open) + }) }, [props.open]) const close = () => { diff --git a/frontends/ol-components/src/components/EmbedlyCard/EmbedlyCard.tsx b/frontends/ol-components/src/components/EmbedlyCard/EmbedlyCard.tsx index 6064544f4c..98143b14f7 100644 --- a/frontends/ol-components/src/components/EmbedlyCard/EmbedlyCard.tsx +++ b/frontends/ol-components/src/components/EmbedlyCard/EmbedlyCard.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from "react" +import React, { useCallback, useEffect, useRef } from "react" import styled from "@emotion/styled" import isURL from "validator/lib/isURL" import { @@ -54,12 +54,12 @@ const EmbedlyCard: React.FC = ({ url, aspectRatio, }) => { - const [container, setContainer] = useState(null) + const containerRef = useRef(null) const renderCard = useCallback((div: HTMLElement | null) => { if (!div) return div.addEventListener(EmbedlyEventTypes.CardCreated, insertCardStylesheet) - setContainer(div) + containerRef.current = div }, []) useEffect(() => { @@ -79,8 +79,8 @@ const EmbedlyCard: React.FC = ({ * to integrate with React: When the URL changes, we need to manually remove * the IFrame and insert a new anchor, which Embedly can then manipulate. */ - if (!container) return - container.innerHTML = "" + if (!containerRef.current) return + containerRef.current.innerHTML = "" if (!isURL(url)) return const a = document.createElement("a") a.dataset.cardChrome = "0" @@ -89,8 +89,8 @@ const EmbedlyCard: React.FC = ({ a.href = url a.classList.add("embedly-card") a.dataset["testid"] = "embedly-card" - container.appendChild(a) - }, [container, url]) + containerRef.current.appendChild(a) + }, [url]) return ( { const [open, setOpen] = useToggle(false) @@ -44,11 +49,6 @@ const NavDrawerDemo = () => { ], } - const StyledButton = styled(MuiButton)({ - position: "absolute", - right: "20px", - }) - return (
diff --git a/frontends/ol-utilities/src/hooks/useThrottle.tsx b/frontends/ol-utilities/src/hooks/useThrottle.tsx index 72f2de2437..8e1387105f 100644 --- a/frontends/ol-utilities/src/hooks/useThrottle.tsx +++ b/frontends/ol-utilities/src/hooks/useThrottle.tsx @@ -6,13 +6,14 @@ export const useThrottle = void>( callback: T, delay: number, ) => { - const lastRun = useRef(Date.now()) + const lastRun = useRef(null) return useCallback( (...args: Parameters) => { - if (Date.now() - lastRun.current >= delay) { + const now = Date.now() + if (lastRun.current === null || now - lastRun.current >= delay) { callback(...args) - lastRun.current = Date.now() + lastRun.current = now } }, [callback, delay], diff --git a/frontends/ol-utilities/src/ssr/NoSSR.tsx b/frontends/ol-utilities/src/ssr/NoSSR.tsx index a6aa80d660..21f45bff4f 100644 --- a/frontends/ol-utilities/src/ssr/NoSSR.tsx +++ b/frontends/ol-utilities/src/ssr/NoSSR.tsx @@ -1,6 +1,6 @@ "use client" -import React, { useState, useEffect, ReactNode } from "react" +import React, { useState, useEffect, ReactNode, startTransition } from "react" type NoSSRProps = { children: ReactNode @@ -11,7 +11,9 @@ export const NoSSR: React.FC = ({ children, onSSR = null }) => { const [isClient, setClient] = useState(false) useEffect(() => { - setClient(true) + startTransition(() => { + setClient(true) + }) }, []) return isClient ? children : onSSR diff --git a/frontends/package.json b/frontends/package.json index 4824764354..d337b69ac9 100644 --- a/frontends/package.json +++ b/frontends/package.json @@ -54,7 +54,7 @@ "eslint-plugin-jest": "^29.0.0", "eslint-plugin-mdx": "^3.0.0", "eslint-plugin-react": "^7.34.3", - "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-hooks": "^7.0.0", "eslint-plugin-styled-components-a11y": "^2.1.35", "eslint-plugin-testing-library": "^7.0.0", "jest": "^29.7.0", diff --git a/yarn.lock b/yarn.lock index c6ce6d1228..e3214c2a1b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12101,15 +12101,6 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-react-hooks@npm:^5.0.0": - version: 5.1.0 - resolution: "eslint-plugin-react-hooks@npm:5.1.0" - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - checksum: 10/b6778fd9e1940b06868921309e8b269426e17eda555816d4b71def4dcf0572de1199fdb627ac09ce42160b9569a93cd9b0fd81b740ab4df98205461c53997a43 - languageName: node - linkType: hard - "eslint-plugin-react-hooks@npm:^7.0.0": version: 7.0.1 resolution: "eslint-plugin-react-hooks@npm:7.0.1" @@ -12869,7 +12860,7 @@ __metadata: eslint-plugin-jest: "npm:^29.0.0" eslint-plugin-mdx: "npm:^3.0.0" eslint-plugin-react: "npm:^7.34.3" - eslint-plugin-react-hooks: "npm:^5.0.0" + eslint-plugin-react-hooks: "npm:^7.0.0" eslint-plugin-styled-components-a11y: "npm:^2.1.35" eslint-plugin-testing-library: "npm:^7.0.0" jest: "npm:^29.7.0"