Skip to content

Commit 9c63222

Browse files
author
Andrew Wiggin
committed
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
1 parent f75cdef commit 9c63222

File tree

3 files changed

+226
-198
lines changed

3 files changed

+226
-198
lines changed

src/client.tsx

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
'use client'
2+
3+
import React from 'react'
4+
import {SYMBOL_KEY, SYMBOL_OBSERVER_KEY} from './constants'
5+
import type {BalancerProps} from "./index";
6+
7+
declare global {
8+
interface Window {
9+
[SYMBOL_KEY]: RelayoutFn
10+
}
11+
}
12+
13+
export interface WrapperElement extends HTMLElement {
14+
[SYMBOL_OBSERVER_KEY]?: ResizeObserver | undefined
15+
}
16+
17+
type RelayoutFn = (
18+
id: string | number,
19+
ratio: number,
20+
wrapper?: WrapperElement
21+
) => void
22+
23+
export const relayout: RelayoutFn = (id, ratio, wrapper) => {
24+
wrapper =
25+
wrapper || document.querySelector<WrapperElement>(`[data-br="${id}"]`)
26+
const container = wrapper.parentElement
27+
28+
const update = (width: number) => (wrapper.style.maxWidth = width + 'px')
29+
30+
// Reset wrapper width
31+
wrapper.style.maxWidth = ''
32+
33+
// Get the initial container size
34+
const width = container.clientWidth
35+
const height = container.clientHeight
36+
37+
// Synchronously do binary search and calculate the layout
38+
let lower: number = width / 2 - 0.25
39+
let upper: number = width + 0.5
40+
let middle: number
41+
42+
if (width) {
43+
while (lower + 1 < upper) {
44+
middle = Math.round((lower + upper) / 2)
45+
update(middle)
46+
if (container.clientHeight === height) {
47+
upper = middle
48+
} else {
49+
lower = middle
50+
}
51+
}
52+
53+
// Update the wrapper width
54+
update(upper * ratio + width * (1 - ratio))
55+
}
56+
57+
// Create a new observer if we don't have one.
58+
// Note that we must inline the key here as we use `toString()` to serialize
59+
// the function.
60+
if (!wrapper['__wrap_o']) {
61+
;(wrapper['__wrap_o'] = new ResizeObserver(() => {
62+
self.__wrap_b(0, +wrapper.dataset.brr, wrapper)
63+
})).observe(container)
64+
}
65+
}
66+
67+
export const RELAYOUT_STR = relayout.toString()
68+
69+
const IS_SERVER = typeof window === 'undefined'
70+
const useIsomorphicLayoutEffect = IS_SERVER
71+
? React.useEffect
72+
: React.useLayoutEffect
73+
74+
interface ClientBalancerProps extends BalancerProps {
75+
id: string
76+
// `as` and `ratio` are required in the client component
77+
as: BalancerProps['as'],
78+
ratio: BalancerProps['ratio'],
79+
}
80+
export const ClientBalancer: React.FC<ClientBalancerProps> = ({
81+
id,
82+
as: Wrapper,
83+
ratio,
84+
children,
85+
...props
86+
}) => {
87+
const wrapperRef = React.useRef<WrapperElement>()
88+
89+
// Re-balance on content change and on mount/hydration.
90+
useIsomorphicLayoutEffect(() => {
91+
if (wrapperRef.current) {
92+
// Re-assign the function here as the component can be dynamically rendered, and script tag won't work in that case.
93+
;(self[SYMBOL_KEY] = relayout)(0, ratio, wrapperRef.current)
94+
}
95+
}, [children, ratio])
96+
97+
// Remove the observer when unmounting.
98+
useIsomorphicLayoutEffect(() => {
99+
return () => {
100+
if (!wrapperRef.current) return
101+
102+
const resizeObserver = wrapperRef.current[SYMBOL_OBSERVER_KEY]
103+
if (!resizeObserver) return
104+
105+
resizeObserver.disconnect()
106+
delete wrapperRef.current[SYMBOL_OBSERVER_KEY]
107+
}
108+
}, [])
109+
110+
return (
111+
<>
112+
<Wrapper
113+
{...props}
114+
data-br={id}
115+
data-brr={ratio}
116+
ref={wrapperRef}
117+
style={{
118+
display: 'inline-block',
119+
verticalAlign: 'top',
120+
textDecoration: 'inherit',
121+
}}
122+
suppressHydrationWarning
123+
>
124+
{children}
125+
</Wrapper>
126+
</>
127+
)
128+
}
129+
130+
// As Next.js adds `display: none` to `body` for development, we need to trigger
131+
// a re-balance right after the style is removed, synchronously.
132+
if (!IS_SERVER && process.env.NODE_ENV !== 'production') {
133+
const next_dev_style = document.querySelector<HTMLElement>(
134+
'[data-next-hide-fouc]'
135+
)
136+
if (next_dev_style) {
137+
const callback: MutationCallback = (mutationList) => {
138+
for (const mutation of mutationList) {
139+
for (const node of Array.from(mutation.removedNodes)) {
140+
if (node !== next_dev_style) continue
141+
142+
observer.disconnect()
143+
const elements =
144+
document.querySelectorAll<WrapperElement>('[data-br]')
145+
146+
for (const element of Array.from(elements)) {
147+
self[SYMBOL_KEY](0, +element.dataset.brr, element)
148+
}
149+
}
150+
}
151+
}
152+
const observer = new MutationObserver(callback)
153+
observer.observe(document.head, {childList: true})
154+
}
155+
}

src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const SYMBOL_KEY = '__wrap_b'
2+
export const SYMBOL_OBSERVER_KEY = '__wrap_o'

0 commit comments

Comments
 (0)