Skip to content

Commit 585bfdd

Browse files
committedMar 29, 2025·
fix(react): process tag content and memoize elements
1 parent 24ee392 commit 585bfdd

File tree

2 files changed

+152
-24
lines changed

2 files changed

+152
-24
lines changed
 

‎examples/vite-ssr-react-ts/src/App.tsx

+114-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import './App.css'
22
import { useState, useEffect } from 'react'
33
import reactLogo from './assets/react.svg'
4-
import { useHead, useScript, useSeoMeta } from '@unhead/react'
4+
import { useHead, useScript, useSeoMeta, Head } from '@unhead/react'
55
import { useSchemaOrg, defineWebPage, defineWebSite } from '@unhead/schema-org/react'
66

77
function PageHead() {
@@ -23,6 +23,118 @@ function PageHead() {
2323
return null
2424
}
2525

26+
function PageHeadComponent() {
27+
const [fontSize, setFontSize] = useState(16);
28+
const [increasing, setIncreasing] = useState(true);
29+
const [isAnimating, setIsAnimating] = useState(true);
30+
const [renderCount, setRenderCount] = useState(0);
31+
32+
// Track component renders
33+
useEffect(() => {
34+
setRenderCount(prev => prev + 1);
35+
}, []);
36+
37+
useEffect(() => {
38+
if (typeof window === 'undefined' || !isAnimating) return;
39+
40+
const timer = setInterval(() => {
41+
setFontSize(prevSize => {
42+
if (increasing) {
43+
if (prevSize >= 20) {
44+
setIncreasing(false);
45+
return 20;
46+
}
47+
return prevSize + 0.1;
48+
} else {
49+
if (prevSize <= 14) {
50+
setIncreasing(true);
51+
return 14.1;
52+
}
53+
return prevSize - 0.1;
54+
}
55+
});
56+
}, 10);
57+
58+
return () => clearInterval(timer);
59+
}, [increasing, isAnimating]);
60+
61+
const dynamicStyle = `
62+
html {
63+
font-size: ${fontSize.toFixed(1)}px;
64+
}
65+
body {
66+
margin: 0;
67+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
68+
-webkit-font-smoothing: antialiased;
69+
-moz-osx-font-smoothing: grayscale;
70+
}
71+
code {
72+
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
73+
}
74+
`;
75+
76+
const buttonStyle = {
77+
position: 'fixed',
78+
top: '10px',
79+
right: '10px',
80+
zIndex: 1000,
81+
padding: '8px 12px',
82+
backgroundColor: isAnimating ? '#f44336' : '#4CAF50',
83+
color: 'white',
84+
border: 'none',
85+
borderRadius: '4px',
86+
cursor: 'pointer'
87+
};
88+
89+
const renderCountStyle = {
90+
position: 'fixed',
91+
top: '50px',
92+
right: '10px',
93+
zIndex: 1000,
94+
padding: '4px 8px',
95+
backgroundColor: '#333',
96+
color: 'white',
97+
borderRadius: '4px',
98+
fontSize: '12px'
99+
};
100+
101+
return (
102+
<>
103+
<Head>
104+
{/* Dynamic content that will re-render */}
105+
<style>{dynamicStyle}</style>
106+
107+
{/* Static content with a key that won't re-render */}
108+
<link
109+
key="static-font"
110+
rel="stylesheet"
111+
href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap"
112+
/>
113+
<meta
114+
key="static-viewport"
115+
name="viewport"
116+
content="width=device-width, initial-scale=1.0"
117+
/>
118+
<meta
119+
key="static-color-scheme"
120+
name="color-scheme"
121+
content="light dark"
122+
/>
123+
</Head>
124+
<button
125+
onClick={() => setIsAnimating(!isAnimating)}
126+
style={buttonStyle as React.CSSProperties}
127+
>
128+
{isAnimating ? 'Stop Animation' : 'Start Animation'}
129+
</button>
130+
<div style={renderCountStyle as React.CSSProperties}>
131+
Component renders: {renderCount}
132+
<br />
133+
<small>Static head elements won't re-render</small>
134+
</div>
135+
</>
136+
);
137+
}
26138
function App() {
27139
const [count, setCount] = useState(0)
28140
const [jsConfetti, setJsConfetti] = useState<{ addConfetti: (opt: any) => void } | null>(null)
@@ -74,6 +186,7 @@ function App() {
74186
return (
75187
<>
76188
<PageHead />
189+
<PageHeadComponent />
77190
<div>
78191
<a href="https://vite.dev" target="_blank">
79192
<img src="/vite.svg" className="logo" alt="Vite logo" />

‎packages/react/src/components.ts

+38-23
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ReactNode } from 'react'
22
import type { ActiveHeadEntry, ResolvableHead as UseHeadInput } from 'unhead/types'
3-
import React, { useCallback, useEffect, useRef } from 'react'
3+
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
44
import { HasElementTags, TagsWithInnerContent, ValidHeadTags } from 'unhead/utils'
55
import { useUnhead } from './composables'
66

@@ -11,40 +11,59 @@ interface HeadProps {
1111

1212
const Head: React.FC<HeadProps> = ({ children, titleTemplate }) => {
1313
const head = useUnhead()
14+
const headRef = useRef<ActiveHeadEntry<UseHeadInput> | null>(null)
15+
16+
// Process children only when they change
17+
const processedElements = useMemo(() =>
18+
React.Children.toArray(children).filter(React.isValidElement), [children])
1419

1520
const getHeadChanges = useCallback(() => {
1621
const input: UseHeadInput = {
1722
titleTemplate,
1823
}
19-
const elements = React.Children.toArray(children).filter(React.isValidElement)
2024

21-
elements.forEach((element: React.ReactElement) => {
22-
const { type, props } = element
25+
for (const element of processedElements) {
26+
const reactElement = element as React.ReactElement
27+
const { type, props } = reactElement
28+
const tagName = String(type)
2329

24-
if (!ValidHeadTags.has(type as string)) {
25-
return
30+
if (!ValidHeadTags.has(tagName)) {
31+
continue
2632
}
33+
2734
const data: Record<string, any> = { ...(typeof props === 'object' ? props : {}) }
28-
if (TagsWithInnerContent.has(type as string) && data.children) {
29-
data[type === 'script' ? 'innerHTML' : 'textContent'] = Array.isArray(data.children) ? data.children.map(String).join('') : data.children
35+
36+
if (TagsWithInnerContent.has(tagName) && data.children) {
37+
const contentKey = tagName === 'script' ? 'innerHTML' : 'textContent'
38+
data[contentKey] = Array.isArray(data.children)
39+
? data.children.map(String).join('')
40+
: String(data.children)
3041
}
3142
delete data.children
32-
if (HasElementTags.has(type as string)) {
33-
input[type as 'meta'] = input[type as 'meta'] || []
34-
// @ts-expect-error untyped
35-
input[type as 'meta']!.push(data)
43+
if (HasElementTags.has(tagName)) {
44+
const key = tagName as keyof UseHeadInput
45+
if (!Array.isArray(input[key])) {
46+
input[key] = []
47+
}
48+
(input[key] as any[])!.push(data)
3649
}
3750
else {
38-
// @ts-expect-error untyped
39-
input[type] = data
51+
// For singleton tags (title, base, etc.)
52+
input[tagName as keyof UseHeadInput] = data
4053
}
41-
})
54+
}
55+
4256
return input
43-
}, [children, titleTemplate])
57+
}, [processedElements, titleTemplate])
58+
59+
const headChanges = useMemo(() => getHeadChanges(), [getHeadChanges])
4460

45-
const headRef = useRef<ActiveHeadEntry<any> | null>(
46-
head.push(getHeadChanges()),
47-
)
61+
if (!headRef.current) {
62+
headRef.current = head.push(headChanges)
63+
}
64+
else {
65+
headRef.current.patch(headChanges)
66+
}
4867

4968
useEffect(() => {
5069
return () => {
@@ -55,10 +74,6 @@ const Head: React.FC<HeadProps> = ({ children, titleTemplate }) => {
5574
}
5675
}, [])
5776

58-
useEffect(() => {
59-
headRef.current?.patch(getHeadChanges())
60-
}, [getHeadChanges])
61-
6277
return null
6378
}
6479

0 commit comments

Comments
 (0)
Please sign in to comment.