From 0934ceb0ee57c2e380880a09bf18b1a1c6ce10bc Mon Sep 17 00:00:00 2001 From: Andrew Wiggin Date: Sun, 22 Jan 2023 18:37:16 -0500 Subject: [PATCH 1/2] Convert exports to server components - `ScriptElement` renders the script only if it has not been rendered onto the DOM already. It does this by using the prop `isGlobal` and a serverside variable. This removes the need for React Context entirely - `Balancer` is now partially serverside, rendering its script and `ClientBalancer`. Any code that doesn't explicitly have to run on the client was moved to the server --- src/client.tsx | 155 +++++++++++++++++++++++++++ src/constants.ts | 2 + src/index.tsx | 267 ++++++++++++----------------------------------- 3 files changed, 226 insertions(+), 198 deletions(-) create mode 100644 src/client.tsx create mode 100644 src/constants.ts diff --git a/src/client.tsx b/src/client.tsx new file mode 100644 index 0000000..3472b59 --- /dev/null +++ b/src/client.tsx @@ -0,0 +1,155 @@ +'use client' + +import React from 'react' +import {SYMBOL_KEY, SYMBOL_OBSERVER_KEY} from './constants' +import type {BalancerProps} from "./index"; + +declare global { + interface Window { + [SYMBOL_KEY]: RelayoutFn + } +} + +interface WrapperElement extends HTMLElement { + [SYMBOL_OBSERVER_KEY]?: ResizeObserver | undefined +} + +type RelayoutFn = ( + id: string | number, + ratio: number, + wrapper?: WrapperElement +) => void + +const relayout: RelayoutFn = (id, ratio, wrapper) => { + wrapper = + wrapper || document.querySelector(`[data-br="${id}"]`) + const container = wrapper.parentElement + + const update = (width: number) => (wrapper.style.maxWidth = width + 'px') + + // Reset wrapper width + wrapper.style.maxWidth = '' + + // Get the initial container size + const width = container.clientWidth + const height = container.clientHeight + + // Synchronously do binary search and calculate the layout + let lower: number = width / 2 - 0.25 + let upper: number = width + 0.5 + let middle: number + + if (width) { + while (lower + 1 < upper) { + middle = Math.round((lower + upper) / 2) + update(middle) + if (container.clientHeight === height) { + upper = middle + } else { + lower = middle + } + } + + // Update the wrapper width + update(upper * ratio + width * (1 - ratio)) + } + + // Create a new observer if we don't have one. + // Note that we must inline the key here as we use `toString()` to serialize + // the function. + if (!wrapper['__wrap_o']) { + ;(wrapper['__wrap_o'] = new ResizeObserver(() => { + self.__wrap_b(0, +wrapper.dataset.brr, wrapper) + })).observe(container) + } +} + +export const RELAYOUT_STR = relayout.toString() + +const IS_SERVER = typeof window === 'undefined' +const useIsomorphicLayoutEffect = IS_SERVER + ? React.useEffect + : React.useLayoutEffect + +interface ClientBalancerProps extends BalancerProps { + id: string + // `as` and `ratio` are required in the client component + as: BalancerProps['as'], + ratio: BalancerProps['ratio'], +} +export const ClientBalancer: React.FC = ({ + id, + as: Wrapper, + ratio, + children, + ...props + }) => { + const wrapperRef = React.useRef() + + // Re-balance on content change and on mount/hydration. + useIsomorphicLayoutEffect(() => { + if (wrapperRef.current) { + // Re-assign the function here as the component can be dynamically rendered, and script tag won't work in that case. + ;(self[SYMBOL_KEY] = relayout)(0, ratio, wrapperRef.current) + } + }, [children, ratio]) + + // Remove the observer when unmounting. + useIsomorphicLayoutEffect(() => { + return () => { + if (!wrapperRef.current) return + + const resizeObserver = wrapperRef.current[SYMBOL_OBSERVER_KEY] + if (!resizeObserver) return + + resizeObserver.disconnect() + delete wrapperRef.current[SYMBOL_OBSERVER_KEY] + } + }, []) + + return ( + <> + + {children} + + + ) +} + +// As Next.js adds `display: none` to `body` for development, we need to trigger +// a re-balance right after the style is removed, synchronously. +if (!IS_SERVER && process.env.NODE_ENV !== 'production') { + const next_dev_style = document.querySelector( + '[data-next-hide-fouc]' + ) + if (next_dev_style) { + const callback: MutationCallback = (mutationList) => { + for (const mutation of mutationList) { + for (const node of Array.from(mutation.removedNodes)) { + if (node !== next_dev_style) continue + + observer.disconnect() + const elements = + document.querySelectorAll('[data-br]') + + for (const element of Array.from(elements)) { + self[SYMBOL_KEY](0, +element.dataset.brr, element) + } + } + } + } + const observer = new MutationObserver(callback) + observer.observe(document.head, {childList: true}) + } +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..914d068 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,2 @@ +export const SYMBOL_KEY = '__wrap_b' +export const SYMBOL_OBSERVER_KEY = '__wrap_o' diff --git a/src/index.tsx b/src/index.tsx index 8201702..006d73a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,218 +1,89 @@ -'use client' - import React from 'react' - -const SYMBOL_KEY = '__wrap_b' -const SYMBOL_OBSERVER_KEY = '__wrap_o' -const IS_SERVER = typeof window === 'undefined' -const useIsomorphicLayoutEffect = IS_SERVER - ? React.useEffect - : React.useLayoutEffect - -interface WrapperElement extends HTMLElement { - [SYMBOL_OBSERVER_KEY]?: ResizeObserver | undefined -} - -type RelayoutFn = ( - id: string | number, - ratio: number, - wrapper?: WrapperElement -) => void - -declare global { - interface Window { - [SYMBOL_KEY]: RelayoutFn - } -} - -const relayout: RelayoutFn = (id, ratio, wrapper) => { - wrapper = - wrapper || document.querySelector(`[data-br="${id}"]`) - const container = wrapper.parentElement - - const update = (width: number) => (wrapper.style.maxWidth = width + 'px') - - // Reset wrapper width - wrapper.style.maxWidth = '' - - // Get the initial container size - const width = container.clientWidth - const height = container.clientHeight - - // Synchronously do binary search and calculate the layout - let lower: number = width / 2 - 0.25 - let upper: number = width + 0.5 - let middle: number - - if (width) { - while (lower + 1 < upper) { - middle = Math.round((lower + upper) / 2) - update(middle) - if (container.clientHeight === height) { - upper = middle - } else { - lower = middle - } +import {ClientBalancer, RELAYOUT_STR} from './client' +import {SYMBOL_KEY} from './constants' + +let hasGlobalRelayoutScript = false + +const RelayoutScript: React.FC<{ isGlobal?: boolean, suffix?: string }> = ({isGlobal, suffix = ''}) => { + const relayoutScript = ( +