Skip to content
Merged
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 @@ -2318,8 +2318,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
14 changes: 12 additions & 2 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,7 +36,6 @@ 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
Expand All @@ -59,7 +59,17 @@ const routeModule = new AppRouteRouteModule({
relativeProjectDir: process.env.__NEXT_RELATIVE_PROJECT_DIR || '',
resolvedPagePath: 'VAR_RESOLVED_PAGE_PATH',
nextConfigOutput,
userland,
// The static import is used for initialization (methods, dynamic, etc.).
userland: userland as AppRouteUserlandModule,
// In Turbopack dev mode, also provide a getter that calls require() on every
// request. This re-reads from devModuleCache so HMR updates are picked up,
// and the async wrapper unwraps async-module Promises (ESM-only
// serverExternalPackages) automatically.
...(process.env.TURBOPACK && process.env.__NEXT_DEV_SERVER
? {
getUserland: () => import('VAR_USERLAND'),
}
: {}),
})

// Pull out the exports that we need to expose from the module. This should
Expand Down
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
61 changes: 47 additions & 14 deletions packages/next/src/server/route-modules/app-route/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,12 @@ export interface AppRouteRouteModuleOptions
extends RouteModuleOptions<AppRouteRouteDefinition, AppRouteUserlandModule> {
readonly resolvedPagePath: string
readonly nextConfigOutput: NextConfig['output']
/**
* Optional getter that returns the live userland module. When provided (in
* Turbopack dev mode), it is called on every request so that server HMR
* updates are picked up without re-executing the entry chunk.
*/
readonly getUserland?: () => Promise<AppRouteUserlandModule>
}

/**
Expand Down Expand Up @@ -212,32 +218,40 @@ 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?: () => Promise<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 })
super({
userland: userland!,
definition,
distDir,
relativeProjectDir,
})

this.resolvedPagePath = resolvedPagePath
this.nextConfigOutput = nextConfigOutput
this._getUserland = getUserland

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

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

// Get the dynamic property from the userland module.
this.dynamic = this.userland.dynamic
this.dynamic = userland!.dynamic
if (this.nextConfigOutput === 'export') {
if (this.dynamic === 'force-dynamic') {
throw new Error(
Expand Down Expand Up @@ -296,8 +310,22 @@ 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 })

// Return the handler.
return this.methods[method]
return autoImplementMethods(this.userland as AppRouteUserlandModule)[method]
}

/**
* Like resolve(), but re-fetches the userland module on every call via the
* async getter. Only used in Turbopack dev mode, where server HMR disposes
* modules between requests. The async wrapper also unwraps async-module
* Promises produced by ESM-only serverExternalPackages.
*/
private async resolveWithGetter(
method: string,
getUserland: () => Promise<AppRouteUserlandModule>
): Promise<AppRouteHandlerFn> {
if (!isHTTPMethod(method)) return () => new Response(null, { status: 400 })
const userland = await getUserland()
return autoImplementMethods(userland)[method]
}

private async do(
Expand Down Expand Up @@ -692,8 +720,12 @@ export class AppRouteRouteModule extends RouteModule<
req: NextRequest,
context: AppRouteRouteHandlerContext
): Promise<Response> {
// Get the handler function for the given method.
const handler = this.resolve(req.method)
// Get the handler function for the given method. In Turbopack dev mode,
// use resolveWithGetter() to re-fetch the live userland on every request
// In all other modes, resolve() is synchronous.
const handler = this._getUserland
? await this.resolveWithGetter(req.method, this._getUserland)
: this.resolve(req.method)

// Get the context for the static generation.
const staticGenerationContext: WorkStoreContext = {
Expand Down Expand Up @@ -738,7 +770,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(this.userland)) {
if (workStore.isStaticGeneration) {
const err = new DynamicServerError(
'Route is configured with methods that cannot be statically generated.'
Expand All @@ -754,7 +786,8 @@ export class AppRouteRouteModule extends RouteModule<
let request = req

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