Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions crates/next-api/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -869,7 +869,7 @@ impl ProjectContainer {
self.project().entrypoints()
}

/// See [Project::hmr_chunk_names].
/// See [`Project::hmr_chunk_names`].
#[turbo_tasks::function]
pub fn hmr_chunk_names(self: Vc<Self>, target: HmrTarget) -> Vc<Vec<RcStr>> {
self.project().hmr_chunk_names(target)
Expand Down Expand Up @@ -2325,8 +2325,9 @@ impl Project {
}

/// Gets a list of all HMR chunk names that can be subscribed to for the
/// specified target. This is only needed for testing purposes and isn't
/// used in real apps.
/// specified target. Used by the dev server to set up server-side HMR
/// subscriptions for all Node.js App Router entries (pages and route
/// handlers).
#[turbo_tasks::function]
pub async fn hmr_chunk_names(self: Vc<Self>, target: HmrTarget) -> Result<Vc<Vec<RcStr>>> {
if let Some(map) = self.await?.versioned_content_map {
Expand Down
10 changes: 8 additions & 2 deletions crates/next-core/src/next_app/app_route_entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,19 +71,25 @@ pub async fn get_app_route_entry(
.unwrap_or("\"\"");

// Load the file from the next.js codebase.
// `getUserland` returns the user route entry. In dev it's called at request
// time for HMR
let userland_getter = format!("() => require(\"{}\")", inner);
let virtual_source = load_next_js_template(
"app-route.js",
project_root.clone(),
[
("VAR_USERLAND", &*inner),
("VAR_DEFINITION_PAGE", &*page.to_string()),
("VAR_DEFINITION_PATHNAME", &pathname),
("VAR_DEFINITION_FILENAME", path.file_stem().unwrap()),
// TODO(alexkirsz) Is this necessary?
("VAR_DEFINITION_BUNDLE_PATH", ""),
("VAR_RESOLVED_PAGE_PATH", &path.value_to_string().await?),
("VAR_USERLAND", &inner),
],
[("nextConfigOutput", output_type)],
[
("nextConfigOutput", output_type),
("__next_app_require__", &userland_getter),
],
[],
)
.await?;
Expand Down
24 changes: 20 additions & 4 deletions packages/next/src/build/templates/app-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
AppRouteRouteModule,
type AppRouteRouteHandlerContext,
type AppRouteRouteModuleOptions,
type AppRouteUserlandModule,
} from '../../server/route-modules/app-route/module.compiled'
import { RouteKind } from '../../server/route-kind'
import { patchFetch as _patchFetch } from '../../server/lib/patch-fetch'
Expand Down Expand Up @@ -35,17 +36,22 @@ import {
type ResponseCacheEntry,
type ResponseGenerator,
} from '../../server/response-cache'

import * as userland from 'VAR_USERLAND'

// These are injected by the loader afterwards. This is injected as a variable
// instead of a replacement because this could also be `undefined` instead of
// an empty string.
declare const nextConfigOutput: AppRouteRouteModuleOptions['nextConfigOutput']

// We inject the nextConfigOutput here so that we can use them in the route
// module.
// __next_app_require__ is injected by the Turbopack loader as a zero-argument
// getter for the userland module. In dev mode it hits devModuleCache on each
// call so server HMR picks up updated exports without re-executing the entry
// chunk. Only injected for Turbopack; webpack uses the static import below.
declare const __next_app_require__: () => AppRouteUserlandModule

// We inject the nextConfigOutput and (for Turbopack) __next_app_require__ here.
// INJECT:nextConfigOutput
// INJECT:__next_app_require__

const routeModule = new AppRouteRouteModule({
definition: {
Expand All @@ -59,7 +65,17 @@ const routeModule = new AppRouteRouteModule({
relativeProjectDir: process.env.__NEXT_RELATIVE_PROJECT_DIR || '',
resolvedPagePath: 'VAR_RESOLVED_PAGE_PATH',
nextConfigOutput,
userland,
// Turbopack dev: use a getter so each request fetches fresh exports from
// devModuleCache, enabling server HMR without re-executing the entry chunk.
// Turbopack require() is synchronous even for modules with ESM externals.
//
// Webpack (dev or prod) and Turbopack prod: use the statically imported
// userland module. The static import ensures webpack properly initializes
// async modules (e.g. ESM-only serverExternalPackages) before use — a plain
// require() on a webpack async module returns a Promise, not the exports.
...(process.env.TURBOPACK && process.env.__NEXT_DEV_SERVER
? { getUserland: __next_app_require__ }
: { userland: userland as AppRouteUserlandModule }),
})

// Pull out the exports that we need to expose from the module. This should
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export async function createAppRouteCode({
},
{
nextConfigOutput: JSON.stringify(nextConfigOutput),
// process.env.TURBOPACK is replaced with false at webpack build time, so
// the getUserland branch referencing __next_app_require__ is DCE'd. The
// injection is still required by the template expansion machinery.
__next_app_require__: `() => require(${JSON.stringify(resolvedPagePath)})`,
}
)
}
27 changes: 11 additions & 16 deletions packages/next/src/server/dev/hot-reloader-turbopack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,31 +602,26 @@ export async function createHotReloaderTurbopack(
join(distDir, p)
)

const { type: entryType, page: entryPage } = splitEntryKey(key)
const isAppPage =
entryType === 'app' &&
currentEntrypoints.app.get(entryPage)?.type === 'app-page'
const { type: entryType } = splitEntryKey(key)

// Server HMR only applies to app router pages since these use the Turbopack runtime.
// Currently, this is only app router pages.
//
// This excludes:
// - Pages Router pages
// - Edge routes
// - Middleware
// - App Router route handlers (route.ts)
// Server HMR applies to all App Router entries built with the Turbopack
// Node.js runtime: both app pages and route handlers. Edge routes,
// Pages Router pages, and middleware/instrumentation do not use the
// Turbopack Node.js dev runtime and are excluded.
const usesServerHmr =
serverFastRefresh && isAppPage && writtenEndpoint.type !== 'edge'
serverFastRefresh &&
entryType === 'app' &&
writtenEndpoint.type !== 'edge'

const filesToDelete: string[] = []
for (const file of serverPaths) {
clearModuleContext(file)

const relativePath = relative(distDir, file)
if (
// For Pages Router, edge routes, middleware, and manifest files
// (e.g., *_client-reference-manifest.js): clear the sharedCache in
// evalManifest(), Node.js require.cache, and edge runtime module contexts.
// For Pages Router, edge routes, middleware, and manifest files:
// clear the sharedCache in evalManifest(), Node.js require.cache,
// and edge runtime module contexts.
force ||
!usesServerHmr ||
!serverHmrSubscriptions?.has(relativePath)
Expand Down
71 changes: 58 additions & 13 deletions packages/next/src/server/route-modules/app-route/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,21 @@ export type AppRouteUserlandModule = AppRouteHandlers &
* module from the bundled code.
*/
export interface AppRouteRouteModuleOptions
extends RouteModuleOptions<AppRouteRouteDefinition, AppRouteUserlandModule> {
extends Omit<
RouteModuleOptions<AppRouteRouteDefinition, AppRouteUserlandModule>,
'userland'
> {
readonly resolvedPagePath: string
readonly nextConfigOutput: NextConfig['output']
/**
* The userland module, or a getter that returns it. Provide `userland`
* directly for static (production/webpack) use. Provide `getUserland` for
* Turbopack dev with server HMR so the getter queries devModuleCache on each
* request — avoiding re-execution of the entry chunk when deps change.
* Exactly one of the two must be provided.
*/
readonly userland?: AppRouteUserlandModule
readonly getUserland?: () => AppRouteUserlandModule
}

/**
Expand Down Expand Up @@ -212,32 +224,42 @@ export class AppRouteRouteModule extends RouteModule<
public readonly resolvedPagePath: string
public readonly nextConfigOutput: NextConfig['output'] | undefined

private readonly methods: Record<HTTP_METHOD, AppRouteHandlerFn>
private readonly hasNonStaticMethods: boolean
private readonly dynamic: AppRouteUserlandModule['dynamic']
private readonly _getUserland?: () => AppRouteUserlandModule

private methods: Record<HTTP_METHOD, AppRouteHandlerFn>
private hasNonStaticMethods: boolean
private dynamic: AppRouteUserlandModule['dynamic']

constructor({
userland,
getUserland,
definition,
distDir,
relativeProjectDir,
resolvedPagePath,
nextConfigOutput,
}: AppRouteRouteModuleOptions) {
super({ userland, definition, distDir, relativeProjectDir })
const initialUserland = getUserland ? getUserland() : userland!
super({
userland: initialUserland,
definition,
distDir,
relativeProjectDir,
})
this._getUserland = getUserland

this.resolvedPagePath = resolvedPagePath
this.nextConfigOutput = nextConfigOutput

// Automatically implement some methods if they aren't implemented by the
// userland module.
this.methods = autoImplementMethods(userland)
this.methods = autoImplementMethods(initialUserland)

// Get the non-static methods for this route.
this.hasNonStaticMethods = hasNonStaticMethods(userland)
this.hasNonStaticMethods = hasNonStaticMethods(initialUserland)

// Get the dynamic property from the userland module.
this.dynamic = this.userland.dynamic
this.dynamic = initialUserland.dynamic
if (this.nextConfigOutput === 'export') {
if (this.dynamic === 'force-dynamic') {
throw new Error(
Expand Down Expand Up @@ -286,6 +308,17 @@ export class AppRouteRouteModule extends RouteModule<
}
}

/**
* Returns the current userland module. When a `getUserland` getter was
* provided (Turbopack dev + server HMR), this queries devModuleCache on
* every call so that HMR-updated exports are picked up per-request.
*/
private getEffectiveUserland(): AppRouteUserlandModule {
return this._getUserland
? this._getUserland()
: (this.userland as AppRouteUserlandModule)
}

/**
* Resolves the handler function for the given method.
*
Expand All @@ -296,6 +329,12 @@ export class AppRouteRouteModule extends RouteModule<
// Ensure that the requested method is a valid method (to prevent RCE's).
if (!isHTTPMethod(method)) return () => new Response(null, { status: 400 })

// When a getUserland getter is provided, resolve methods fresh each request
// so HMR-updated handlers are picked up from devModuleCache.
if (this._getUserland) {
return autoImplementMethods(this._getUserland())[method]
}

// Return the handler.
return this.methods[method]
}
Expand Down Expand Up @@ -349,7 +388,7 @@ export class AppRouteRouteModule extends RouteModule<
let res: unknown
try {
if (isStaticGeneration) {
const userlandRevalidate = this.userland.revalidate
const userlandRevalidate = this.getEffectiveUserland().revalidate
const defaultRevalidate: number =
// If the static generation store does not have a revalidate value
// set, then we should set it the revalidate value from the userland
Expand Down Expand Up @@ -692,6 +731,12 @@ export class AppRouteRouteModule extends RouteModule<
req: NextRequest,
context: AppRouteRouteHandlerContext
): Promise<Response> {
// Resolve the effective userland module once per request. When a
// getUserland getter is present (Turbopack dev + server HMR), this queries
// devModuleCache so HMR-updated exports are visible on the next request
// without re-executing the entry chunk.
const userland = this.getEffectiveUserland()

// Get the handler function for the given method.
const handler = this.resolve(req.method)

Expand All @@ -704,7 +749,7 @@ export class AppRouteRouteModule extends RouteModule<
}

// Add the fetchCache option to the renderOpts.
staticGenerationContext.renderOpts.fetchCache = this.userland.fetchCache
staticGenerationContext.renderOpts.fetchCache = userland.fetchCache

const actionStore: ActionStore = {
isAppRoute: true,
Expand Down Expand Up @@ -738,7 +783,7 @@ export class AppRouteRouteModule extends RouteModule<
this.workAsyncStorage.run(workStore, async () => {
// Check to see if we should bail out of static generation based on
// having non-static methods.
if (this.hasNonStaticMethods) {
if (hasNonStaticMethods(userland)) {
if (workStore.isStaticGeneration) {
const err = new DynamicServerError(
'Route is configured with methods that cannot be statically generated.'
Expand All @@ -754,7 +799,7 @@ export class AppRouteRouteModule extends RouteModule<
let request = req

// Update the static generation store based on the dynamic property.
switch (this.dynamic) {
switch (userland.dynamic) {
case 'force-dynamic': {
// Routes of generated paths should be dynamic
workStore.forceDynamic = true
Expand Down Expand Up @@ -790,7 +835,7 @@ export class AppRouteRouteModule extends RouteModule<
request = proxyNextRequest(req, workStore)
break
default:
this.dynamic satisfies never
userland.dynamic satisfies never
}

const tracer = getTracer()
Expand Down
3 changes: 3 additions & 0 deletions test/development/app-dir/server-hmr/app/api/with-dep/dep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const _hmrTrigger = 0
export const depMessage = 'original message'
export const depEvaluatedAt = Date.now()
13 changes: 13 additions & 0 deletions test/development/app-dir/server-hmr/app/api/with-dep/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { depMessage, depEvaluatedAt } from './dep'

const routeEvaluatedAt = Date.now()
const routeVersion = 'v1'

export async function GET() {
return Response.json({
depMessage,
depEvaluatedAt,
routeEvaluatedAt,
routeVersion,
})
}
28 changes: 28 additions & 0 deletions test/development/app-dir/server-hmr/server-hmr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,5 +177,33 @@ describe('server-hmr', () => {
expect(updated).toBe('version: 1')
})
})

itTurbopackDev(
'does not re-evaluate an unmodified dependency when route changes',
async () => {
const initial = await next
.fetch('/api/with-dep')
.then((res) => res.json())
expect(initial.routeVersion).toBe('v1')
const initialDepEvaluatedAt = initial.depEvaluatedAt

// Change only the route module, not the dependency
await next.patchFile('app/api/with-dep/route.ts', (content) =>
content.replace("'v1'", "'v2'")
)

await retry(async () => {
const updated = await next
.fetch('/api/with-dep')
.then((res) => res.json())

// The route change should be reflected in the response
expect(updated.routeVersion).toBe('v2')

// The unmodified dependency should NOT have been re-evaluated
expect(updated.depEvaluatedAt).toBe(initialDepEvaluatedAt)
})
}
)
})
})
Loading