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

React 18 Support: Cleanup DOM during hydration to avoid mismatch #341

Merged
merged 2 commits into from
Nov 2, 2023
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
58 changes: 28 additions & 30 deletions examples/kitchen-sink/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,44 +94,42 @@ export const App: React.FunctionComponent = () => (
* rendered.
*/}
<Media lessThan="sm">
{className => (
{(className, renderChildren) => (
<li className={className} style={ExtraSmallStyle}>
xs
{renderChildren ? `xs` : null}
</li>
)}
</Media>
<Media between={["sm", "lg"]}>
{(className, renderChildren) =>
renderChildren && (
<>
<li
className={className}
style={{
...SmallStyle,
height: "100px",
lineHeight: "100px",
}}
>
sm
</li>
<li
className={className}
style={{
...MediumStyle,
height: "100px",
lineHeight: "100px",
}}
>
md
</li>
</>
)
}
{(className, renderChildren) => (
<>
<li
className={className}
style={{
...SmallStyle,
height: "100px",
lineHeight: "100px",
}}
>
{renderChildren ? `sm` : null}
</li>
<li
className={className}
style={{
...MediumStyle,
height: "100px",
lineHeight: "100px",
}}
>
{renderChildren ? `md` : null}
</li>
</>
)}
</Media>
<Media greaterThanOrEqual="lg">
{className => (
{(className, renderChildren) => (
<li className={className} style={LargeStyle}>
lg
{renderChildren ? `lg` : null}
</li>
)}
</Media>
Expand Down
11 changes: 7 additions & 4 deletions examples/kitchen-sink/src/client.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
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(<App />, document.getElementById("react-root"))
root = ReactDOM.hydrateRoot(document.getElementById("react-root"), <App />)
} else {
// client-side only
const style = document.createElement("style")
style.type = "text/css"
style.id = SSRStyleID
style.innerText = mediaStyle
document.getElementsByTagName("head")[0].appendChild(style)
ReactDOM.render(<App />, document.getElementById("react-root"))

root = ReactDOM.createRoot(document.getElementById("react-root"))
root.render(<App />)
}
1 change: 0 additions & 1 deletion examples/kitchen-sink/src/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ app.get("/rehydration", (req, res) => {
<div id="react-root">
${ReactDOMServer.renderToString(
<MediaContextProvider
disableDynamicMediaQueries
onlyMatch={onlyMatchListForUserAgent(req.header(
"User-Agent"
) as string)}
Expand Down
2 changes: 1 addition & 1 deletion examples/nextjs/src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Media, MediaContextProvider } from "../media"

export default function HomePage() {
return (
<MediaContextProvider disableDynamicMediaQueries>
<MediaContextProvider>
<Media at="xs">Hello mobile!</Media>
<Media greaterThan="xs">Hello desktop!</Media>
</MediaContextProvider>
Expand Down
2 changes: 1 addition & 1 deletion examples/ssr-rendering/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Media, MediaContextProvider } from "./Media"

export const App = () => {
return (
<MediaContextProvider disableDynamicMediaQueries>
<MediaContextProvider>
<Media interaction="landscape">landscape</Media>
<Media interaction="portrait">portrait</Media>

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
229 changes: 112 additions & 117 deletions src/Media.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
createClassName,
castBreakpointsToIntegers,
memoize,
useIsFirstRender,
} from "./Utils"
import { BreakpointConstraint } from "./Breakpoints"

Expand Down Expand Up @@ -340,10 +341,15 @@ export function createMedia<
>({})
MediaContext.displayName = "Media.Context"

const MediaParentContext = React.createContext<{
type MediaParentContextValue = {
hasParentMedia: boolean
breakpointProps: MediaBreakpointProps<BreakpointKey>
}>({ hasParentMedia: false, breakpointProps: {} })
}

const MediaParentContext = React.createContext<MediaParentContextValue>({
hasParentMedia: false,
breakpointProps: {},
})
MediaContext.displayName = "MediaParent.Context"

const getMediaContextValue = memoize(onlyMatch => ({
Expand Down Expand Up @@ -394,128 +400,117 @@ export function createMedia<
}
}

const Media = class extends React.Component<
MediaProps<BreakpointKey, Interaction>
> {
constructor(props) {
super(props)
validateProps(props)
}
const Media = (props: MediaProps<BreakpointKey, Interaction>) => {
validateProps(props)

const {
children,
className: passedClassName,
style,
interaction,
...breakpointProps
} = props

const getMediaParentContextValue = React.useMemo(() => {
return memoize(
(newBreakpointProps: MediaBreakpointProps<BreakpointKey>) => ({
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<BreakpointKey>) => ({
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 `<Media greaterThanOrEqual=" +
`"${largestBreakpoint}">\` 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(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
*
* 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 containerEls = document.getElementsByClassName(uniqueComponentId)
Array.from(containerEls).forEach(el => (el.innerHTML = ""))
Copy link
Member

Choose a reason for hiding this comment

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

Simple, easy to understand javascript saves the day again. It is almost ironic.

}

return (
<MediaParentContext.Consumer>
{mediaParentContext => {
return (
<MediaParentContext.Provider value={childMediaParentContext}>
{(() => {
if (props.children instanceof Function) {
return props.children(className, renderChildren)
} else {
return (
<MediaParentContext.Provider value={mediaParentContextValue}>
<MediaContext.Consumer>
{({ 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 `<Media greaterThanOrEqual=" +
`"${largestBreakpoint}">\` 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 (
<div
className={`fresnel-container ${className} ${passedClassName}`}
style={style}
suppressHydrationWarning={!renderChildren}
>
{renderChildren ? props.children : null}
</div>
)
}
}}
</MediaContext.Consumer>
</MediaParentContext.Provider>
<div
className={`fresnel-container ${className} ${passedClassName}`}
style={style}
suppressHydrationWarning={!renderChildren}
>
{renderChildren ? props.children : null}
</div>
Copy link
Member

Choose a reason for hiding this comment

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

👌 Much less code

)
}}
</MediaParentContext.Consumer>
)
}
}
})()}
</MediaParentContext.Provider>
)
}

return {
Expand Down
Loading