mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
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.
419 lines
14 KiB
TypeScript
419 lines
14 KiB
TypeScript
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()
|
||
})
|
||
})
|
||
})
|