From ea4a15940418f118e27d618735761d36aa302fd8 Mon Sep 17 00:00:00 2001 From: Max Greenwald Date: Thu, 2 Nov 2023 07:30:58 -0400 Subject: [PATCH 1/2] Update Media to support dom cleanup before hydration --- examples/kitchen-sink/src/server.tsx | 1 - examples/nextjs/src/pages/index.tsx | 2 +- examples/ssr-rendering/src/App.tsx | 2 +- package.json | 2 +- src/Media.tsx | 229 +++++++++++++-------------- src/Utils.ts | 15 ++ 6 files changed, 130 insertions(+), 121 deletions(-) diff --git a/examples/kitchen-sink/src/server.tsx b/examples/kitchen-sink/src/server.tsx index d7ee1a58..55cb35f8 100644 --- a/examples/kitchen-sink/src/server.tsx +++ b/examples/kitchen-sink/src/server.tsx @@ -81,7 +81,6 @@ app.get("/rehydration", (req, res) => {
${ReactDOMServer.renderToString( + Hello mobile! Hello desktop! diff --git a/examples/ssr-rendering/src/App.tsx b/examples/ssr-rendering/src/App.tsx index c3260105..4ca02b14 100644 --- a/examples/ssr-rendering/src/App.tsx +++ b/examples/ssr-rendering/src/App.tsx @@ -4,7 +4,7 @@ import { Media, MediaContextProvider } from "./Media" export const App = () => { return ( - + landscape portrait diff --git a/package.json b/package.json index 88c45d9b..e4ee4aac 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ }, "homepage": "https://github.com/artsy/fresnel#readme", "peerDependencies": { - "react": ">=16.3.0" + "react": ">=18.0.0" }, "devDependencies": { "@artsy/auto-config": "1.2.0", diff --git a/src/Media.tsx b/src/Media.tsx index 2b83092f..066f286b 100644 --- a/src/Media.tsx +++ b/src/Media.tsx @@ -9,6 +9,7 @@ import { createClassName, castBreakpointsToIntegers, memoize, + useIsFirstRender, } from "./Utils" import { BreakpointConstraint } from "./Breakpoints" @@ -340,10 +341,15 @@ export function createMedia< >({}) MediaContext.displayName = "Media.Context" - const MediaParentContext = React.createContext<{ + type MediaParentContextValue = { hasParentMedia: boolean breakpointProps: MediaBreakpointProps - }>({ hasParentMedia: false, breakpointProps: {} }) + } + + const MediaParentContext = React.createContext({ + hasParentMedia: false, + breakpointProps: {}, + }) MediaContext.displayName = "MediaParent.Context" const getMediaContextValue = memoize(onlyMatch => ({ @@ -394,128 +400,117 @@ export function createMedia< } } - const Media = class extends React.Component< - MediaProps - > { - constructor(props) { - super(props) - validateProps(props) - } + const Media = (props: MediaProps) => { + validateProps(props) + + const { + children, + className: passedClassName, + style, + interaction, + ...breakpointProps + } = props + + const getMediaParentContextValue = React.useMemo(() => { + return memoize( + (newBreakpointProps: MediaBreakpointProps) => ({ + hasParentMedia: true, + breakpointProps: newBreakpointProps, + }) + ) + }, []) - static defaultProps = { - className: "", - style: {}, - } + const mediaParentContext = React.useContext(MediaParentContext) + const childMediaParentContext = getMediaParentContextValue(breakpointProps) + const { onlyMatch } = React.useContext(MediaContext) - static contextType = MediaParentContext + const id = React.useId() + const isClient = typeof window !== "undefined" + const isFirstRender = useIsFirstRender() - getMediaParentContextValue = memoize( - (breakpointProps: MediaBreakpointProps) => ({ - hasParentMedia: true, - breakpointProps, - }) - ) + let className: string | null + if (props.interaction) { + className = createClassName("interaction", props.interaction) + } else { + if (props.at) { + const largestBreakpoint = mediaQueries.breakpoints.largestBreakpoint + if (props.at === largestBreakpoint) { + console.warn( + "[@artsy/fresnel] " + + "`at` is being used with the largest breakpoint. " + + "Consider using `\` to account for future ` + + `breakpoint definitions outside of this range.` + ) + } + } + + const type = propKey(breakpointProps) + const breakpoint = breakpointProps[type]! + className = createClassName(type, breakpoint) + } - render() { - const props = this.props - const { - children, - className: passedClassName, - style, - interaction, - ...breakpointProps - } = props - const mediaParentContextValue = this.getMediaParentContextValue( - breakpointProps - ) + const doesMatchParent = + !mediaParentContext.hasParentMedia || + intersection( + mediaQueries.breakpoints.toVisibleAtBreakpointSet( + mediaParentContext.breakpointProps + ), + mediaQueries.breakpoints.toVisibleAtBreakpointSet(breakpointProps) + ).length > 0 + + const renderChildren = + doesMatchParent && + (onlyMatch === undefined || + mediaQueries.shouldRenderMediaQuery( + { ...breakpointProps, interaction }, + onlyMatch + )) + + // Append a unique id to the className (consistent on server and client) + const uniqueComponentId = ` fresnel-${id}` + className += uniqueComponentId + + /** + * SPECIAL CASE: + * If we're on the client, this is the first render, and we are not going + * to render the children, we need to cleanup the the server-rendered HTML + * to avoid a hydration mismatch on React 18+. We do this by grabbing the + * already-existing element directly from the DOM using the unique class id + * and clearing its contents. This solution follows one of the suggestions + * from Dan Abromov here: + * + * https://github.com/facebook/react/issues/23381#issuecomment-1096899474 + * + * This will not have a negative impact on client-only rendering because + * either 1) isFirstRender will be false OR 2) the element won't exist yet + * so there will be nothing to clean up. It will only apply on SSR'd HTML + * on initial hydration. + */ + if (isClient && isFirstRender && !renderChildren) { + const containerEl = document.getElementsByClassName(uniqueComponentId)[0] + if (!!containerEl) containerEl.innerHTML = "" + } - return ( - - {mediaParentContext => { + return ( + + {(() => { + if (props.children instanceof Function) { + return props.children(className, renderChildren) + } else { return ( - - - {({ onlyMatch } = {}) => { - let className: string | null - if (props.interaction) { - className = createClassName( - "interaction", - props.interaction - ) - } else { - if (props.at) { - const largestBreakpoint = - mediaQueries.breakpoints.largestBreakpoint - if (props.at === largestBreakpoint) { - // TODO: We should look into making React’s __DEV__ available - // and have webpack completely compile these away. - let ownerName = null - try { - const owner = (this as any)._reactInternalFiber - ._debugOwner.type - ownerName = owner.displayName || owner.name - } catch (err) { - // no-op - } - - console.warn( - "[@artsy/fresnel] " + - "`at` is being used with the largest breakpoint. " + - "Consider using `\` to account for future ` + - `breakpoint definitions outside of this range.${ - ownerName - ? ` It is being used in the ${ownerName} component.` - : "" - }` - ) - } - } - - const type = propKey(breakpointProps) - const breakpoint = breakpointProps[type]! - className = createClassName(type, breakpoint) - } - - const doesMatchParent = - !mediaParentContext.hasParentMedia || - intersection( - mediaQueries.breakpoints.toVisibleAtBreakpointSet( - mediaParentContext.breakpointProps - ), - mediaQueries.breakpoints.toVisibleAtBreakpointSet( - breakpointProps - ) - ).length > 0 - const renderChildren = - doesMatchParent && - (onlyMatch === undefined || - mediaQueries.shouldRenderMediaQuery( - { ...breakpointProps, interaction }, - onlyMatch - )) - - if (props.children instanceof Function) { - return props.children(className, renderChildren) - } else { - return ( -
- {renderChildren ? props.children : null} -
- ) - } - }} -
-
+
+ {renderChildren ? props.children : null} +
) - }} -
- ) - } + } + })()} + + ) } return { diff --git a/src/Utils.ts b/src/Utils.ts index f548d2ae..9cfaf7e1 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -1,5 +1,6 @@ import { MediaBreakpointProps } from "./Media" import { BreakpointConstraintKey } from "./Breakpoints" +import { useRef } from "react" /** * Extracts the single breakpoint prop from the props object. @@ -74,3 +75,17 @@ export function memoize void>(func: F) { return results[argsKey] } } + +/** + * Hook to determine if the current render is the first render. + */ +export function useIsFirstRender(): boolean { + const isFirst = useRef(true) + + if (isFirst.current) { + isFirst.current = false + return true + } else { + return false + } +} From d8c26fbecbd533a3232ae39dcf5add88a2174325 Mon Sep 17 00:00:00 2001 From: Max Greenwald Date: Thu, 2 Nov 2023 08:44:42 -0400 Subject: [PATCH 2/2] Fix for multiple child elements --- examples/kitchen-sink/src/App.tsx | 58 ++++++++++++++-------------- examples/kitchen-sink/src/client.tsx | 11 ++++-- src/Media.tsx | 10 ++--- 3 files changed, 40 insertions(+), 39 deletions(-) diff --git a/examples/kitchen-sink/src/App.tsx b/examples/kitchen-sink/src/App.tsx index c9664896..754ebc20 100644 --- a/examples/kitchen-sink/src/App.tsx +++ b/examples/kitchen-sink/src/App.tsx @@ -94,44 +94,42 @@ export const App: React.FunctionComponent = () => ( * rendered. */} - {className => ( + {(className, renderChildren) => (
  • - xs + {renderChildren ? `xs` : null}
  • )}
    - {(className, renderChildren) => - renderChildren && ( - <> -
  • - sm -
  • -
  • - md -
  • - - ) - } + {(className, renderChildren) => ( + <> +
  • + {renderChildren ? `sm` : null} +
  • +
  • + {renderChildren ? `md` : null} +
  • + + )}
    - {className => ( + {(className, renderChildren) => (
  • - lg + {renderChildren ? `lg` : null}
  • )}
    diff --git a/examples/kitchen-sink/src/client.tsx b/examples/kitchen-sink/src/client.tsx index cb0918b7..edb8724e 100644 --- a/examples/kitchen-sink/src/client.tsx +++ b/examples/kitchen-sink/src/client.tsx @@ -1,11 +1,12 @@ import React from "react" -import ReactDOM from "react-dom" +import ReactDOM from "react-dom/client" import { App } from "./app" import { SSRStyleID, mediaStyle } from "./Media" +let root + if (document.getElementById(SSRStyleID)) { - // rehydration - ReactDOM.hydrate(, document.getElementById("react-root")) + root = ReactDOM.hydrateRoot(document.getElementById("react-root"), ) } else { // client-side only const style = document.createElement("style") @@ -13,5 +14,7 @@ if (document.getElementById(SSRStyleID)) { style.id = SSRStyleID style.innerText = mediaStyle document.getElementsByTagName("head")[0].appendChild(style) - ReactDOM.render(, document.getElementById("react-root")) + + root = ReactDOM.createRoot(document.getElementById("react-root")) + root.render() } diff --git a/src/Media.tsx b/src/Media.tsx index 066f286b..570df108 100644 --- a/src/Media.tsx +++ b/src/Media.tsx @@ -476,9 +476,9 @@ export function createMedia< * If we're on the client, this is the first render, and we are not going * to render the children, we need to cleanup the the server-rendered HTML * to avoid a hydration mismatch on React 18+. We do this by grabbing the - * already-existing element directly from the DOM using the unique class id - * and clearing its contents. This solution follows one of the suggestions - * from Dan Abromov here: + * already-existing element(s) directly from the DOM using the unique class + * id and clearing its contents. This solution follows one of the + * suggestions from Dan Abromov here: * * https://github.com/facebook/react/issues/23381#issuecomment-1096899474 * @@ -488,8 +488,8 @@ export function createMedia< * on initial hydration. */ if (isClient && isFirstRender && !renderChildren) { - const containerEl = document.getElementsByClassName(uniqueComponentId)[0] - if (!!containerEl) containerEl.innerHTML = "" + const containerEls = document.getElementsByClassName(uniqueComponentId) + Array.from(containerEls).forEach(el => (el.innerHTML = "")) } return (