From f54f44cfe1ca0dabd87fbeb83a24e5c7877cb197 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Sat, 6 Jun 2026 15:41:15 -0400 Subject: [PATCH 1/2] Enable experimental.appShells by default with cacheComponents Turns on `experimental.appShells` by default for projects already on `cacheComponents`, unless explicitly disabled. A handful of tests are incidentally coupled to the previous behavior. In this PR, the fixtures for those tests set `appShells` back to `false`. In subsequent PRs, I will incrementally update the tests until there are no more overrides. --- packages/next/src/server/config.ts | 32 +++++++++++++++++-- .../fixtures/default/next.config.js | 3 ++ .../fixtures/root-params/next.config.js | 3 ++ .../app-dir/optimistic-routing/next.config.js | 3 ++ .../parallel-route-navigations/next.config.js | 8 ++++- .../prefetch-true-instant/next.config.js | 6 ++++ .../segment-cache/basic/next.config.js | 6 ++++ .../cached-navigations/next.config.ts | 3 ++ .../cdn-cache-busting/next.config.js | 3 ++ .../dynamic-on-hover/next.config.js | 3 ++ .../segment-cache/force-stale/next.config.js | 3 ++ .../max-prefetch-inlining/next.config.js | 3 ++ .../memory-pressure/next.config.js | 3 ++ .../next.config.js | 3 ++ .../next.config.js | 3 ++ .../prefetch-auto/next.config.js | 5 +++ .../prefetch-inlining/next.config.js | 3 ++ .../prefetch-runtime/next.config.ts | 3 ++ .../prefetch-scheduling/next.config.js | 6 ++++ .../segment-cache/revalidation/next.config.js | 6 ++++ .../search-params/next.config.js | 3 ++ .../next.config.js | 3 ++ .../segment-cache/staleness/next.config.js | 3 ++ .../vary-params-base-dynamic/next.config.js | 3 ++ .../segment-cache/vary-params/next.config.js | 3 ++ .../next.config.js | 3 ++ .../next.config.ts | 6 ++++ 27 files changed, 128 insertions(+), 4 deletions(-) diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index da676394c6b8..e0095ba76a83 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -539,13 +539,13 @@ function assignDefaultsAndValidate( // defaults, so we don't support enabling App Shells against arbitrary // subsets of them — the validation goes away once each becomes a // default. + // Note: `prefetchInlining` is intentionally NOT required. App Shells works + // correctly whether or not prefetch inlining is enabled, so disabling it + // (e.g. to exercise non-inlined prefetch paths) must not force App Shells off. const missing: string[] = [] if (!result.cacheComponents) { missing.push('`cacheComponents`') } - if (!result.experimental.prefetchInlining) { - missing.push('`experimental.prefetchInlining`') - } if (!result.experimental.varyParams) { missing.push('`experimental.varyParams`') } @@ -2254,6 +2254,32 @@ function enforceExperimentalFeatures( config.experimental.cachedNavigations = true } + // Enable appShells by default when cacheComponents is enabled, unless + // explicitly disabled. App Shells builds on Cache Components rendering, so + // the two features are tied together: we only flip the default for projects + // that are already using Cache Components. Done silently for the same reasons + // as the cachedNavigations default above. + // + // We only auto-enable when App Shells's required dependencies are satisfied. + // If a project has explicitly disabled one of them, we leave App Shells off + // rather than force it on — otherwise the validation in + // `assignDefaultsAndValidate` would turn a previously-valid config into a + // hard error. Users who want App Shells in that situation can still enable it + // explicitly and get the actionable validation message. `prefetchInlining` is + // intentionally not part of this gate (App Shells works without it). This runs + // after the cachedNavigations default above so that dependency is already set. + // TODO: Remove this once appShells is unconditionally the default. + if ( + config.cacheComponents && + config.experimental.varyParams !== false && + config.experimental.optimisticRouting !== false && + config.experimental.cachedNavigations !== false && + (config.experimental.appShells === undefined || + (isDefaultConfig && !config.experimental.appShells)) + ) { + config.experimental.appShells = true + } + // TODO: Remove this once appNewScrollHandler is the default. if ( process.env.__NEXT_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER === 'true' && diff --git a/test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/next.config.js b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/next.config.js index aab0ad560203..293a5b2c66b1 100644 --- a/test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/next.config.js +++ b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/next.config.js @@ -4,6 +4,9 @@ const nextConfig = { cacheComponents: true, experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, // Enable the testing API in production builds for these tests exposeTestingApiInProductionBuild: true, prefetchInlining: false, diff --git a/test/e2e/app-dir/instant-navigation-testing-api/fixtures/root-params/next.config.js b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/root-params/next.config.js index d7dccfae3ef5..874a37964106 100644 --- a/test/e2e/app-dir/instant-navigation-testing-api/fixtures/root-params/next.config.js +++ b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/root-params/next.config.js @@ -4,6 +4,9 @@ const nextConfig = { cacheComponents: true, experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, exposeTestingApiInProductionBuild: true, prefetchInlining: false, }, diff --git a/test/e2e/app-dir/optimistic-routing/next.config.js b/test/e2e/app-dir/optimistic-routing/next.config.js index 4eb17d0b4a22..6d2135f2d7ad 100644 --- a/test/e2e/app-dir/optimistic-routing/next.config.js +++ b/test/e2e/app-dir/optimistic-routing/next.config.js @@ -4,6 +4,9 @@ const nextConfig = { cacheComponents: true, experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, optimisticRouting: true, varyParams: true, }, diff --git a/test/e2e/app-dir/parallel-route-navigations/next.config.js b/test/e2e/app-dir/parallel-route-navigations/next.config.js index 807126e4cf0b..5c851b1f9b1e 100644 --- a/test/e2e/app-dir/parallel-route-navigations/next.config.js +++ b/test/e2e/app-dir/parallel-route-navigations/next.config.js @@ -1,6 +1,12 @@ /** * @type {import('next').NextConfig} */ -const nextConfig = {} +const nextConfig = { + experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, + }, +} module.exports = nextConfig diff --git a/test/e2e/app-dir/prefetch-true-instant/next.config.js b/test/e2e/app-dir/prefetch-true-instant/next.config.js index e64bae22d658..16fab28dd2a5 100644 --- a/test/e2e/app-dir/prefetch-true-instant/next.config.js +++ b/test/e2e/app-dir/prefetch-true-instant/next.config.js @@ -2,6 +2,12 @@ * @type {import('next').NextConfig} */ const nextConfig = { + experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, + }, + cacheComponents: true, } diff --git a/test/e2e/app-dir/segment-cache/basic/next.config.js b/test/e2e/app-dir/segment-cache/basic/next.config.js index e64bae22d658..16fab28dd2a5 100644 --- a/test/e2e/app-dir/segment-cache/basic/next.config.js +++ b/test/e2e/app-dir/segment-cache/basic/next.config.js @@ -2,6 +2,12 @@ * @type {import('next').NextConfig} */ const nextConfig = { + experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, + }, + cacheComponents: true, } diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/next.config.ts b/test/e2e/app-dir/segment-cache/cached-navigations/next.config.ts index ee9a05dc80b1..9845c667d81c 100644 --- a/test/e2e/app-dir/segment-cache/cached-navigations/next.config.ts +++ b/test/e2e/app-dir/segment-cache/cached-navigations/next.config.ts @@ -4,6 +4,9 @@ const nextConfig: NextConfig = { cacheComponents: true, productionBrowserSourceMaps: true, experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, cachedNavigations: true, prefetchInlining: false, exposeTestingApiInProductionBuild: true, diff --git a/test/e2e/app-dir/segment-cache/cdn-cache-busting/next.config.js b/test/e2e/app-dir/segment-cache/cdn-cache-busting/next.config.js index eea5b256ec7c..1c612e346227 100644 --- a/test/e2e/app-dir/segment-cache/cdn-cache-busting/next.config.js +++ b/test/e2e/app-dir/segment-cache/cdn-cache-busting/next.config.js @@ -3,6 +3,9 @@ */ const nextConfig = { experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, validateRSCRequestHeaders: true, }, } diff --git a/test/e2e/app-dir/segment-cache/dynamic-on-hover/next.config.js b/test/e2e/app-dir/segment-cache/dynamic-on-hover/next.config.js index 5e058adaed31..ad07dbab34bf 100644 --- a/test/e2e/app-dir/segment-cache/dynamic-on-hover/next.config.js +++ b/test/e2e/app-dir/segment-cache/dynamic-on-hover/next.config.js @@ -4,6 +4,9 @@ const nextConfig = { cacheComponents: true, experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, dynamicOnHover: true, prefetchInlining: false, }, diff --git a/test/e2e/app-dir/segment-cache/force-stale/next.config.js b/test/e2e/app-dir/segment-cache/force-stale/next.config.js index 492391b2884b..1f34e326ba69 100644 --- a/test/e2e/app-dir/segment-cache/force-stale/next.config.js +++ b/test/e2e/app-dir/segment-cache/force-stale/next.config.js @@ -5,6 +5,9 @@ const nextConfig = { cacheComponents: true, productionBrowserSourceMaps: true, experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, prefetchInlining: false, }, } diff --git a/test/e2e/app-dir/segment-cache/max-prefetch-inlining/next.config.js b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/next.config.js index f917d7e092a5..688f9dd1ca8a 100644 --- a/test/e2e/app-dir/segment-cache/max-prefetch-inlining/next.config.js +++ b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/next.config.js @@ -9,6 +9,9 @@ const nextConfig = { cacheComponents: true, experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, prefetchInlining: { maxSize: Infinity, maxBundleSize: Infinity, diff --git a/test/e2e/app-dir/segment-cache/memory-pressure/next.config.js b/test/e2e/app-dir/segment-cache/memory-pressure/next.config.js index 8e2912a137c1..e02b909f2772 100644 --- a/test/e2e/app-dir/segment-cache/memory-pressure/next.config.js +++ b/test/e2e/app-dir/segment-cache/memory-pressure/next.config.js @@ -4,6 +4,9 @@ const nextConfig = { cacheComponents: true, experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, prefetchInlining: false, }, } diff --git a/test/e2e/app-dir/segment-cache/optimistic-route-cache-keying-regression/next.config.js b/test/e2e/app-dir/segment-cache/optimistic-route-cache-keying-regression/next.config.js index a5dd2df3f533..44633b7e155e 100644 --- a/test/e2e/app-dir/segment-cache/optimistic-route-cache-keying-regression/next.config.js +++ b/test/e2e/app-dir/segment-cache/optimistic-route-cache-keying-regression/next.config.js @@ -3,6 +3,9 @@ */ const nextConfig = { experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, // The client segment cache currently only writes segment data during // prefetches, not during navigations. The staleTimes feature is an // exception: it preserves route cache entries for reuse across diff --git a/test/e2e/app-dir/segment-cache/optimistic-routing-rewrite-detection-regression/next.config.js b/test/e2e/app-dir/segment-cache/optimistic-routing-rewrite-detection-regression/next.config.js index 66d413cb3105..7093e49c94a4 100644 --- a/test/e2e/app-dir/segment-cache/optimistic-routing-rewrite-detection-regression/next.config.js +++ b/test/e2e/app-dir/segment-cache/optimistic-routing-rewrite-detection-regression/next.config.js @@ -4,6 +4,9 @@ const nextConfig = { cacheComponents: true, experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, optimisticRouting: true, varyParams: true, }, diff --git a/test/e2e/app-dir/segment-cache/prefetch-auto/next.config.js b/test/e2e/app-dir/segment-cache/prefetch-auto/next.config.js index e64bae22d658..1cd801f1f09f 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-auto/next.config.js +++ b/test/e2e/app-dir/segment-cache/prefetch-auto/next.config.js @@ -3,6 +3,11 @@ */ const nextConfig = { cacheComponents: true, + experimental: { + // TODO: Migrate this test to the two-phase (app shell + data) prefetch + // behavior, then remove this override. See PR #94516. + appShells: false, + }, } module.exports = nextConfig diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/next.config.js b/test/e2e/app-dir/segment-cache/prefetch-inlining/next.config.js index 2381363710c4..3b8c2c501e70 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-inlining/next.config.js +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/next.config.js @@ -4,6 +4,9 @@ const nextConfig = { cacheComponents: true, experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, optimisticRouting: true, }, } diff --git a/test/e2e/app-dir/segment-cache/prefetch-runtime/next.config.ts b/test/e2e/app-dir/segment-cache/prefetch-runtime/next.config.ts index 2033ad0fab90..267911214ab6 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-runtime/next.config.ts +++ b/test/e2e/app-dir/segment-cache/prefetch-runtime/next.config.ts @@ -4,6 +4,9 @@ const nextConfig: NextConfig = { cacheComponents: true, productionBrowserSourceMaps: true, experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, // TODO: This test asserts on the pre-`varyParams` cache-keying behavior // for root params. Pin the fixture to the old default until the test is // updated to reflect the new shape (or until the flag is removed). diff --git a/test/e2e/app-dir/segment-cache/prefetch-scheduling/next.config.js b/test/e2e/app-dir/segment-cache/prefetch-scheduling/next.config.js index e64bae22d658..16fab28dd2a5 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-scheduling/next.config.js +++ b/test/e2e/app-dir/segment-cache/prefetch-scheduling/next.config.js @@ -2,6 +2,12 @@ * @type {import('next').NextConfig} */ const nextConfig = { + experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, + }, + cacheComponents: true, } diff --git a/test/e2e/app-dir/segment-cache/revalidation/next.config.js b/test/e2e/app-dir/segment-cache/revalidation/next.config.js index e64bae22d658..16fab28dd2a5 100644 --- a/test/e2e/app-dir/segment-cache/revalidation/next.config.js +++ b/test/e2e/app-dir/segment-cache/revalidation/next.config.js @@ -2,6 +2,12 @@ * @type {import('next').NextConfig} */ const nextConfig = { + experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, + }, + cacheComponents: true, } diff --git a/test/e2e/app-dir/segment-cache/search-params/next.config.js b/test/e2e/app-dir/segment-cache/search-params/next.config.js index 3f1e40f5a670..8f7e9dfab147 100644 --- a/test/e2e/app-dir/segment-cache/search-params/next.config.js +++ b/test/e2e/app-dir/segment-cache/search-params/next.config.js @@ -4,6 +4,9 @@ const nextConfig = { cacheComponents: true, experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, // TODO: This test asserts on the pre-`optimisticRouting` prefetch and // search-param-rewrite behavior. Pin the fixture to the old default // until the test is updated (or until the flag is removed). diff --git a/test/e2e/app-dir/segment-cache/stale-search-params-on-replace-regression/next.config.js b/test/e2e/app-dir/segment-cache/stale-search-params-on-replace-regression/next.config.js index 340e8bd09e24..a5fcfdbcf5c1 100644 --- a/test/e2e/app-dir/segment-cache/stale-search-params-on-replace-regression/next.config.js +++ b/test/e2e/app-dir/segment-cache/stale-search-params-on-replace-regression/next.config.js @@ -4,6 +4,9 @@ const nextConfig = { reactStrictMode: true, experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, // Disabling prefetch inlining avoids the `InliningHintsStale` marker // that would otherwise immediately expire the initial-state route // cache entry. The bug under test depends on that entry sticking diff --git a/test/e2e/app-dir/segment-cache/staleness/next.config.js b/test/e2e/app-dir/segment-cache/staleness/next.config.js index 81e37cfaf4bd..7d512c490475 100644 --- a/test/e2e/app-dir/segment-cache/staleness/next.config.js +++ b/test/e2e/app-dir/segment-cache/staleness/next.config.js @@ -4,6 +4,9 @@ const nextConfig = { cacheComponents: true, experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, staleTimes: { dynamic: 30, }, diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/next.config.js b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/next.config.js index 2b9c81ae5917..039cda1938af 100644 --- a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/next.config.js +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/next.config.js @@ -11,6 +11,9 @@ const nextConfig = { }, }, experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, optimisticRouting: true, prefetchInlining: false, varyParams: true, diff --git a/test/e2e/app-dir/segment-cache/vary-params/next.config.js b/test/e2e/app-dir/segment-cache/vary-params/next.config.js index a98eb2704452..ce36d493a029 100644 --- a/test/e2e/app-dir/segment-cache/vary-params/next.config.js +++ b/test/e2e/app-dir/segment-cache/vary-params/next.config.js @@ -4,6 +4,9 @@ const nextConfig = { cacheComponents: true, experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, optimisticRouting: true, prefetchInlining: false, varyParams: true, diff --git a/test/e2e/app-dir/sub-shell-generation-middleware/next.config.js b/test/e2e/app-dir/sub-shell-generation-middleware/next.config.js index 0bc951cc5865..40c6b621e5d0 100644 --- a/test/e2e/app-dir/sub-shell-generation-middleware/next.config.js +++ b/test/e2e/app-dir/sub-shell-generation-middleware/next.config.js @@ -3,6 +3,9 @@ */ const nextConfig = { experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, prefetchInlining: false, useCache: true, }, diff --git a/test/production/app-dir/use-cache-non-deterministic-args/next.config.ts b/test/production/app-dir/use-cache-non-deterministic-args/next.config.ts index fa33c7c54f24..6d9a1567b442 100644 --- a/test/production/app-dir/use-cache-non-deterministic-args/next.config.ts +++ b/test/production/app-dir/use-cache-non-deterministic-args/next.config.ts @@ -1,6 +1,12 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { + experimental: { + // TODO(appShells): migrate this test to the two-phase (app shell + + // per-page data) prefetch behavior, then remove this override. See #94516. + appShells: false, + }, + cacheComponents: true, } From 07638754235aecd26fe50576833554c589858297 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Sat, 6 Jun 2026 19:03:53 -0400 Subject: [PATCH 2/2] Make router-act ignore App Shell prefetch requests When App Shells is enabled, prefetching is split into two phases: a prefetch for the shell, and a separate phase for the per-page content. This causes a handful of the existing prefetch-related tests to fail. However, the fact that the shell is prefetched separately from the page content is an incidental detail of how prefetching works. The App Shell is conceptually part of the route, not part of the "prefetch". Or in other words, App Shell prefetching has more in common with incremental module loading than it does with prefetching in the traditional sense used in the context of web apps. So, this updates router-act to ignore App Shell requests for the purposes of making assertions about prefetch responses. As a result, most of the prefetching tests now pass regardless of whether App Shells is enabled. (There are a few remaining `appShells` overrides in test fixtures that we'll address in subsequent PRs.) --- .agents/skills/router-act/SKILL.md | 16 ++++ .../app-dir/optimistic-routing/next.config.js | 3 - .../prefetch-true-instant/next.config.js | 6 -- .../segment-cache/basic/next.config.js | 6 -- .../cached-navigations/next.config.ts | 3 - .../cdn-cache-busting/next.config.js | 3 - .../dynamic-on-hover/next.config.js | 3 - .../segment-cache/force-stale/next.config.js | 3 - .../max-prefetch-inlining.test.ts | 17 ++-- .../max-prefetch-inlining/next.config.js | 3 - .../memory-pressure/next.config.js | 3 - .../next.config.js | 3 - .../next.config.js | 3 - .../prefetch-auto/next.config.js | 5 -- .../prefetch-inlining/next.config.js | 3 - .../prefetch-inlining.test.ts | 39 ++++---- .../prefetch-runtime/next.config.ts | 3 - .../prefetch-scheduling/next.config.js | 6 -- .../segment-cache/revalidation/next.config.js | 6 -- .../next.config.js | 3 - .../segment-cache/staleness/next.config.js | 3 - .../vary-params-base-dynamic/next.config.js | 3 - .../segment-cache/vary-params/next.config.js | 3 - .../vary-params/vary-params.test.ts | 55 ++++++++---- test/lib/router-act.ts | 88 +++++++++++++++++-- 25 files changed, 167 insertions(+), 122 deletions(-) diff --git a/.agents/skills/router-act/SKILL.md b/.agents/skills/router-act/SKILL.md index 805623e59d43..d09bea29a5a6 100644 --- a/.agents/skills/router-act/SKILL.md +++ b/.agents/skills/router-act/SKILL.md @@ -60,6 +60,22 @@ await act(async () => { ... }) - Extra responses that don't match any `includes` assertion are silently ignored — you only need to assert on the responses you care about. This keeps tests decoupled from the exact number of requests the router makes. - Each `includes` expectation claims exactly one response. If the same substring appears in N separate responses, provide N separate `{ includes: '...' }` entries. +### App Shell requests are ignored by default + +When App Shells are enabled (the default when Cache Components is on), a `prefetch` is split into two phases: an **App Shell** prefetch — the param/searchParam-independent chrome of the route (layouts, loading boundaries, static shell) — and a separate per-link/per-page data prefetch. The App Shell is conceptually part of the route, not prefetch data, so **`act` ignores App Shell requests for all assertion purposes** (they carry a `next-router-prefetch: '3'` header). + +This means you generally do **not** need to account for the extra App Shell response in your assertions. If a `Loading...` fallback now arrives in both the App Shell prefetch and the per-link prefetch, you still write a single `{ includes: 'Loading...' }` — the App Shell copy is invisible to matching. Likewise, `'no-requests'` still passes even if an App Shell prefetch fires, and `block: 'reject'` won't match content that appears only in the App Shell. + +App Shell requests are still intercepted, fulfilled, and awaited (so the shell is cached and no requests are left in flight) — they just don't participate in `includes` matching, `no-requests`, `block: 'reject'`, or the "at least one request" check. An App Shell response that returns an error status (4xx/5xx) still fails the test. + +To assert on App Shell responses directly — for tests specifically about App Shell behavior — opt in at the `act` instance level: + +```typescript +const act = createRouterAct(page, { includeAppShellRequests: true }) +``` + +With this option, App Shell requests are treated like any other router request. Prefer expressing App Shell behavior through observable outcomes (e.g. an instant navigation rendering the cached shell before the data response arrives) rather than asserting on prefetch content where practical. See `test/e2e/app-dir/segment-cache/prefetch-app-shell/prefetch-app-shell.test.ts` for the canonical example. + ### What `act` Does Internally `act` intercepts all router requests — prefetches, navigations, and Server Actions — made during the scope: diff --git a/test/e2e/app-dir/optimistic-routing/next.config.js b/test/e2e/app-dir/optimistic-routing/next.config.js index 6d2135f2d7ad..4eb17d0b4a22 100644 --- a/test/e2e/app-dir/optimistic-routing/next.config.js +++ b/test/e2e/app-dir/optimistic-routing/next.config.js @@ -4,9 +4,6 @@ const nextConfig = { cacheComponents: true, experimental: { - // TODO(appShells): migrate this test to the two-phase (app shell + - // per-page data) prefetch behavior, then remove this override. See #94516. - appShells: false, optimisticRouting: true, varyParams: true, }, diff --git a/test/e2e/app-dir/prefetch-true-instant/next.config.js b/test/e2e/app-dir/prefetch-true-instant/next.config.js index 16fab28dd2a5..e64bae22d658 100644 --- a/test/e2e/app-dir/prefetch-true-instant/next.config.js +++ b/test/e2e/app-dir/prefetch-true-instant/next.config.js @@ -2,12 +2,6 @@ * @type {import('next').NextConfig} */ const nextConfig = { - experimental: { - // TODO(appShells): migrate this test to the two-phase (app shell + - // per-page data) prefetch behavior, then remove this override. See #94516. - appShells: false, - }, - cacheComponents: true, } diff --git a/test/e2e/app-dir/segment-cache/basic/next.config.js b/test/e2e/app-dir/segment-cache/basic/next.config.js index 16fab28dd2a5..e64bae22d658 100644 --- a/test/e2e/app-dir/segment-cache/basic/next.config.js +++ b/test/e2e/app-dir/segment-cache/basic/next.config.js @@ -2,12 +2,6 @@ * @type {import('next').NextConfig} */ const nextConfig = { - experimental: { - // TODO(appShells): migrate this test to the two-phase (app shell + - // per-page data) prefetch behavior, then remove this override. See #94516. - appShells: false, - }, - cacheComponents: true, } diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/next.config.ts b/test/e2e/app-dir/segment-cache/cached-navigations/next.config.ts index 9845c667d81c..ee9a05dc80b1 100644 --- a/test/e2e/app-dir/segment-cache/cached-navigations/next.config.ts +++ b/test/e2e/app-dir/segment-cache/cached-navigations/next.config.ts @@ -4,9 +4,6 @@ const nextConfig: NextConfig = { cacheComponents: true, productionBrowserSourceMaps: true, experimental: { - // TODO(appShells): migrate this test to the two-phase (app shell + - // per-page data) prefetch behavior, then remove this override. See #94516. - appShells: false, cachedNavigations: true, prefetchInlining: false, exposeTestingApiInProductionBuild: true, diff --git a/test/e2e/app-dir/segment-cache/cdn-cache-busting/next.config.js b/test/e2e/app-dir/segment-cache/cdn-cache-busting/next.config.js index 1c612e346227..eea5b256ec7c 100644 --- a/test/e2e/app-dir/segment-cache/cdn-cache-busting/next.config.js +++ b/test/e2e/app-dir/segment-cache/cdn-cache-busting/next.config.js @@ -3,9 +3,6 @@ */ const nextConfig = { experimental: { - // TODO(appShells): migrate this test to the two-phase (app shell + - // per-page data) prefetch behavior, then remove this override. See #94516. - appShells: false, validateRSCRequestHeaders: true, }, } diff --git a/test/e2e/app-dir/segment-cache/dynamic-on-hover/next.config.js b/test/e2e/app-dir/segment-cache/dynamic-on-hover/next.config.js index ad07dbab34bf..5e058adaed31 100644 --- a/test/e2e/app-dir/segment-cache/dynamic-on-hover/next.config.js +++ b/test/e2e/app-dir/segment-cache/dynamic-on-hover/next.config.js @@ -4,9 +4,6 @@ const nextConfig = { cacheComponents: true, experimental: { - // TODO(appShells): migrate this test to the two-phase (app shell + - // per-page data) prefetch behavior, then remove this override. See #94516. - appShells: false, dynamicOnHover: true, prefetchInlining: false, }, diff --git a/test/e2e/app-dir/segment-cache/force-stale/next.config.js b/test/e2e/app-dir/segment-cache/force-stale/next.config.js index 1f34e326ba69..492391b2884b 100644 --- a/test/e2e/app-dir/segment-cache/force-stale/next.config.js +++ b/test/e2e/app-dir/segment-cache/force-stale/next.config.js @@ -5,9 +5,6 @@ const nextConfig = { cacheComponents: true, productionBrowserSourceMaps: true, experimental: { - // TODO(appShells): migrate this test to the two-phase (app shell + - // per-page data) prefetch behavior, then remove this override. See #94516. - appShells: false, prefetchInlining: false, }, } diff --git a/test/e2e/app-dir/segment-cache/max-prefetch-inlining/max-prefetch-inlining.test.ts b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/max-prefetch-inlining.test.ts index c1bbc39b0990..34bda4b119aa 100644 --- a/test/e2e/app-dir/segment-cache/max-prefetch-inlining/max-prefetch-inlining.test.ts +++ b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/max-prefetch-inlining.test.ts @@ -25,9 +25,11 @@ describe('max prefetch inlining', () => { // /shared/a/b/c and /shared/a/d/e // Without inlining, prefetching each route would issue one request per // segment plus one for the head (6+ requests). With inlining enabled, - // all segment data is bundled into a single response, so revealing a - // link should produce at most 2 prefetch requests per route: one for - // /_tree and one for the inlined segment data. + // all segment data is bundled into a single response. Under App Shells a + // route is prefetched in two phases (App Shell + per-link), and each phase + // may issue a /_tree request plus an inlined segment-data request, so + // revealing a link produces at most 4 prefetch requests per route — still + // far fewer than the un-inlined per-segment requests. let rscRequestCount = 0 let page: Playwright.Page @@ -71,10 +73,13 @@ describe('max prefetch inlining', () => { } ) - // The delta should be at most 2 requests (/_tree + /_inlined). - // Without inlining, there would be 6+ individual segment requests. + // The delta counts raw `rsc` requests via the listener above (NOT through + // `act`, which ignores App Shell requests), so it includes the App Shell + // prefetch as well as the per-link prefetch. Each may issue a /_tree request + // plus an inlined segment-data request, so the delta is at most 4. Without + // inlining there would be 6+ individual segment requests. const delta = rscRequestCount - countBeforeSecondPrefetch - expect(delta).toBeLessThanOrEqual(2) + expect(delta).toBeLessThanOrEqual(4) // Navigate to the second route. Because the data was fully prefetched, // there should be no additional requests. diff --git a/test/e2e/app-dir/segment-cache/max-prefetch-inlining/next.config.js b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/next.config.js index 688f9dd1ca8a..f917d7e092a5 100644 --- a/test/e2e/app-dir/segment-cache/max-prefetch-inlining/next.config.js +++ b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/next.config.js @@ -9,9 +9,6 @@ const nextConfig = { cacheComponents: true, experimental: { - // TODO(appShells): migrate this test to the two-phase (app shell + - // per-page data) prefetch behavior, then remove this override. See #94516. - appShells: false, prefetchInlining: { maxSize: Infinity, maxBundleSize: Infinity, diff --git a/test/e2e/app-dir/segment-cache/memory-pressure/next.config.js b/test/e2e/app-dir/segment-cache/memory-pressure/next.config.js index e02b909f2772..8e2912a137c1 100644 --- a/test/e2e/app-dir/segment-cache/memory-pressure/next.config.js +++ b/test/e2e/app-dir/segment-cache/memory-pressure/next.config.js @@ -4,9 +4,6 @@ const nextConfig = { cacheComponents: true, experimental: { - // TODO(appShells): migrate this test to the two-phase (app shell + - // per-page data) prefetch behavior, then remove this override. See #94516. - appShells: false, prefetchInlining: false, }, } diff --git a/test/e2e/app-dir/segment-cache/optimistic-route-cache-keying-regression/next.config.js b/test/e2e/app-dir/segment-cache/optimistic-route-cache-keying-regression/next.config.js index 44633b7e155e..a5dd2df3f533 100644 --- a/test/e2e/app-dir/segment-cache/optimistic-route-cache-keying-regression/next.config.js +++ b/test/e2e/app-dir/segment-cache/optimistic-route-cache-keying-regression/next.config.js @@ -3,9 +3,6 @@ */ const nextConfig = { experimental: { - // TODO(appShells): migrate this test to the two-phase (app shell + - // per-page data) prefetch behavior, then remove this override. See #94516. - appShells: false, // The client segment cache currently only writes segment data during // prefetches, not during navigations. The staleTimes feature is an // exception: it preserves route cache entries for reuse across diff --git a/test/e2e/app-dir/segment-cache/optimistic-routing-rewrite-detection-regression/next.config.js b/test/e2e/app-dir/segment-cache/optimistic-routing-rewrite-detection-regression/next.config.js index 7093e49c94a4..66d413cb3105 100644 --- a/test/e2e/app-dir/segment-cache/optimistic-routing-rewrite-detection-regression/next.config.js +++ b/test/e2e/app-dir/segment-cache/optimistic-routing-rewrite-detection-regression/next.config.js @@ -4,9 +4,6 @@ const nextConfig = { cacheComponents: true, experimental: { - // TODO(appShells): migrate this test to the two-phase (app shell + - // per-page data) prefetch behavior, then remove this override. See #94516. - appShells: false, optimisticRouting: true, varyParams: true, }, diff --git a/test/e2e/app-dir/segment-cache/prefetch-auto/next.config.js b/test/e2e/app-dir/segment-cache/prefetch-auto/next.config.js index 1cd801f1f09f..e64bae22d658 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-auto/next.config.js +++ b/test/e2e/app-dir/segment-cache/prefetch-auto/next.config.js @@ -3,11 +3,6 @@ */ const nextConfig = { cacheComponents: true, - experimental: { - // TODO: Migrate this test to the two-phase (app shell + data) prefetch - // behavior, then remove this override. See PR #94516. - appShells: false, - }, } module.exports = nextConfig diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/next.config.js b/test/e2e/app-dir/segment-cache/prefetch-inlining/next.config.js index 3b8c2c501e70..2381363710c4 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-inlining/next.config.js +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/next.config.js @@ -4,9 +4,6 @@ const nextConfig = { cacheComponents: true, experimental: { - // TODO(appShells): migrate this test to the two-phase (app shell + - // per-page data) prefetch behavior, then remove this override. See #94516. - appShells: false, optimisticRouting: true, }, } diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts b/test/e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts index 63c4c48a0c74..446181f16701 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts @@ -777,30 +777,33 @@ describe('prefetch inlining', () => { // Now we're on route A. Reveal the sibling link to route B. The // runtime layout is shared between A and B, so it's already cached // and won't be re-fetched. The only new segment is the [item] page, - // which is static. But the head differs (title includes "Item: b") - // and depends on runtime data, so it must still be fetched via a - // runtime prefetch even though no other runtime request is needed. + // which is static, so it IS prefetched. But the head depends on the + // [item] param (and searchParams), so it is param-dependent and is NOT + // part of the App Shell. Under App Shells, a speculative per-link + // prefetch does not request the param-dependent head — it is deferred + // to navigation. So the static page is prefetched here, but the title + // for B is not. + await act(async () => { + await browser + .elementByCss('input[data-link-accordion="/test-independent-head/b"]') + .click() + }, [ + // The static page below the runtime layout is prefetched. + { includes: 'page-independent-head' }, + // The param-dependent head must NOT be prefetched — it is not part + // of the App Shell and arrives on navigation instead. + { includes: 'Independent Head Title: b', block: 'reject' }, + ]) + + // Navigate to route B. The param-dependent head was not prefetched, so + // it is fetched now, on navigation. await act( async () => { - await browser - .elementByCss('input[data-link-accordion="/test-independent-head/b"]') - .click() + await browser.elementByCss('a[href="/test-independent-head/b"]').click() }, { includes: 'Independent Head Title: b' } ) - // Navigate to route B. The page segment is unnecessarily marked as - // partial because the metadata outlet in the page's RSC data - // contains an unresolved reference to the dynamic metadata. This - // causes navigation to re-fetch the page even though the actual - // page content is fully static. - // TODO: The page segment should not be considered partial just - // because the metadata is dynamic. Once this is fixed, this - // navigation should not require any network requests. - await act(async () => { - await browser.elementByCss('a[href="/test-independent-head/b"]').click() - }) - expect(await browser.elementByCss('#page-independent-head').text()).toBe( 'Independent head page' ) diff --git a/test/e2e/app-dir/segment-cache/prefetch-runtime/next.config.ts b/test/e2e/app-dir/segment-cache/prefetch-runtime/next.config.ts index 267911214ab6..2033ad0fab90 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-runtime/next.config.ts +++ b/test/e2e/app-dir/segment-cache/prefetch-runtime/next.config.ts @@ -4,9 +4,6 @@ const nextConfig: NextConfig = { cacheComponents: true, productionBrowserSourceMaps: true, experimental: { - // TODO(appShells): migrate this test to the two-phase (app shell + - // per-page data) prefetch behavior, then remove this override. See #94516. - appShells: false, // TODO: This test asserts on the pre-`varyParams` cache-keying behavior // for root params. Pin the fixture to the old default until the test is // updated to reflect the new shape (or until the flag is removed). diff --git a/test/e2e/app-dir/segment-cache/prefetch-scheduling/next.config.js b/test/e2e/app-dir/segment-cache/prefetch-scheduling/next.config.js index 16fab28dd2a5..e64bae22d658 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-scheduling/next.config.js +++ b/test/e2e/app-dir/segment-cache/prefetch-scheduling/next.config.js @@ -2,12 +2,6 @@ * @type {import('next').NextConfig} */ const nextConfig = { - experimental: { - // TODO(appShells): migrate this test to the two-phase (app shell + - // per-page data) prefetch behavior, then remove this override. See #94516. - appShells: false, - }, - cacheComponents: true, } diff --git a/test/e2e/app-dir/segment-cache/revalidation/next.config.js b/test/e2e/app-dir/segment-cache/revalidation/next.config.js index 16fab28dd2a5..e64bae22d658 100644 --- a/test/e2e/app-dir/segment-cache/revalidation/next.config.js +++ b/test/e2e/app-dir/segment-cache/revalidation/next.config.js @@ -2,12 +2,6 @@ * @type {import('next').NextConfig} */ const nextConfig = { - experimental: { - // TODO(appShells): migrate this test to the two-phase (app shell + - // per-page data) prefetch behavior, then remove this override. See #94516. - appShells: false, - }, - cacheComponents: true, } diff --git a/test/e2e/app-dir/segment-cache/stale-search-params-on-replace-regression/next.config.js b/test/e2e/app-dir/segment-cache/stale-search-params-on-replace-regression/next.config.js index a5fcfdbcf5c1..340e8bd09e24 100644 --- a/test/e2e/app-dir/segment-cache/stale-search-params-on-replace-regression/next.config.js +++ b/test/e2e/app-dir/segment-cache/stale-search-params-on-replace-regression/next.config.js @@ -4,9 +4,6 @@ const nextConfig = { reactStrictMode: true, experimental: { - // TODO(appShells): migrate this test to the two-phase (app shell + - // per-page data) prefetch behavior, then remove this override. See #94516. - appShells: false, // Disabling prefetch inlining avoids the `InliningHintsStale` marker // that would otherwise immediately expire the initial-state route // cache entry. The bug under test depends on that entry sticking diff --git a/test/e2e/app-dir/segment-cache/staleness/next.config.js b/test/e2e/app-dir/segment-cache/staleness/next.config.js index 7d512c490475..81e37cfaf4bd 100644 --- a/test/e2e/app-dir/segment-cache/staleness/next.config.js +++ b/test/e2e/app-dir/segment-cache/staleness/next.config.js @@ -4,9 +4,6 @@ const nextConfig = { cacheComponents: true, experimental: { - // TODO(appShells): migrate this test to the two-phase (app shell + - // per-page data) prefetch behavior, then remove this override. See #94516. - appShells: false, staleTimes: { dynamic: 30, }, diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/next.config.js b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/next.config.js index 039cda1938af..2b9c81ae5917 100644 --- a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/next.config.js +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/next.config.js @@ -11,9 +11,6 @@ const nextConfig = { }, }, experimental: { - // TODO(appShells): migrate this test to the two-phase (app shell + - // per-page data) prefetch behavior, then remove this override. See #94516. - appShells: false, optimisticRouting: true, prefetchInlining: false, varyParams: true, diff --git a/test/e2e/app-dir/segment-cache/vary-params/next.config.js b/test/e2e/app-dir/segment-cache/vary-params/next.config.js index ce36d493a029..a98eb2704452 100644 --- a/test/e2e/app-dir/segment-cache/vary-params/next.config.js +++ b/test/e2e/app-dir/segment-cache/vary-params/next.config.js @@ -4,9 +4,6 @@ const nextConfig = { cacheComponents: true, experimental: { - // TODO(appShells): migrate this test to the two-phase (app shell + - // per-page data) prefetch behavior, then remove this override. See #94516. - appShells: false, optimisticRouting: true, prefetchInlining: false, varyParams: true, diff --git a/test/e2e/app-dir/segment-cache/vary-params/vary-params.test.ts b/test/e2e/app-dir/segment-cache/vary-params/vary-params.test.ts index d0928d01f2f1..4f1172321fe5 100644 --- a/test/e2e/app-dir/segment-cache/vary-params/vary-params.test.ts +++ b/test/e2e/app-dir/segment-cache/vary-params/vary-params.test.ts @@ -355,17 +355,28 @@ describe('segment cache - vary params', () => { await toggle.click() }, [{ includes: 'Page: aaa' }, { includes: 'Static page body' }]) - // Second prefetch: head re-fetched (metadata varies on slug), - // but body is cached (body doesn't access slug) + // Second link with a different slug. The body is param-independent and + // already cached (via the App Shell), and the param-dependent head is NOT + // speculatively prefetched on link reveal under App Shells — it's deferred + // to navigation. So revealing the link fires no request at all, which also + // proves the body is reused from cache (changing the slug doesn't + // re-prefetch it). await act(async () => { const toggle = await browser.elementByCss( 'input[data-link-accordion="/metadata/bbb"]' ) await toggle.click() - }, [ - { includes: 'Page: bbb' }, - { includes: 'Static page body', block: 'reject' }, - ]) + }, 'no-requests') + + // Navigating fetches the param-dependent head for bbb (metadata varies on + // slug, so it can't reuse the cached aaa head). + await act( + async () => { + const link = await browser.elementByCss('a[href="/metadata/bbb"]') + await link.click() + }, + { includes: 'Page: bbb' } + ) }) it('caches head segment when generateMetadata does not access params', async () => { @@ -409,16 +420,17 @@ describe('segment cache - vary params', () => { }, }) - // First prefetch fetches both layout and page + // First prefetch fetches the route. The page's static shell (the + // "Page category:" label) is param-independent and lands in the App Shell + // prefetch, which `act` ignores; only its resolved value arrives on + // navigation. What's observable here is the per-link prefetch of the + // param-dependent layout content for this concrete param. await act(async () => { const toggle = await browser.elementByCss( 'input[data-link-accordion="/page-reuse/electronics/phone"]' ) await toggle.click() - }, [ - { includes: 'Layout: electronics/phone' }, - { includes: 'Page category:' }, - ]) + }, [{ includes: 'Layout: electronics/phone' }]) // Second prefetch: layout re-fetched (varies on item), // page is cached (only varies on category) @@ -716,14 +728,25 @@ describe('segment cache - vary params', () => { { includes: 'Runtime Metadata: aaa' } ) - // Second prefetch with different slug triggers a new request - // (metadata varies on slug, so it can't reuse the cache) + // Second link with a different slug. The param-independent body is already + // cached (App Shell), and the param-dependent head is NOT speculatively + // prefetched on link reveal under App Shells — it's deferred to navigation. + // So revealing the link fires no request. + await act(async () => { + const toggle = await browser.elementByCss( + 'input[data-link-accordion="/runtime-prefetch-metadata/bbb"]' + ) + await toggle.click() + }, 'no-requests') + + // Navigating fetches the param-dependent head for bbb (metadata varies on + // slug, so it can't reuse the cached aaa head). await act( async () => { - const toggle = await browser.elementByCss( - 'input[data-link-accordion="/runtime-prefetch-metadata/bbb"]' + const link = await browser.elementByCss( + 'a[href="/runtime-prefetch-metadata/bbb"]' ) - await toggle.click() + await link.click() }, { includes: 'Runtime Metadata: bbb' } ) diff --git a/test/lib/router-act.ts b/test/lib/router-act.ts index a7daeb275286..d465229d8559 100644 --- a/test/lib/router-act.ts +++ b/test/lib/router-act.ts @@ -2,9 +2,27 @@ import type * as Playwright from 'playwright' import { diff } from 'jest-diff' import { equals } from '@jest/expect-utils' +// Mirrors NEXT_ROUTER_PREFETCH_HEADER from the Next.js client. App Shell +// prefetches carry the value '3' (FetchStrategy.RuntimeShell). The App Shell is +// the param/searchParam-independent chrome of a route — conceptually part of the +// route itself, not prefetch data in the way we normally think of it. By default +// we therefore exclude App Shell requests from all `act` assertion logic +// (`includes` matching, `no-requests`, `block: 'reject'`, and the "at least one +// request" check). They are still intercepted, fulfilled, and awaited so that the +// browser caches the shell and no requests are left in flight. Pass +// `includeAppShellRequests: true` to `createRouterAct` to assert on them directly +// (e.g. when testing App Shell behavior specifically). +const NEXT_ROUTER_PREFETCH_HEADER = 'next-router-prefetch' +const APP_SHELL_PREFETCH_VALUE = '3' + type Batch = { pendingRequestChecks: Set> pendingRequests: Set + // The number of pending requests in `pendingRequests` that are NOT App Shell + // requests. App Shell requests don't count toward the "at least one request" + // check, so we track this separately rather than scanning the set. Maintained + // in lockstep with `pendingRequests` membership. + pendingNonAppShellRequests: number } type PendingRSCRequest = { @@ -17,6 +35,10 @@ type PendingRSCRequest = { status: number }> didProcess: boolean + // True if this is an App Shell prefetch request that should be ignored for + // assertion purposes (see note above). Always false when the caller passes + // `includeAppShellRequests: true`. + isAppShell: boolean } let currentBatch: Batch | null = null @@ -63,8 +85,19 @@ export function createRouterAct( * provided, all error status codes are disallowed (400+). */ allowErrorStatusCodes?: number[] + /** + * By default, App Shell prefetch requests (those with a + * `next-router-prefetch: '3'` header) are ignored for the purposes of + * assertion matching, `no-requests`, `block: 'reject'`, and the "at least + * one request" check. They are still intercepted, fulfilled, and awaited. + * + * Set this to `true` to treat App Shell requests like any other router + * request. Use this when writing tests for App Shell behavior specifically. + */ + includeAppShellRequests?: boolean } ): (scope: () => Promise | T, config?: ActConfig) => Promise { + const includeAppShellRequests = options?.includeAppShellRequests ?? false /** * Helper function to wait for requestIdleCallback with retry logic. * Retries up to 3 times if "Execution context was destroyed" error occurs. @@ -214,12 +247,21 @@ export function createRouterAct( headers['rsc'] !== undefined || // Matches navigations and prefetches headers['next-action'] !== undefined // Matches Server Actions + // App Shell prefetch requests are intercepted and fulfilled like any + // other router request, but (unless the caller opts in) they don't + // participate in any assertion logic. See the note at the top of + // this file. + const isAppShell = + !includeAppShellRequests && + headers[NEXT_ROUTER_PREFETCH_HEADER] === APP_SHELL_PREFETCH_VALUE + if (isRouterRequest) { // This request was initiated by the Next.js Router. Intercept it and // add it to the current batch. pendingRequests.add({ url: request.url(), route, + isAppShell, // `act` controls the timing of when responses reach the client, // but it should not affect the timing of when requests reach the // server; we pass the request to the server the immediately. @@ -254,9 +296,14 @@ export function createRouterAct( })(), didProcess: false, }) - if (onDidIssueFirstRequest !== null) { - onDidIssueFirstRequest() - onDidIssueFirstRequest = null + // App Shell requests don't count toward the "at least one request" + // check, so only track and signal for non-App-Shell requests. + if (!isAppShell) { + batch.pendingNonAppShellRequests++ + if (onDidIssueFirstRequest !== null) { + onDidIssueFirstRequest() + onDidIssueFirstRequest = null + } } return } @@ -280,6 +327,7 @@ export function createRouterAct( const orphanedRequests = batch.pendingRequests batch.pendingRequests = new Set() batch.pendingRequestChecks = new Set() + batch.pendingNonAppShellRequests = 0 await Promise.all( Array.from(orphanedRequests).map((item) => item.route?.continue()) ) @@ -297,6 +345,7 @@ export function createRouterAct( const batch: Batch = { pendingRequestChecks: new Set(), pendingRequests: new Set(), + pendingNonAppShellRequests: 0, } currentBatch = batch await page.route('**/*', routeHandler) @@ -305,8 +354,12 @@ export function createRouterAct( // Call the user-provided scope function const returnValue = await scope() - // Wait until the first request is initiated, up to some timeout. - if (expectedResponses !== null && batch.pendingRequests.size === 0) { + // Wait until the first request is initiated, up to some timeout. App Shell + // requests don't count, so check the non-App-Shell pending request count. + if ( + expectedResponses !== null && + batch.pendingNonAppShellRequests === 0 + ) { await new Promise((resolve, reject) => { const timerId = setTimeout(() => { error.message = 'Timed out waiting for a request to be initiated.' @@ -349,13 +402,21 @@ export function createRouterAct( const route = item.route const url = item.url + // This request is being removed from `pendingRequests` for + // processing. Keep the non-App-Shell counter in lockstep. (If it ends + // up blocked and transferred to the outer batch, that batch's counter + // is incremented when the transfer happens, below.) + if (!item.isAppShell) { + batch.pendingNonAppShellRequests-- + } + let shouldBlock = false const fulfilled = await item.result if (item.didProcess) { // This response was already processed by an inner `act` call. } else { item.didProcess = true - if (expectedResponses === null) { + if (!item.isAppShell && expectedResponses === null) { error.message = ` Expected no network requests to be initiated. @@ -368,6 +429,8 @@ ${fulfilled.body} throw error } + // The error-status check applies to all requests, including App + // Shell requests — a 4xx/5xx App Shell is a real failure. if ( fulfilled.status >= 400 && (allowStatuses === null || @@ -385,7 +448,7 @@ ${fulfilled.body} ` throw error } - if (forbiddenResponses !== null) { + if (!item.isAppShell && forbiddenResponses !== null) { for (const forbiddenResponse of forbiddenResponses) { const includes = forbiddenResponse.includes if (fulfilled.body.includes(includes)) { @@ -401,7 +464,7 @@ ${fulfilled.body} } } } - if (expectedResponses !== null) { + if (!item.isAppShell && expectedResponses !== null) { // Check if this response matches any of the expectations. // // @@ -539,7 +602,13 @@ ${fulfilled.body} } })(), didProcess: false, + // The target of a redirect is a navigation, not an App + // Shell prefetch. + isAppShell: false, }) + // Keep the counter in lockstep with the add above (drained + // and decremented on the next iteration of the while loop). + batch.pendingNonAppShellRequests++ page.off('response', handleResponse) page.off('requestfailed', handleFailure) resolve() @@ -612,6 +681,9 @@ ${fulfilled.body} if (remaining.size !== 0 && prevBatch !== null) { for (const item of remaining) { prevBatch.pendingRequests.add(item) + if (!item.isAppShell) { + prevBatch.pendingNonAppShellRequests++ + } } }