Skip to content

Commit f158df1

Browse files
sokraclaude
authored andcommitted
Fix styled-jsx race condition: styles lost due to concurrent rendering (#92459)
### What? Fix a race condition in the Pages Router SSR path where styled-jsx styles were dropped from the rendered HTML. ### Why? `styledJsxInsertedHTML()` reads and flushes the styled-jsx style registry. Previously it was called concurrently with the page render via `Promise.all`: ```js const [rawStyledJsxInsertedHTML, content] = await Promise.all([ renderToString(styledJsxInsertedHTML()), (async () => { /* render the page */ })(), ]) ``` Because both ran at the same time, `styledJsxInsertedHTML()` could (and in practice did) execute and flush the registry **before** the page render had finished populating it. The result was that dynamic styled-jsx styles — those with interpolated expressions that compute their class names at runtime via DJB2 hashing — were silently dropped from the SSR output, causing a flash of unstyled content on first load. This is particularly visible in production deployments where all components use dynamic styled-jsx (numeric `jsx-*` class names), since those styles only exist in the registry after rendering completes. ### How? Serialize the two operations: render the page first, then call `styledJsxInsertedHTML()`. Since the registry is fully populated by the time it is read, all styles are captured correctly. A new e2e test (`test/e2e/styled-jsx-dynamic`) exercises this scenario with multiple nested components that all use dynamic styled-jsx with interpolated props, covering the exact FOUC pattern seen in production. <!-- NEXT_JS_LLM_PR --> Co-authored-by: Tobias Koppers <sokra@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 356d605 commit f158df1

File tree

6 files changed

+119
-21
lines changed

6 files changed

+119
-21
lines changed

packages/next/src/server/render.tsx

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1385,29 +1385,22 @@ export async function renderToHTMLImpl(
13851385
| {}
13861386
| Awaited<ReturnType<typeof loadDocumentInitialProps>>
13871387

1388-
const [rawStyledJsxInsertedHTML, content] = await Promise.all([
1389-
renderToString(styledJsxInsertedHTML()),
1390-
(async () => {
1391-
if (hasDocumentGetInitialProps) {
1392-
documentInitialPropsRes = await loadDocumentInitialProps(renderShell)
1393-
if (documentInitialPropsRes === null) return null
1394-
const { docProps } = documentInitialPropsRes as any
1395-
return docProps.html
1396-
} else {
1397-
documentInitialPropsRes = {}
1398-
const stream = await renderShell(App, Component)
1399-
await stream.allReady
1400-
return streamToString(stream)
1401-
}
1402-
})(),
1403-
])
1404-
1405-
if (content === null) {
1406-
return null
1388+
let content: string | null
1389+
if (hasDocumentGetInitialProps) {
1390+
documentInitialPropsRes = await loadDocumentInitialProps(renderShell)
1391+
if (documentInitialPropsRes === null) {
1392+
content = null
1393+
} else {
1394+
const { docProps } = documentInitialPropsRes as any
1395+
content = docProps.html
1396+
}
1397+
} else {
1398+
documentInitialPropsRes = {}
1399+
const stream = await renderShell(App, Component)
1400+
await stream.allReady
1401+
content = await streamToString(stream)
14071402
}
14081403

1409-
const contentHTML = rawStyledJsxInsertedHTML + content
1410-
14111404
// @ts-ignore: documentInitialPropsRes is set
14121405
const { docProps } = (documentInitialPropsRes as any) || {}
14131406
const documentElement = (htmlProps: any) => {
@@ -1427,6 +1420,17 @@ export async function renderToHTMLImpl(
14271420
jsxStyleRegistry.flush()
14281421
}
14291422

1423+
// Registry is now flushed; rawStyledJsxInsertedHTML will be empty.
1424+
const rawStyledJsxInsertedHTML = await renderToString(
1425+
styledJsxInsertedHTML()
1426+
)
1427+
1428+
if (content === null) {
1429+
return null
1430+
}
1431+
1432+
const contentHTML = rawStyledJsxInsertedHTML + content
1433+
14301434
return {
14311435
contentHTML,
14321436
documentElement,
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export default function DynamicStyled({ color }) {
2+
return (
3+
<div>
4+
<style jsx>{`
5+
p {
6+
color: ${color};
7+
}
8+
`}</style>
9+
<p>dynamic styled</p>
10+
</div>
11+
)
12+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export default function Footer({ color }) {
2+
return (
3+
<footer>
4+
<style jsx>{`
5+
footer {
6+
color: ${color};
7+
padding: 1rem;
8+
}
9+
`}</style>
10+
<span>Footer</span>
11+
</footer>
12+
)
13+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export default function Header({ bg, fg }) {
2+
return (
3+
<header>
4+
<style jsx>{`
5+
header {
6+
background-color: ${bg};
7+
color: ${fg};
8+
padding: 1rem;
9+
}
10+
`}</style>
11+
<span>Header</span>
12+
</header>
13+
)
14+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { nextTestSetup } from 'e2e-utils'
2+
3+
describe('styled-jsx dynamic styles SSR', () => {
4+
const { next } = nextTestSetup({
5+
files: __dirname,
6+
skipDeployment: true,
7+
})
8+
9+
// Dynamic styled-jsx (with interpolated expressions) produces numeric class
10+
// names at runtime via the DJB2 hash in styled-jsx's computeId function.
11+
// This pattern matches production deployments where all jsx class names
12+
// are numeric (e.g. jsx-2267428885) rather than hex (jsx-f36313d9f07883b7).
13+
it('should contain dynamic styled-jsx styles during SSR', async () => {
14+
const html = await next.render('/')
15+
16+
// Dynamic styled-jsx produces numeric class names at runtime
17+
const numericClasses = html.match(/\bjsx-\d+\b/g) || []
18+
console.log('Numeric jsx classes:', [...new Set(numericClasses)])
19+
expect(numericClasses.length).toBeGreaterThan(0)
20+
21+
// All dynamic styles should be present as inline <style> tags
22+
expect(html).toMatch(/color:.*?green/) // main page
23+
expect(html).toMatch(/color:.*?blue/) // DynamicStyled
24+
expect(html).toMatch(/background-color:.*?navy/) // header
25+
expect(html).toMatch(/color:.*?purple/) // footer
26+
})
27+
})
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import DynamicStyled from '../components/DynamicStyled'
2+
import Header from '../components/Header'
3+
import Footer from '../components/Footer'
4+
5+
export default function Page({ mainColor }) {
6+
return (
7+
<div>
8+
<style jsx>{`
9+
div {
10+
color: ${mainColor};
11+
}
12+
`}</style>
13+
<Header bg="navy" fg="white" />
14+
<main>
15+
<DynamicStyled color="blue" />
16+
</main>
17+
<Footer color="purple" />
18+
</div>
19+
)
20+
}
21+
22+
export function getServerSideProps() {
23+
return {
24+
props: {
25+
mainColor: 'green',
26+
},
27+
}
28+
}

0 commit comments

Comments
 (0)