diff --git a/packages/cli/plugin-ssg/src/server/index.ts b/packages/cli/plugin-ssg/src/server/index.ts index 0e22edf246d1..90840c43bea7 100644 --- a/packages/cli/plugin-ssg/src/server/index.ts +++ b/packages/cli/plugin-ssg/src/server/index.ts @@ -120,6 +120,7 @@ export const createServer = async ( method: 'GET', headers: { host: 'localhost', + 'x-modern-ssg-render': 'true', }, }); diff --git a/packages/runtime/plugin-runtime/src/cli/code.ts b/packages/runtime/plugin-runtime/src/cli/code.ts index 328cf080beaf..727bb0fc42c6 100644 --- a/packages/runtime/plugin-runtime/src/cli/code.ts +++ b/packages/runtime/plugin-runtime/src/cli/code.ts @@ -1,6 +1,5 @@ import path from 'path'; import type { - AppNormalizedConfig, AppToolsContext, AppToolsFeatureHooks, AppToolsNormalizedConfig, @@ -17,6 +16,7 @@ import { INDEX_FILE_NAME, SERVER_ENTRY_POINT_FILE_NAME, } from './constants'; +import { resolveSSRMode } from './ssr/mode'; import * as template from './template'; import * as serverTemplate from './template.server'; @@ -24,25 +24,7 @@ function getSSRMode( entry: string, config: AppToolsNormalizedConfig, ): 'string' | 'stream' | false { - const { ssr, ssrByEntries } = config.server; - - if (config.output.ssg || config.output.ssgByEntries) { - return 'string'; - } - - return checkSSRMode(ssrByEntries?.[entry] || ssr); - - function checkSSRMode(ssr: AppNormalizedConfig['server']['ssr']) { - if (!ssr) { - return false; - } - - if (typeof ssr === 'boolean') { - return ssr ? 'string' : false; - } - - return ssr.mode === 'stream' ? 'stream' : 'string'; - } + return resolveSSRMode({ entry, config }); } export const generateCode = async ( diff --git a/packages/runtime/plugin-runtime/src/cli/ssr/index.ts b/packages/runtime/plugin-runtime/src/cli/ssr/index.ts index 1821537e8cfd..f1283f048112 100644 --- a/packages/runtime/plugin-runtime/src/cli/ssr/index.ts +++ b/packages/runtime/plugin-runtime/src/cli/ssr/index.ts @@ -8,6 +8,7 @@ import type { import type { CLIPluginAPI } from '@modern-js/plugin'; import { LOADABLE_STATS_FILE, isUseSSRBundle } from '@modern-js/utils'; import type { RsbuildPlugin } from '@rsbuild/core'; +import { resolveSSRMode } from './mode'; const hasStringSSREntry = (userConfig: AppToolsNormalizedConfig): boolean => { const isStreaming = (ssr: ServerUserConfig['ssr']) => @@ -42,16 +43,12 @@ const hasStringSSREntry = (userConfig: AppToolsNormalizedConfig): boolean => { return false; }; -const checkUseStringSSR = (config: AppToolsNormalizedConfig): boolean => { - const { output } = config; - - if (output?.ssg) { - return true; - } - if (output?.ssgByEntries && Object.keys(output.ssgByEntries).length > 0) { - return true; - } - return hasStringSSREntry(config); +const checkUseStringSSR = ( + config: AppToolsNormalizedConfig, + appDirectory?: string, +): boolean => { + const ssrMode = resolveSSRMode({ config, appDirectory }); + return ssrMode === 'string'; }; const ssrBuilderPlugin = ( @@ -72,10 +69,13 @@ const ssrBuilderPlugin = ( ? 'edge' : 'node'; + const appContext = modernAPI.getAppContext(); + const { appDirectory } = appContext; + const useLoadablePlugin = isUseSSRBundle(userConfig) && !isServerEnvironment && - checkUseStringSSR(userConfig); + checkUseStringSSR(userConfig, appDirectory); return mergeEnvironmentConfig(config, { source: { diff --git a/packages/runtime/plugin-runtime/src/cli/ssr/mode.ts b/packages/runtime/plugin-runtime/src/cli/ssr/mode.ts new file mode 100644 index 000000000000..aad70d2323a9 --- /dev/null +++ b/packages/runtime/plugin-runtime/src/cli/ssr/mode.ts @@ -0,0 +1,68 @@ +import type { + AppNormalizedConfig, + AppToolsNormalizedConfig, +} from '@modern-js/app-tools'; +import { isReact18, isUseRsc } from '@modern-js/utils'; + +export type SSRMode = 'string' | 'stream' | false; + +/** + * Unified SSR mode resolution function. + * Priority: + * 1. If SSG is enabled, use SSG configuration (SSG takes precedence over SSR when both are configured) + * 2. User's explicit server.ssr/server.ssrByEntries config + * 3. Otherwise return false (no SSR) + */ +export function resolveSSRMode(params: { + entry?: string; + config: AppToolsNormalizedConfig; + appDirectory?: string; +}): SSRMode { + const { entry, config, appDirectory } = params; + + // 1. Check if SSG is enabled first (SSG takes precedence over SSR when both are configured) + const isSsgEnabled = + config.output?.ssg || + (config.output?.ssgByEntries && + (entry + ? !!config.output.ssgByEntries[entry] + : Object.keys(config.output.ssgByEntries).length > 0)); + + if (isSsgEnabled) { + // If user explicitly disables conventional routing (non-conventional routing), force 'string' + const entryRouterConfig = entry + ? config.runtimeByEntries?.[entry]?.router + : undefined; + const routerConfig = + entryRouterConfig !== undefined + ? entryRouterConfig + : config.runtime?.router; + + if (!routerConfig) { + return 'string'; + } + + if (appDirectory) { + return isReact18(appDirectory) ? 'stream' : 'string'; + } + return 'stream'; + } + + // 2. Check user's explicit SSR config (server.ssr or server.ssrByEntries) + const ssr = entry + ? config.server?.ssrByEntries?.[entry] || config.server?.ssr + : config.server?.ssr; + + if (ssr !== undefined) { + if (!ssr) { + return false; + } + if (typeof ssr === 'boolean') { + return ssr ? 'string' : false; + } + return ssr.mode === 'stream' ? 'stream' : 'string'; + } + + // 3. No SSR + return false; +} diff --git a/packages/runtime/plugin-runtime/src/core/server/requestHandler.tsx b/packages/runtime/plugin-runtime/src/core/server/requestHandler.tsx index 6aaef9c813d1..d5cc7435c13f 100644 --- a/packages/runtime/plugin-runtime/src/core/server/requestHandler.tsx +++ b/packages/runtime/plugin-runtime/src/core/server/requestHandler.tsx @@ -10,7 +10,7 @@ import { parseHeaders, parseQuery, } from '@modern-js/runtime-utils/universal/request'; -import type React from 'react'; +import React from 'react'; import { Fragment } from 'react'; import { type RuntimeContext, @@ -143,7 +143,13 @@ function createSSRContext( config.ssr, config.ssrByEntries, ); - const ssrMode = getSSRMode(ssrConfig); + let ssrMode = getSSRMode(ssrConfig); + + const isSsgRender = headers.get('x-modern-ssg-render') === 'true'; + if (isSsgRender) { + const reactMajor = Number((React.version || '0').split('.')[0]); + ssrMode = reactMajor >= 18 ? 'stream' : 'string'; + } const loaderFailureMode = typeof ssrConfig === 'object' ? ssrConfig.loaderFailureMode : undefined; diff --git a/packages/runtime/plugin-runtime/src/core/server/stream/createReadableStream.ts b/packages/runtime/plugin-runtime/src/core/server/stream/createReadableStream.ts index 165b24b666a0..6a3b786056b8 100644 --- a/packages/runtime/plugin-runtime/src/core/server/stream/createReadableStream.ts +++ b/packages/runtime/plugin-runtime/src/core/server/stream/createReadableStream.ts @@ -36,7 +36,11 @@ export const createReadableStreamFromElement: CreateReadableStreamFromElement = // When a crawler visit the page, we should waiting for entrie content of page const isbot = checkIsBot(request.headers.get('user-agent')); - const onReady = isbot || forceStream2String ? 'onAllReady' : 'onShellReady'; + const isSsgRender = request.headers.get('x-modern-ssg-render') === 'true'; + const onReady = + isbot || isSsgRender || forceStream2String + ? 'onAllReady' + : 'onShellReady'; const internalRuntimeContext = getGlobalInternalRuntimeContext(); const hooks = internalRuntimeContext.hooks; diff --git a/packages/runtime/plugin-runtime/src/core/server/stream/createReadableStream.worker.ts b/packages/runtime/plugin-runtime/src/core/server/stream/createReadableStream.worker.ts index 5f3ca9bb0e6a..9011c6cd9798 100644 --- a/packages/runtime/plugin-runtime/src/core/server/stream/createReadableStream.worker.ts +++ b/packages/runtime/plugin-runtime/src/core/server/stream/createReadableStream.worker.ts @@ -56,8 +56,9 @@ export const createReadableStreamFromElement: CreateReadableStreamFromElement = }); const isbot = checkIsBot(request.headers.get('user-agent')); - if (isbot) { - // However, when a crawler visits your page, or if you’re generating the pages at the build time, + const isSsgRender = request.headers.get('x-modern-ssg-render') === 'true'; + if (isbot || isSsgRender) { + // However, when a crawler visits your page, or if you're generating the pages at the build time, // you might want to let all of the content load first and then produce the final HTML output instead of revealing it progressively. // from: https://react.dev/reference/react-dom/server/renderToReadableStream#handling-different-errors-in-different-ways await readableOriginal.allReady; diff --git a/packages/runtime/plugin-runtime/src/router/cli/code/index.ts b/packages/runtime/plugin-runtime/src/router/cli/code/index.ts index 2244856975d7..5114febf2704 100644 --- a/packages/runtime/plugin-runtime/src/router/cli/code/index.ts +++ b/packages/runtime/plugin-runtime/src/router/cli/code/index.ts @@ -28,6 +28,7 @@ import { } from '@modern-js/utils'; import { cloneDeep } from '@modern-js/utils/lodash'; import { ENTRY_POINT_RUNTIME_GLOBAL_CONTEXT_FILE_NAME } from '../../../cli/constants'; +import { resolveSSRMode } from '../../../cli/ssr/mode'; import { FILE_SYSTEM_ROUTES_FILE_NAME } from '../constants'; import { walk } from './nestedRoutes'; import * as templates from './templates'; @@ -173,14 +174,14 @@ export const generateCode = async ( config.server.ssrByEntries, packageName, ); - const useSSG = isSSGEntry(config, entryName, entrypoints); - let mode: SSRMode | undefined; - if (ssr) { - mode = typeof ssr === 'object' ? ssr.mode || 'string' : 'string'; - } + const ssrMode = resolveSSRMode({ + entry: entrypoint.entryName, + config, + appDirectory: appContext.appDirectory, + }); - if (mode === 'stream') { + if (ssrMode === 'stream') { const hasPageRoute = routes.some( route => 'type' in route && route.type === 'page', ); @@ -197,7 +198,7 @@ export const generateCode = async ( code: await templates.fileSystemRoutes({ metaName, routes: routes, - ssrMode: useSSG ? 'string' : isUseRsc(config) ? 'stream' : mode, + ssrMode: isUseRsc(config) ? 'stream' : ssrMode, nestedRoutesEntry: entrypoint.nestedRoutesEntry, entryName: entrypoint.entryName, internalDirectory, @@ -233,7 +234,7 @@ export const generateCode = async ( const serverRoutesCode = await templates.fileSystemRoutes({ metaName, routes: filtedRoutesForServer, - ssrMode: useSSG ? 'string' : mode, + ssrMode, nestedRoutesEntry: entrypoint.nestedRoutesEntry, entryName: entrypoint.entryName, internalDirectory, diff --git a/packages/toolkit/types/common/index.d.ts b/packages/toolkit/types/common/index.d.ts index c8b6e158d423..348423be8c86 100644 --- a/packages/toolkit/types/common/index.d.ts +++ b/packages/toolkit/types/common/index.d.ts @@ -12,4 +12,4 @@ export type ServerPlugin = { options?: Record; }; -export type SSRMode = 'string' | 'stream'; +export type SSRMode = 'string' | 'stream' | false;