Files
next.js/test/development/browser-logs/browser-logs.test.ts
Andrew ClarkandGitHub 83e47acd77 Simplify scroll restoration with shared ScrollRef on CacheNode (#91348)
Replaces the segment-path-matching scroll system with a simpler model
based on a shared mutable ScrollRef on CacheNode.

The old system accumulated segment paths during navigation and matched
them in layout-router to decide which segments should scroll. This was
necessary when CacheNodes were created lazily during render. Now that we
construct the entire CacheNode tree immediately upon navigation, we can
assign a shared ScrollRef directly to each new leaf node. When any
segment scrolls, it flips the ref to false, preventing other segments
from also scrolling. This removes all the segment path accumulation and
matching logic.

Fixes a regression where calling `refresh()` from a server action
scrolled the page to the top. The old system had a semantic gap between
`null` (no segments) and `[]` (scroll everything) — a server action
refresh with no new segments fell through to a path that scrolled
unconditionally. The new model avoids this: refresh creates no new
CacheNodes, so no ScrollRef is assigned, and nothing scrolls.

Repro: https://github.com/stipsan/nextjs-refresh-regression-repro

There is extensive existing test coverage for scroll restoration
behavior. This adds one additional test for the server action refresh
bug.
2026-03-14 13:38:36 +01:00

419 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createNext, FileRef } from 'e2e-utils'
import { NextInstance } from 'e2e-utils'
import webdriver from 'next-webdriver'
import { join } from 'path'
import stripAnsi from 'strip-ansi'
import { retry } from 'next-test-utils'
const bundlerName = process.env.IS_TURBOPACK_TEST ? 'Turbopack' : 'Webpack'
const enableNewScrollHandler =
process.env.__NEXT_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER === 'true'
const innerScrollAndMaybeFocusHandlerName = enableNewScrollHandler
? 'InnerScrollHandlerNew'
: 'InnerScrollAndFocusHandlerOld'
function setupLogCapture() {
const logs: string[] = []
const originalStdout = process.stdout.write
const originalStderr = process.stderr.write
const capture = (chunk: any) => {
logs.push(stripAnsi(chunk.toString()))
return true
}
process.stdout.write = function (chunk: any) {
capture(chunk)
return originalStdout.call(this, chunk)
}
process.stderr.write = function (chunk: any) {
capture(chunk)
return originalStderr.call(this, chunk)
}
const restore = () => {
process.stdout.write = originalStdout
process.stderr.write = originalStderr
}
const clearLogs = () => {
logs.length = 0
}
return { logs, restore, clearLogs }
}
describe(`Terminal Logging (${bundlerName})`, () => {
describe('Pages Router', () => {
let next: NextInstance
let logs: string[] = []
let logCapture: ReturnType<typeof setupLogCapture>
let browser = null
beforeAll(async () => {
logCapture = setupLogCapture()
logs = logCapture.logs
next = await createNext({
files: {
pages: new FileRef(join(__dirname, 'fixtures/pages')),
'next.config.js': new FileRef(
join(__dirname, 'fixtures/next.config.js')
),
},
})
})
afterAll(async () => {
logCapture.restore()
await next.destroy()
})
beforeEach(() => {
logCapture.clearLogs()
})
afterEach(async () => {
if (browser) {
await browser.close()
browser = null
}
})
it('should forward client component logs', async () => {
browser = await webdriver(next.url, '/pages-client-log')
await browser.waitForElementByCss('#log-button')
await browser.elementByCss('#log-button').click()
await retry(() => {
const logOutput = logs.join('')
expect(logOutput).toContain(
'[browser] Log from pages router client component'
)
})
})
it('should handle circular references safely', async () => {
browser = await webdriver(next.url, '/circular-refs')
await browser.waitForElementByCss('#circular-button')
await browser.elementByCss('#circular-button').click()
await retry(() => {
const logOutput = logs.join('\n')
expect(logOutput).toContain('[browser] Circular object:')
expect(logOutput).toContain('[Circular]')
})
})
it('should respect default depth limit', async () => {
browser = await webdriver(next.url, '/deep-objects')
await browser.waitForElementByCss('#deep-button')
await browser.elementByCss('#deep-button').click()
await retry(() => {
const logOutput = logs.join('\n')
expect(logOutput).toContain('[browser] Deep object: {')
expect(logOutput).toContain('level1: {')
expect(logOutput).toContain('level2: { level3: { level4: { level5:')
expect(logOutput).toContain("'[Object]'")
})
})
it('should show source-mapped errors in pages router', async () => {
browser = await webdriver(next.url, '/pages-client-error')
await browser.waitForElementByCss('#error-button')
logCapture.clearLogs()
await browser.elementByCss('#error-button').click()
await retry(() => {
const logOutput = logs.join('\n')
const browserErrorPattern =
/\[browser\] Uncaught Error: Client error in pages router\n\s+at throwClientError \(pages\/pages-client-error\.js:2:\d+\)\n\s+at callClientError \(pages\/pages-client-error\.js:6:\d+\)/
expect(logOutput).toMatch(browserErrorPattern)
})
})
it('should show source-mapped errors for server errors from pages router ', async () => {
const outputIndex = logs.length
browser = await webdriver(next.url, '/pages-server-error')
await retry(() => {
const newLogs = logs.slice(outputIndex).join('\n')
const browserErrorPattern =
/\[browser\] Uncaught Error: Server error in pages router\n\s+at throwPagesServerError \(pages\/pages-server-error\.js:2:\d+\)\n\s+at callPagesServerError \(pages\/pages-server-error\.js:6:\d+\)/
expect(newLogs).toMatch(browserErrorPattern)
})
})
})
describe('App Router - Server Components', () => {
let next: NextInstance
let logs: string[] = []
let logCapture: ReturnType<typeof setupLogCapture>
beforeAll(async () => {
logCapture = setupLogCapture()
logs = logCapture.logs
next = await createNext({
files: {
app: new FileRef(join(__dirname, 'fixtures/app')),
'next.config.js': new FileRef(
join(__dirname, 'fixtures/next.config.js')
),
},
})
})
afterAll(async () => {
logCapture.restore()
await next.destroy()
})
beforeEach(() => {
logCapture.clearLogs()
})
it('should not re-log server component logs', async () => {
const outputIndex = logs.length
await next.render('/server-log')
await retry(() => {
const newLogs = logs.slice(outputIndex).join('')
expect(newLogs).toContain('Server component console.log')
}, 2000)
const newLogs = logs.slice(outputIndex).join('')
expect(newLogs).not.toContain('[browser] Server component console.log')
expect(newLogs).not.toContain('[browser] Server component console.error')
})
it('should show source-mapped errors for server components', async () => {
const outputIndex = logs.length
const browser = await webdriver(next.url, '/server-error')
await retry(() => {
const newLogs = logs.slice(outputIndex).join('\n')
const browserErrorPattern =
/\[browser\] Uncaught Error: Server component error in app router\n\s+at throwServerError \(app\/server-error\/page\.js:2:\d+\)\n\s+at callServerError \(app\/server-error\/page\.js:6:\d+\)\n\s+at ServerErrorPage \(app\/server-error\/page\.js:10:\d+\)/
expect(newLogs).toMatch(browserErrorPattern)
})
await browser.close()
})
})
describe('App Router - Client Components', () => {
let next: NextInstance
let logs: string[] = []
let logCapture: ReturnType<typeof setupLogCapture>
beforeAll(async () => {
logCapture = setupLogCapture()
logs = logCapture.logs
next = await createNext({
files: {
app: new FileRef(join(__dirname, 'fixtures/app')),
'next.config.js': new FileRef(
join(__dirname, 'fixtures/next.config.js')
),
},
})
})
afterAll(async () => {
logCapture.restore()
await next.destroy()
})
beforeEach(() => {
logCapture.clearLogs()
})
it('should forward client component logs in app router', async () => {
const browser = await webdriver(next.url, '/client-log')
await browser.waitForElementByCss('#log-button')
await browser.elementByCss('#log-button').click()
await retry(() => {
const logOutput = logs.join('')
expect(logOutput).toContain(
'[browser] Client component log from app router'
)
})
await browser.close()
})
it('should show source-mapped errors for client components', async () => {
const browser = await webdriver(next.url, '/client-error')
await browser.waitForElementByCss('#error-button')
logCapture.clearLogs()
await browser.elementByCss('#error-button').click()
await retry(() => {
const logOutput = logs.join('\n')
const browserErrorPattern =
/\[browser\] Uncaught Error: Client component error in app router\n\s+at throwError \(app\/client-error\/page\.js:4:\d+\)\n\s+at callError \(app\/client-error\/page\.js:8:\d+\)/
expect(logOutput).toMatch(browserErrorPattern)
})
await browser.close()
})
})
describe('App Router - Hydration Errors', () => {
let next: NextInstance
let logs: string[] = []
let logCapture: ReturnType<typeof setupLogCapture>
beforeAll(async () => {
logCapture = setupLogCapture()
logs = logCapture.logs
next = await createNext({
files: {
app: new FileRef(join(__dirname, 'fixtures/app')),
'next.config.js': new FileRef(
join(__dirname, 'fixtures/next.config.js')
),
},
})
})
afterAll(async () => {
logCapture.restore()
await next.destroy()
})
beforeEach(() => {
logCapture.clearLogs()
})
it('should show hydration errors with owner stack trace', async () => {
const browser = await webdriver(next.url, '/hydration-error')
let hydrationErrorLog = ''
await retry(() => {
const logOutput = logs.join('\n')
// Find the hydration error log entry
// Stop at: another [browser] log, status indicators (○ ⨯),
// or timestamp-prefixed logs (e.g. "[12:34:56.789Z] Browser Log: ...")
const hydrationMatch = logOutput.match(
/\[browser\].*Hydration[\s\S]*?(?=\n\[browser\]|\n *○|\n *⨯|\n *\[\d|$)/
)
expect(hydrationMatch).not.toBeNull()
hydrationErrorLog = hydrationMatch![0]
// Verify the Page component is in the forwarded stack trace with source location
expect(hydrationErrorLog).toMatch(/Page/)
expect(hydrationErrorLog).toMatch(/app\/hydration-error\/page/)
})
// Assert the entire hydration error message including owner stack trace
expect(hydrationErrorLog).toMatchInlineSnapshot(`
"[browser] Uncaught Error: Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
- A server/client branch \`if (typeof window !== 'undefined')\`.
- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
https://react.dev/link/hydration-mismatch
...
<RenderFromTemplateContext>
<ScrollAndMaybeFocusHandler cacheNode={{rsc:{...}, ...}}>
<${innerScrollAndMaybeFocusHandlerName} focusAndScrollRef={{scrollRef:null, ...}} cacheNode={{rsc:{...}, ...}}>
<ErrorBoundary errorComponent={undefined} errorStyles={undefined} errorScripts={undefined}>
<LoadingBoundary name="hydration-..." loading={null}>
<HTTPAccessFallbackBoundary notFound={undefined} forbidden={undefined} unauthorized={undefined}>
<RedirectBoundary>
<RedirectErrorBoundary router={{...}}>
<InnerLayoutRouter url="/hydration..." tree={[...]} params={{}} cacheNode={{rsc:{...}, ...}} ...>
<SegmentViewNode type="page" pagePath="hydration-...">
<SegmentTrieNode>
<ClientPageRoot Component={function Page} serverProvidedParams={{...}}>
<Page params={Promise} searchParams={Promise}>
<div>
<p>
+ client
- server
...
...
...
at <unknown> (https://react.dev/link/hydration-mismatch)
at p (<anonymous>)
at Page (app/hydration-error/page.js:7:7)
5 | return (
6 | <div>
> 7 | <p>{isClient ? 'client' : 'server'}</p>
| ^
8 | </div>
9 | )
10 | }
"
`)
await browser.close()
})
})
describe('App Router - Edge Runtime', () => {
let next: NextInstance
let logs: string[] = []
let logCapture: ReturnType<typeof setupLogCapture>
beforeAll(async () => {
logCapture = setupLogCapture()
logs = logCapture.logs
next = await createNext({
files: {
app: new FileRef(join(__dirname, 'fixtures/app')),
'next.config.js': new FileRef(
join(__dirname, 'fixtures/next.config.js')
),
},
})
})
afterAll(async () => {
logCapture.restore()
await next.destroy()
})
beforeEach(() => {
logCapture.clearLogs()
})
it('should handle edge runtime errors with source mapping', async () => {
const browser = await webdriver(next.url, '/edge-deep-stack')
await retry(() => {
const logOutput = logs.join('\n')
const browserErrorPattern =
/\[browser\] Uncaught Error: Deep stack error during render\n\s+at functionA \(app\/edge-deep-stack\/page\.js:6:\d+\)\n\s+at functionB \(app\/edge-deep-stack\/page\.js:10:\d+\)\n\s+at functionC \(app\/edge-deep-stack\/page\.js:14:\d+\)\n\s+at EdgeDeepStackPage \(app\/edge-deep-stack\/page\.js:18:\d+\)/
expect(logOutput).toMatch(browserErrorPattern)
})
await browser.close()
})
})
})