Skip to content

Commit

Permalink
Add Turbopack support for 'use cache' in route handlers (#71306)
Browse files Browse the repository at this point in the history
Adds the following missing pieces to Turbopack:
- apply server action transforms to route handler modules
- generate client reference manifests for route handler app endpoints
- make `experimental.cacheLife` config serialisable, to be injected into
the edge route handler modules
    - the `nextConfig` injection was already implemented in:
    #71258
  • Loading branch information
unstubbable authored Oct 15, 2024
1 parent 872cb61 commit 01117b2
Show file tree
Hide file tree
Showing 6 changed files with 143 additions and 94 deletions.
28 changes: 16 additions & 12 deletions crates/next-api/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -815,13 +815,12 @@ impl AppEndpoint {

let app_entry = self.app_endpoint_entry().await?;

let (process_client, process_ssr) = match this.ty {
AppEndpointType::Page { ty, .. } => (true, matches!(ty, AppPageEndpointType::Html)),
// NOTE(alexkirsz) For routes, technically, a lot of the following code is not needed,
// as we know we won't have any client references. However, for now, for simplicity's
// sake, we just do the same thing as for pages.
AppEndpointType::Route { .. } => (false, false),
AppEndpointType::Metadata { .. } => (false, false),
let (process_client_components, process_client_assets, process_ssr) = match this.ty {
AppEndpointType::Page { ty, .. } => {
(true, true, matches!(ty, AppPageEndpointType::Html))
}
AppEndpointType::Route { .. } => (true, false, false),
AppEndpointType::Metadata { .. } => (false, false, false),
};

let node_root = this.app_project.project().node_root();
Expand All @@ -843,7 +842,7 @@ impl AppEndpoint {
let client_chunking_context = this.app_project.project().client_chunking_context();

let (app_server_reference_modules, client_dynamic_imports, client_references) =
if process_client {
if process_client_components {
let client_shared_chunk_group = get_app_client_shared_chunk_group(
AssetIdent::from_path(this.app_project.project().project_path())
.with_modifier(client_shared_chunks()),
Expand Down Expand Up @@ -902,7 +901,7 @@ impl AppEndpoint {
NextRuntime::Edge => this
.app_project
.project()
.edge_chunking_context(process_client),
.edge_chunking_context(process_client_assets),
})
} else {
None
Expand Down Expand Up @@ -1138,7 +1137,7 @@ impl AppEndpoint {
let chunking_context = this
.app_project
.project()
.edge_chunking_context(process_client);
.edge_chunking_context(process_client_assets);
let mut evaluatable_assets = this
.app_project
.edge_rsc_runtime_entries()
Expand Down Expand Up @@ -1300,7 +1299,7 @@ impl AppEndpoint {
let chunking_context = this
.app_project
.project()
.server_chunking_context(process_client);
.server_chunking_context(process_client_assets);

if let Some(app_server_reference_modules) = app_server_reference_modules {
let (loader, manifest) = create_server_actions_manifest(
Expand Down Expand Up @@ -1354,7 +1353,12 @@ impl AppEndpoint {
.server_component_entries
.iter()
.copied()
.take(client_references.server_component_entries.len() - 1)
.take(
client_references
.server_component_entries
.len()
.saturating_sub(1),
)
{
let span = tracing::trace_span!(
"layout segment",
Expand Down
56 changes: 56 additions & 0 deletions crates/next-core/src/next_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@ pub struct ExperimentalConfig {
after: Option<bool>,
amp: Option<serde_json::Value>,
app_document_preloading: Option<bool>,
cache_life: Option<FxIndexMap<String, CacheLifeProfile>>,
case_sensitive_routes: Option<bool>,
cpus: Option<f64>,
cra_compat: Option<bool>,
Expand Down Expand Up @@ -581,6 +582,61 @@ pub struct ExperimentalConfig {
worker_threads: Option<bool>,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TraceRawVcs)]
#[serde(rename_all = "camelCase")]
pub struct CacheLifeProfile {
#[serde(skip_serializing_if = "Option::is_none")]
pub stale: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub revalidate: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expire: Option<u32>,
}

#[test]
fn test_cache_life_profiles() {
let json = serde_json::json!({
"cacheLife": {
"frequent": {
"stale": 19,
"revalidate": 100,
},
}
});

let config: ExperimentalConfig = serde_json::from_value(json).unwrap();
let mut expected_cache_life = FxIndexMap::default();

expected_cache_life.insert(
"frequent".to_string(),
CacheLifeProfile {
stale: Some(19),
revalidate: Some(100),
expire: None,
},
);

assert_eq!(config.cache_life, Some(expected_cache_life));
}

#[test]
fn test_cache_life_profiles_invalid() {
let json = serde_json::json!({
"cacheLife": {
"invalid": {
"stale": "invalid_value",
},
}
});

let result: Result<ExperimentalConfig, _> = serde_json::from_value(json);

assert!(
result.is_err(),
"Deserialization should fail due to invalid 'stale' value type"
);
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TraceRawVcs)]
#[serde(rename_all = "lowercase")]
pub enum ExperimentalPartialPrerenderingIncrementalValue {
Expand Down
7 changes: 7 additions & 0 deletions crates/next-core/src/next_server/transforms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ pub async fn get_next_server_transforms_rules(
ActionsTransform::Client,
mdx_rs,
));

is_app_dir = true;

false
Expand All @@ -105,7 +106,13 @@ pub async fn get_next_server_transforms_rules(
true
}
ServerContextType::AppRoute { .. } => {
rules.push(get_server_actions_transform_rule(
ActionsTransform::Server,
mdx_rs,
));

is_app_dir = true;

false
}
ServerContextType::Middleware { .. } | ServerContextType::Instrumentation { .. } => false,
Expand Down
82 changes: 38 additions & 44 deletions test/e2e/app-dir/dynamic-io/dynamic-io.routes.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/* eslint-disable jest/no-standalone-expect */
import { nextTestSetup } from 'e2e-utils'

describe('dynamic-io', () => {
const { next, isNextDev, isTurbopack, skipped } = nextTestSetup({
const { next, isNextDev, skipped } = nextTestSetup({
files: __dirname,
skipDeployment: true,
})
Expand All @@ -11,8 +10,6 @@ describe('dynamic-io', () => {
return
}

const itSkipTurbopack = isTurbopack ? it.skip : it

let cliIndex = 0
beforeEach(() => {
cliIndex = next.cliOutput.length
Expand Down Expand Up @@ -209,56 +206,53 @@ describe('dynamic-io', () => {
expect(message2).toEqual(json.message2)
})

itSkipTurbopack(
'should prerender GET route handlers that have entirely cached io ("use cache")',
async () => {
let str = await next.render('/routes/use_cache-cached', {})
let json = JSON.parse(str)

let message1 = json.message1
let message2 = json.message2

if (isNextDev) {
expect(json.value).toEqual('at runtime')
expect(typeof message1).toBe('string')
expect(typeof message2).toBe('string')
} else {
expect(json.value).toEqual('at buildtime')
expect(typeof message1).toBe('string')
expect(typeof message2).toBe('string')
}

str = await next.render('/routes/use_cache-cached', {})
json = JSON.parse(str)

if (isNextDev) {
expect(json.value).toEqual('at runtime')
expect(message1).toEqual(json.message1)
expect(message2).toEqual(json.message2)
} else {
expect(json.value).toEqual('at buildtime')
expect(message1).toEqual(json.message1)
expect(message2).toEqual(json.message2)
}

str = await next.render('/routes/-edge/use_cache-cached', {})
json = JSON.parse(str)

message1 = json.message1
message2 = json.message2
it('should prerender GET route handlers that have entirely cached io ("use cache")', async () => {
let str = await next.render('/routes/use_cache-cached', {})
let json = JSON.parse(str)

let message1 = json.message1
let message2 = json.message2

if (isNextDev) {
expect(json.value).toEqual('at runtime')
expect(typeof message1).toBe('string')
expect(typeof message2).toBe('string')
} else {
expect(json.value).toEqual('at buildtime')
expect(typeof message1).toBe('string')
expect(typeof message2).toBe('string')
}

str = await next.render('/routes/-edge/use_cache-cached', {})
json = JSON.parse(str)
str = await next.render('/routes/use_cache-cached', {})
json = JSON.parse(str)

if (isNextDev) {
expect(json.value).toEqual('at runtime')
expect(message1).toEqual(json.message1)
expect(message2).toEqual(json.message2)
} else {
expect(json.value).toEqual('at buildtime')
expect(message1).toEqual(json.message1)
expect(message2).toEqual(json.message2)
}
)

str = await next.render('/routes/-edge/use_cache-cached', {})
json = JSON.parse(str)

message1 = json.message1
message2 = json.message2

expect(json.value).toEqual('at runtime')
expect(typeof message1).toBe('string')
expect(typeof message2).toBe('string')

str = await next.render('/routes/-edge/use_cache-cached', {})
json = JSON.parse(str)

expect(json.value).toEqual('at runtime')
expect(message1).toEqual(json.message1)
expect(message2).toEqual(json.message2)
})

it('should not prerender GET route handlers that have some uncached io (unstable_cache)', async () => {
let str = await next.render('/routes/io-mixed', {})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,20 @@
/* eslint-disable jest/no-standalone-expect */
import { nextTestSetup } from 'e2e-utils'

// Explicitly don't mix route handlers with pages in this test app, to make sure
// that this also works in isolation.
describe('use-cache-route-handler-only', () => {
const { next, isTurbopack } = nextTestSetup({
const { next } = nextTestSetup({
files: __dirname,
})

const itSkipTurbopack = isTurbopack ? it.skip : it

itSkipTurbopack('should cache results in node route handlers', async () => {
it('should cache results in node route handlers', async () => {
const response = await next.fetch('/node')
const { rand1, rand2 } = await response.json()

expect(rand1).toEqual(rand2)
})

itSkipTurbopack('should cache results in edge route handlers', async () => {
it('should cache results in edge route handlers', async () => {
const response = await next.fetch('/edge')
const { rand1, rand2 } = await response.json()

Expand Down
55 changes: 23 additions & 32 deletions test/e2e/app-dir/use-cache/use-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,46 +76,37 @@ describe('use-cache', () => {
}
})

itSkipTurbopack('should cache results in route handlers', async () => {
it('should cache results in route handlers', async () => {
const response = await next.fetch('/api')
const { rand1, rand2 } = await response.json()

expect(rand1).toEqual(rand2)
})

if (isNextStart) {
itSkipTurbopack(
'should match the expected revalidate config on the prerender manifest',
async () => {
const prerenderManifest = JSON.parse(
await next.readFile('.next/prerender-manifest.json')
)

expect(prerenderManifest.version).toBe(4)
expect(
prerenderManifest.routes['/cache-life'].initialRevalidateSeconds
).toBe(100)
}
)
it('should match the expected revalidate config on the prerender manifest', async () => {
const prerenderManifest = JSON.parse(
await next.readFile('.next/prerender-manifest.json')
)

itSkipTurbopack(
'should match the expected stale config in the page header',
async () => {
const meta = JSON.parse(
await next.readFile('.next/server/app/cache-life.meta')
)
expect(meta.headers['x-nextjs-stale-time']).toBe('19')
}
)
expect(prerenderManifest.version).toBe(4)
expect(
prerenderManifest.routes['/cache-life'].initialRevalidateSeconds
).toBe(100)
})

itSkipTurbopack(
'should propagate unstable_cache tags correctly',
async () => {
const meta = JSON.parse(
await next.readFile('.next/server/app/cache-tag.meta')
)
expect(meta.headers['x-next-cache-tags']).toContain('a,c,b')
}
)
it('should match the expected stale config in the page header', async () => {
const meta = JSON.parse(
await next.readFile('.next/server/app/cache-life.meta')
)
expect(meta.headers['x-nextjs-stale-time']).toBe('19')
})

it('should propagate unstable_cache tags correctly', async () => {
const meta = JSON.parse(
await next.readFile('.next/server/app/cache-tag.meta')
)
expect(meta.headers['x-next-cache-tags']).toContain('a,c,b')
})
}
})

0 comments on commit 01117b2

Please sign in to comment.