diff --git a/AGENTS.md b/AGENTS.md index 87864950b1315c..070fc530321d2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,13 +144,13 @@ pnpm test-dev-turbo test/development/ Generating tests using `pnpm new-test` is mandatory. ```bash -# Use --args for non-interactive mode -# Format: pnpm new-test --args +# Use --args for non-interactive mode (forward args to the script using `--`) +# Format: pnpm new-test -- --args # appDir: true/false (is this for app directory?) # name: test name (e.g. "my-feature") # type: e2e | production | development | unit -pnpm new-test --args true my-feature e2e +pnpm new-test -- --args true my-feature e2e ``` **Analyzing test output efficiently:** @@ -397,7 +397,7 @@ Core runtime/bundling rules (always apply; skills above expand on these with ver ### Test Gotchas - **Cache components enables PPR by default**: When `__NEXT_CACHE_COMPONENTS=true`, most app-dir pages use PPR implicitly. Dedicated `ppr-full/` and `ppr/` test suites are mostly `describe.skip` (migrating to cache components). To test PPR codepaths, run normal app-dir e2e tests with `__NEXT_CACHE_COMPONENTS=true` rather than looking for explicit PPR test suites. -- **Quick smoke testing with toy apps**: For fast feedback, generate a minimal test fixture with `pnpm new-test --args true e2e`, then run the dev server directly with `node packages/next/dist/bin/next dev --port ` and `curl --max-time 10`. This avoids the overhead of the full test harness and gives immediate feedback on hangs/crashes. +-- **Quick smoke testing with toy apps**: For fast feedback, generate a minimal test fixture with `pnpm new-test -- --args true e2e`, then run the dev server directly with `node packages/next/dist/bin/next dev --port ` and `curl --max-time 10`. This avoids the overhead of the full test harness and gives immediate feedback on hangs/crashes. - Mode-specific tests need `skipStart: true` + manual `next.start()` in `beforeAll` after mode check - Don't rely on exact log messages - filter by content patterns, find sequences not positions - **Snapshot tests vary by env flags**: Tests with inline snapshots can produce different output depending on env flags. When updating snapshots, always run the test with the exact env flags the CI job uses (check `.github/workflows/build_and_test.yml` `afterBuild:` sections). Turbopack resolves `react-dom/server.edge` (no Node APIs like `renderToPipeableStream`), while webpack resolves the `.node` build (has them). diff --git a/packages/next/src/client/components/app-router-headers.ts b/packages/next/src/client/components/app-router-headers.ts index f39aa6dbd460ed..463f776b5098e9 100644 --- a/packages/next/src/client/components/app-router-headers.ts +++ b/packages/next/src/client/components/app-router-headers.ts @@ -13,6 +13,9 @@ export const NEXT_ROUTER_SEGMENT_PREFETCH_HEADER = 'next-router-segment-prefetch' as const export const NEXT_HMR_REFRESH_HEADER = 'next-hmr-refresh' as const export const NEXT_HMR_REFRESH_HASH_COOKIE = '__next_hmr_refresh_hash__' as const +// Header used to pass the HMR refresh hash when cookies are unavailable (e.g. +// in cross-origin iframes where SameSite=Lax cookies are not sent). +export const NEXT_HMR_REFRESH_HASH_HEADER = 'next-hmr-refresh-hash' as const export const NEXT_URL = 'next-url' as const export const RSC_CONTENT_TYPE_HEADER = 'text/x-component' as const diff --git a/packages/next/src/client/components/hmr-client-state.ts b/packages/next/src/client/components/hmr-client-state.ts new file mode 100644 index 00000000000000..cca71360f88255 --- /dev/null +++ b/packages/next/src/client/components/hmr-client-state.ts @@ -0,0 +1,18 @@ +'use client' + +// Module-level state for the current HMR refresh hash. +// +// The hash is set by the hot-reloader when server component changes are +// detected, and consumed by fetchServerResponse to ensure cache busting works +// in cross-origin iframe contexts where cookies may not be transmitted due to +// SameSite=Lax restrictions. + +let currentHmrRefreshHash: string | undefined + +export function setCurrentHmrRefreshHash(hash: string): void { + currentHmrRefreshHash = hash +} + +export function getCurrentHmrRefreshHash(): string | undefined { + return currentHmrRefreshHash +} diff --git a/packages/next/src/client/components/router-reducer/fetch-server-response.ts b/packages/next/src/client/components/router-reducer/fetch-server-response.ts index d238a72d892112..95b2386e3d037e 100644 --- a/packages/next/src/client/components/router-reducer/fetch-server-response.ts +++ b/packages/next/src/client/components/router-reducer/fetch-server-response.ts @@ -22,11 +22,13 @@ import { RSC_HEADER, RSC_CONTENT_TYPE_HEADER, NEXT_HMR_REFRESH_HEADER, + NEXT_HMR_REFRESH_HASH_HEADER, NEXT_DID_POSTPONE_HEADER, NEXT_ROUTER_STALE_TIME_HEADER, NEXT_HTML_REQUEST_ID_HEADER, NEXT_REQUEST_ID_HEADER, } from '../app-router-headers' +import { getCurrentHmrRefreshHash } from '../hmr-client-state' import { callServer } from '../../app-call-server' import { findSourceMapURL } from '../../app-find-source-map-url' import { @@ -87,6 +89,7 @@ export type RequestHeaders = { [NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]?: string 'x-deployment-id'?: string [NEXT_HMR_REFRESH_HEADER]?: '1' + [NEXT_HMR_REFRESH_HASH_HEADER]?: string // A header that is only added in test mode to assert on fetch priority 'Next-Test-Fetch-Priority'?: RequestInit['priority'] [NEXT_HTML_REQUEST_ID_HEADER]?: string // dev-only @@ -137,6 +140,14 @@ export async function fetchServerResponse( if (process.env.NODE_ENV === 'development' && options.isHmrRefresh) { headers[NEXT_HMR_REFRESH_HEADER] = '1' + // Also pass the hash as a header to support cross-origin iframes where + // SameSite=Lax cookies are not sent due to the top-level context being a + // different origin. The server will use the header as a fallback when the + // cookie is absent. + const hmrHash = getCurrentHmrRefreshHash() + if (hmrHash) { + headers[NEXT_HMR_REFRESH_HASH_HEADER] = hmrHash + } } if (nextUrl) { diff --git a/packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx b/packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx index 32181d20973319..8b018fa62aab14 100644 --- a/packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx +++ b/packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx @@ -32,6 +32,7 @@ import { useUntrackedPathname } from '../../../components/navigation-untracked' import reportHmrLatency from '../../report-hmr-latency' import { TurbopackHmr } from '../turbopack-hot-reloader-common' import { NEXT_HMR_REFRESH_HASH_COOKIE } from '../../../components/app-router-headers' +import { setCurrentHmrRefreshHash } from '../../../components/hmr-client-state' import { publicAppRouterInstance, type GlobalErrorState, @@ -407,8 +408,20 @@ export function processMessage( ) // Store the latest hash in a session cookie so that it's sent back to the - // server with any subsequent requests. - document.cookie = `${NEXT_HMR_REFRESH_HASH_COOKIE}=${message.hash};path=/` + // server with any subsequent requests. Use SameSite=None on HTTPS to allow + // the cookie to be sent from cross-origin iframes (e.g. when using a + // tunnel like cloudflared during development). SameSite=None requires + // Secure, so it can only be set on HTTPS connections. + const cookieFlags = + location.protocol === 'https:' + ? `path=/;SameSite=None;Secure` + : `path=/` + document.cookie = `${NEXT_HMR_REFRESH_HASH_COOKIE}=${message.hash};${cookieFlags}` + + // Also store in module-level state as a fallback for cross-origin iframe + // contexts where cookies may be blocked (e.g. "Block third-party cookies" + // enabled). The hash will be sent as a request header instead. + setCurrentHmrRefreshHash(message.hash) if ( RuntimeErrorHandler.hadRuntimeError || diff --git a/packages/next/src/server/app-render/work-unit-async-storage.external.ts b/packages/next/src/server/app-render/work-unit-async-storage.external.ts index f56b736d4cb2bf..cef9d820b21a66 100644 --- a/packages/next/src/server/app-render/work-unit-async-storage.external.ts +++ b/packages/next/src/server/app-render/work-unit-async-storage.external.ts @@ -18,7 +18,10 @@ import type { import type { Params } from '../request/params' import type { ImplicitTags } from '../lib/implicit-tags' import type { WorkStore } from './work-async-storage.external' -import { NEXT_HMR_REFRESH_HASH_COOKIE } from '../../client/components/app-router-headers' +import { + NEXT_HMR_REFRESH_HASH_COOKIE, + NEXT_HMR_REFRESH_HASH_HEADER, +} from '../../client/components/app-router-headers' import { InvariantError } from '../../shared/lib/invariant-error' import type { StagedRenderingController } from './staged-rendering' import type { ValidationBoundaryTracking } from './instant-validation/boundary-tracking' @@ -419,7 +422,14 @@ export function getHmrRefreshHash( case 'prerender-runtime': return workUnitStore.hmrRefreshHash case 'request': - return workUnitStore.cookies.get(NEXT_HMR_REFRESH_HASH_COOKIE)?.value + // Prefer the cookie, but fall back to the request header for cross-origin + // iframe contexts where SameSite=Lax (or blocked third-party cookies) + // prevent the cookie from being sent with the HMR refresh fetch. + return ( + workUnitStore.cookies.get(NEXT_HMR_REFRESH_HASH_COOKIE)?.value ?? + workUnitStore.headers.get(NEXT_HMR_REFRESH_HASH_HEADER) ?? + undefined + ) case 'prerender-client': case 'validation-client': case 'prerender-ppr':