diff --git a/goldens/public-api/angular/ssr/index.api.md b/goldens/public-api/angular/ssr/index.api.md index bf56e3d29c15..b31723be95f6 100644 --- a/goldens/public-api/angular/ssr/index.api.md +++ b/goldens/public-api/angular/ssr/index.api.md @@ -9,6 +9,7 @@ import { EnvironmentProviders } from '@angular/core'; // @public export class AngularAppEngine { handle(request: Request, requestContext?: unknown): Promise; + static ɵallowStaticRouteRender: boolean; static ɵhooks: Hooks; } diff --git a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts index 1f9b309f41bf..91fafc2deb17 100644 --- a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts +++ b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts @@ -44,7 +44,10 @@ export function createAngularSsrInternalMiddleware( ɵgetOrCreateAngularServerApp: typeof getOrCreateAngularServerApp; }; - const angularServerApp = ɵgetOrCreateAngularServerApp(); + const angularServerApp = ɵgetOrCreateAngularServerApp({ + allowStaticRouteRender: true, + }); + // Only Add the transform hook only if it's a different instance. if (cachedAngularServerApp !== angularServerApp) { angularServerApp.hooks.on('html:transform:pre', async ({ html, url }) => { @@ -96,6 +99,7 @@ export async function createAngularSsrExternalMiddleware( reqHandler?: unknown; AngularAppEngine: typeof SSRAngularAppEngine; }; + if (!isSsrNodeRequestHandler(reqHandler) && !isSsrRequestHandler(reqHandler)) { if (!fallbackWarningShown) { // eslint-disable-next-line no-console @@ -118,6 +122,7 @@ export async function createAngularSsrExternalMiddleware( } if (cachedAngularAppEngine !== AngularAppEngine) { + AngularAppEngine.ɵallowStaticRouteRender = true; AngularAppEngine.ɵhooks.on('html:transform:pre', async ({ html, url }) => { const processedHtml = await server.transformIndexHtml(url.pathname, html); diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts index 95f3a4fd8e74..fb7f8473669f 100644 --- a/packages/angular/build/src/utils/server-rendering/prerender.ts +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -280,6 +280,7 @@ async function getAllRoutes( if (appShellOptions) { routes.push({ + renderMode: RouteRenderMode.AppShell, route: urlJoin(baseHref, appShellOptions.route), }); } @@ -288,6 +289,7 @@ async function getAllRoutes( const routesFromFile = (await readFile(routesFile, 'utf8')).split(/\r?\n/); for (const route of routesFromFile) { routes.push({ + renderMode: RouteRenderMode.Prerender, route: urlJoin(baseHref, route.trim()), }); } @@ -321,7 +323,19 @@ async function getAllRoutes( {}, ); - return { errors, serializedRouteTree: [...routes, ...serializedRouteTree] }; + if (!routes.length) { + return { errors, serializedRouteTree }; + } + + // Merge the routing trees + const uniqueRoutes = new Map(); + for (const item of [...routes, ...serializedRouteTree]) { + if (!uniqueRoutes.has(item.route)) { + uniqueRoutes.set(item.route, item); + } + } + + return { errors, serializedRouteTree: Array.from(uniqueRoutes.values()) }; } catch (err) { assertIsError(err); diff --git a/packages/angular/build/src/utils/server-rendering/render-worker.ts b/packages/angular/build/src/utils/server-rendering/render-worker.ts index ddd274e4edac..4b4c1aed0bc4 100644 --- a/packages/angular/build/src/utils/server-rendering/render-worker.ts +++ b/packages/angular/build/src/utils/server-rendering/render-worker.ts @@ -39,10 +39,13 @@ let serverURL = DEFAULT_URL; async function renderPage({ url }: RenderOptions): Promise { const { ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp } = await loadEsmModuleFromMemory('./main.server.mjs'); - const angularServerApp = getOrCreateAngularServerApp(); - const response = await angularServerApp.renderStatic( - new URL(url, serverURL), - AbortSignal.timeout(30_000), + + const angularServerApp = getOrCreateAngularServerApp({ + allowStaticRouteRender: true, + }); + + const response = await angularServerApp.handle( + new Request(new URL(url, serverURL), { signal: AbortSignal.timeout(30_000) }), ); return response ? response.text() : null; diff --git a/packages/angular/ssr/src/app-engine.ts b/packages/angular/ssr/src/app-engine.ts index 8e13ec084afb..cda1754fcbdf 100644 --- a/packages/angular/ssr/src/app-engine.ts +++ b/packages/angular/ssr/src/app-engine.ts @@ -6,11 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ -import type { AngularServerApp } from './app'; +import type { AngularServerApp, getOrCreateAngularServerApp } from './app'; import { Hooks } from './hooks'; import { getPotentialLocaleIdFromUrl } from './i18n'; import { EntryPointExports, getAngularAppEngineManifest } from './manifest'; -import { stripIndexHtmlFromURL, stripTrailingSlash } from './utils/url'; /** * Angular server application engine. @@ -24,23 +23,23 @@ import { stripIndexHtmlFromURL, stripTrailingSlash } from './utils/url'; */ export class AngularAppEngine { /** - * Hooks for extending or modifying the behavior of the server application. - * These hooks are used by the Angular CLI when running the development server and - * provide extensibility points for the application lifecycle. + * A flag to enable or disable the rendering of prerendered routes. + * + * Typically used during development to avoid prerendering all routes ahead of time, + * allowing them to be rendered on the fly as requested. * * @private */ - static ɵhooks = /* #__PURE__*/ new Hooks(); + static ɵallowStaticRouteRender = false; /** - * Provides access to the hooks for extending or modifying the server application's behavior. - * This allows attaching custom functionality to various server application lifecycle events. + * Hooks for extending or modifying the behavior of the server application. + * These hooks are used by the Angular CLI when running the development server and + * provide extensibility points for the application lifecycle. * - * @internal + * @private */ - get hooks(): Hooks { - return AngularAppEngine.ɵhooks; - } + static ɵhooks = /* #__PURE__*/ new Hooks(); /** * The manifest for the server application. @@ -88,12 +87,15 @@ export class AngularAppEngine { return null; } - const { ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp } = entryPoint; // Note: Using `instanceof` is not feasible here because `AngularServerApp` will // be located in separate bundles, making `instanceof` checks unreliable. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const serverApp = getOrCreateAngularServerApp() as AngularServerApp; - serverApp.hooks = this.hooks; + const ɵgetOrCreateAngularServerApp = + entryPoint.ɵgetOrCreateAngularServerApp as typeof getOrCreateAngularServerApp; + + const serverApp = ɵgetOrCreateAngularServerApp({ + allowStaticRouteRender: AngularAppEngine.ɵallowStaticRouteRender, + hooks: AngularAppEngine.ɵhooks, + }); return serverApp; } diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index 8547fd7cfc48..d4a21c088cec 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -45,6 +45,30 @@ const SERVER_CONTEXT_VALUE: Record = { [RenderMode.Client]: '', }; +/** + * Options for configuring an `AngularServerApp`. + */ +interface AngularServerAppOptions { + /** + * Whether to allow rendering of prerendered routes. + * + * When enabled, prerendered routes will be served directly. When disabled, they will be + * rendered on demand. + * + * Defaults to `false`. + */ + allowStaticRouteRender?: boolean; + + /** + * Hooks for extending or modifying server behavior. + * + * This allows customization of the server's rendering process and other lifecycle events. + * + * If not provided, a new `Hooks` instance is created. + */ + hooks?: Hooks; +} + /** * Represents a locale-specific Angular server application managed by the server application engine. * @@ -52,10 +76,28 @@ const SERVER_CONTEXT_VALUE: Record = { */ export class AngularServerApp { /** - * Hooks for extending or modifying the behavior of the server application. - * This instance can be used to attach custom functionality to various events in the server application lifecycle. + * Whether prerendered routes should be rendered on demand or served directly. + * + * @see {@link AngularServerAppOptions.allowStaticRouteRender} for more details. + */ + private readonly allowStaticRouteRender: boolean; + + /** + * Hooks for extending or modifying server behavior. + * + * @see {@link AngularServerAppOptions.hooks} for more details. + */ + readonly hooks: Hooks; + + /** + * Constructs an instance of `AngularServerApp`. + * + * @param options Optional configuration options for the server application. */ - hooks = new Hooks(); + constructor(private readonly options: Readonly = {}) { + this.allowStaticRouteRender = this.options.allowStaticRouteRender ?? false; + this.hooks = options.hooks ?? new Hooks(); + } /** * The manifest associated with this server application. @@ -91,21 +133,6 @@ export class AngularServerApp { */ private readonly criticalCssLRUCache = new LRUCache(MAX_INLINE_CSS_CACHE_ENTRIES); - /** - * Renders a page based on the provided URL via server-side rendering and returns the corresponding HTTP response. - * The rendering process can be interrupted by an abort signal, where the first resolved promise (either from the abort - * or the render process) will dictate the outcome. - * - * @param url - The full URL to be processed and rendered by the server. - * @param signal - (Optional) An `AbortSignal` object that allows for the cancellation of the rendering process. - * @returns A promise that resolves to the generated HTTP response object, or `null` if no matching route is found. - */ - renderStatic(url: URL, signal?: AbortSignal): Promise { - const request = new Request(url, { signal }); - - return this.handleAbortableRendering(request, /** isSsrMode */ false); - } - /** * Handles an incoming HTTP request by serving prerendered content, performing server-side rendering, * or delivering a static file for client-side rendered routes based on the `RenderMode` setting. @@ -120,8 +147,8 @@ export class AngularServerApp { async handle(request: Request, requestContext?: unknown): Promise { const url = new URL(request.url); this.router ??= await ServerRouter.from(this.manifest, url); - const matchedRoute = this.router.match(url); + if (!matchedRoute) { // Not a known Angular route. return null; @@ -134,45 +161,33 @@ export class AngularServerApp { } } - return this.handleAbortableRendering( - request, - /** isSsrMode */ true, - matchedRoute, - requestContext, - ); - } - - /** - * Retrieves the matched route for the incoming request based on the request URL. - * - * @param request - The incoming HTTP request to match against routes. - * @returns A promise that resolves to the matched route metadata or `undefined` if no route matches. - */ - private async getMatchedRoute(request: Request): Promise { - this.router ??= await ServerRouter.from(this.manifest, new URL(request.url)); - - return this.router.match(new URL(request.url)); + return Promise.race([ + this.waitForRequestAbort(request), + this.handleRendering(request, matchedRoute, requestContext), + ]); } /** * Handles serving a prerendered static asset if available for the matched route. * + * This method only supports `GET` and `HEAD` requests. + * * @param request - The incoming HTTP request for serving a static page. - * @param matchedRoute - Optional parameter representing the metadata of the matched route for rendering. + * @param matchedRoute - The metadata of the matched route for rendering. * If not provided, the method attempts to find a matching route based on the request URL. * @returns A promise that resolves to a `Response` object if the prerendered page is found, or `null`. */ private async handleServe( request: Request, - matchedRoute?: RouteTreeNodeMetadata, + matchedRoute: RouteTreeNodeMetadata, ): Promise { - matchedRoute ??= await this.getMatchedRoute(request); - if (!matchedRoute) { + const { headers, renderMode } = matchedRoute; + if (renderMode !== RenderMode.Prerender) { return null; } - const { headers, renderMode } = matchedRoute; - if (renderMode !== RenderMode.Prerender) { + const { url, method } = request; + if (method !== 'GET' && method !== 'HEAD') { return null; } @@ -197,53 +212,12 @@ export class AngularServerApp { }); } - /** - * Handles the server-side rendering process for the given HTTP request, allowing for abortion - * of the rendering if the request is aborted. This method matches the request URL to a route - * and performs rendering if a matching route is found. - * - * @param request - The incoming HTTP request to be processed. It includes a signal to monitor - * for abortion events. - * @param isSsrMode - A boolean indicating whether the rendering is performed in server-side - * rendering (SSR) mode. - * @param matchedRoute - Optional parameter representing the metadata of the matched route for - * rendering. If not provided, the method attempts to find a matching route based on the request URL. - * @param requestContext - Optional additional context for rendering, such as request metadata. - * - * @returns A promise that resolves to the rendered response, or null if no matching route is found. - * If the request is aborted, the promise will reject with an `AbortError`. - */ - private async handleAbortableRendering( - request: Request, - isSsrMode: boolean, - matchedRoute?: RouteTreeNodeMetadata, - requestContext?: unknown, - ): Promise { - return Promise.race([ - new Promise((_, reject) => { - request.signal.addEventListener( - 'abort', - () => { - const abortError = new Error( - `Request for: ${request.url} was aborted.\n${request.signal.reason}`, - ); - abortError.name = 'AbortError'; - reject(abortError); - }, - { once: true }, - ); - }), - this.handleRendering(request, isSsrMode, matchedRoute, requestContext), - ]); - } - /** * Handles the server-side rendering process for the given HTTP request. * This method matches the request URL to a route and performs rendering if a matching route is found. * * @param request - The incoming HTTP request to be processed. - * @param isSsrMode - A boolean indicating whether the rendering is performed in server-side rendering (SSR) mode. - * @param matchedRoute - Optional parameter representing the metadata of the matched route for rendering. + * @param matchedRoute - The metadata of the matched route for rendering. * If not provided, the method attempts to find a matching route based on the request URL. * @param requestContext - Optional additional context for rendering, such as request metadata. * @@ -251,15 +225,9 @@ export class AngularServerApp { */ private async handleRendering( request: Request, - isSsrMode: boolean, - matchedRoute?: RouteTreeNodeMetadata, + matchedRoute: RouteTreeNodeMetadata, requestContext?: unknown, ): Promise { - matchedRoute ??= await this.getMatchedRoute(request); - if (!matchedRoute) { - return null; - } - const { redirectTo, status } = matchedRoute; const url = new URL(request.url); @@ -271,44 +239,44 @@ export class AngularServerApp { return Response.redirect(new URL(redirectTo, url), (status as any) ?? 302); } - const { renderMode = isSsrMode ? RenderMode.Server : RenderMode.Prerender, headers } = - matchedRoute; + const { renderMode, headers } = matchedRoute; + if ( + !this.allowStaticRouteRender && + (renderMode === RenderMode.Prerender || renderMode === RenderMode.AppShell) + ) { + return null; + } const platformProviders: StaticProvider[] = []; - let responseInit: ResponseInit | undefined; - - if (isSsrMode) { - // Initialize the response with status and headers if available. - responseInit = { - status, - headers: new Headers({ - 'Content-Type': 'text/html;charset=UTF-8', - ...headers, - }), - }; - - if (renderMode === RenderMode.Server) { - // Configure platform providers for request and response only for SSR. - platformProviders.push( - { - provide: REQUEST, - useValue: request, - }, - { - provide: REQUEST_CONTEXT, - useValue: requestContext, - }, - { - provide: RESPONSE_INIT, - useValue: responseInit, - }, - ); - } else if (renderMode === RenderMode.Client) { - return new Response( - await this.assets.getServerAsset('index.csr.html').text(), - responseInit, - ); - } + + // Initialize the response with status and headers if available. + const responseInit = { + status, + headers: new Headers({ + 'Content-Type': 'text/html;charset=UTF-8', + ...headers, + }), + }; + + if (renderMode === RenderMode.Server) { + // Configure platform providers for request and response only for SSR. + platformProviders.push( + { + provide: REQUEST, + useValue: request, + }, + { + provide: REQUEST_CONTEXT, + useValue: requestContext, + }, + { + provide: RESPONSE_INIT, + useValue: responseInit, + }, + ); + } else if (renderMode === RenderMode.Client) { + // Serve the client-side rendered version if the route is configured for CSR. + return new Response(await this.assets.getServerAsset('index.csr.html').text(), responseInit); } const { @@ -349,7 +317,7 @@ export class AngularServerApp { }); // TODO(alanagius): remove once Node.js version 18 is no longer supported. - if (isSsrMode && typeof crypto === 'undefined') { + if (renderMode === RenderMode.Server && typeof crypto === 'undefined') { // eslint-disable-next-line no-console console.error( `The global 'crypto' module is unavailable. ` + @@ -358,7 +326,7 @@ export class AngularServerApp { ); } - if (isSsrMode && typeof crypto !== 'undefined') { + if (renderMode === RenderMode.Server && typeof crypto !== 'undefined') { // Only cache if we are running in SSR Mode. const cacheKey = await sha256(html); let htmlWithCriticalCss = this.criticalCssLRUCache.get(cacheKey); @@ -375,6 +343,29 @@ export class AngularServerApp { return new Response(html, responseInit); } + + /** + * Returns a promise that rejects if the request is aborted. + * + * @param request - The HTTP request object being monitored for abortion. + * @returns A promise that never resolves and rejects with an `AbortError` + * if the request is aborted. + */ + private waitForRequestAbort(request: Request): Promise { + return new Promise((_, reject) => { + request.signal.addEventListener( + 'abort', + () => { + const abortError = new Error( + `Request for: ${request.url} was aborted.\n${request.signal.reason}`, + ); + abortError.name = 'AbortError'; + reject(abortError); + }, + { once: true }, + ); + }); + } } let angularServerApp: AngularServerApp | undefined; @@ -383,10 +374,15 @@ let angularServerApp: AngularServerApp | undefined; * Retrieves or creates an instance of `AngularServerApp`. * - If an instance of `AngularServerApp` already exists, it will return the existing one. * - If no instance exists, it will create a new one with the provided options. + * + * @param options Optional configuration options for the server application. + * * @returns The existing or newly created instance of `AngularServerApp`. */ -export function getOrCreateAngularServerApp(): AngularServerApp { - return (angularServerApp ??= new AngularServerApp()); +export function getOrCreateAngularServerApp( + options?: Readonly, +): AngularServerApp { + return (angularServerApp ??= new AngularServerApp(options)); } /** diff --git a/packages/angular/ssr/src/routes/ng-routes.ts b/packages/angular/ssr/src/routes/ng-routes.ts index eaf92e419b39..8146b7636561 100644 --- a/packages/angular/ssr/src/routes/ng-routes.ts +++ b/packages/angular/ssr/src/routes/ng-routes.ts @@ -133,6 +133,7 @@ async function* traverseRoutesConfig(options: { } const metadata: ServerConfigRouteTreeNodeMetadata = { + renderMode: RenderMode.Prerender, ...matchedMetaData, route: currentRoutePath, }; diff --git a/packages/angular/ssr/src/routes/route-tree.ts b/packages/angular/ssr/src/routes/route-tree.ts index a7a31b5453d1..36bdabb027df 100644 --- a/packages/angular/ssr/src/routes/route-tree.ts +++ b/packages/angular/ssr/src/routes/route-tree.ts @@ -63,9 +63,8 @@ export interface RouteTreeNodeMetadata { /** * Specifies the rendering mode used for this route. - * If not provided, the default rendering mode for the application will be used. */ - renderMode?: RenderMode; + renderMode: RenderMode; } /** diff --git a/packages/angular/ssr/test/BUILD.bazel b/packages/angular/ssr/test/BUILD.bazel index fdd0e53b2913..948e558c9bc7 100644 --- a/packages/angular/ssr/test/BUILD.bazel +++ b/packages/angular/ssr/test/BUILD.bazel @@ -6,6 +6,7 @@ ESM_TESTS = [ "app_spec.ts", "app-engine_spec.ts", "routes/router_spec.ts", + "routes/route-tree_spec.ts", "routes/ng-routes_spec.ts", ] diff --git a/packages/angular/ssr/test/app_spec.ts b/packages/angular/ssr/test/app_spec.ts index c6ed44413ad9..df0898ca41bf 100644 --- a/packages/angular/ssr/test/app_spec.ts +++ b/packages/angular/ssr/test/app_spec.ts @@ -178,6 +178,18 @@ describe('AngularServerApp', () => { expect(await response?.text()).toContain('Home SSG works'); }); + it('should return null if the requested prerendered page is accessed with a non-GET and non-HEAD method', async () => { + const responseHead = await app.handle( + new Request('http://localhost/home-ssg', { method: 'HEAD' }), + ); + expect(await responseHead?.text()).toContain('Home SSG works'); + + const responsePost = await app.handle( + new Request('http://localhost/home-ssg', { method: 'POST' }), + ); + expect(responsePost).toBeNull(); + }); + it(`should correctly serve the content for the requested prerendered page when the URL ends with 'index.html'`, async () => { const response = await app.handle(new Request('http://localhost/home-ssg/index.html')); expect(await response?.text()).toContain('Home SSG works'); diff --git a/packages/angular/ssr/test/routes/route-tree_spec.ts b/packages/angular/ssr/test/routes/route-tree_spec.ts index 101f064ee042..30a52c09f4de 100644 --- a/packages/angular/ssr/test/routes/route-tree_spec.ts +++ b/packages/angular/ssr/test/routes/route-tree_spec.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import { RenderMode } from '../../src/routes/route-config'; import { RouteTree } from '../../src/routes/route-tree'; describe('RouteTree', () => { @@ -17,32 +18,53 @@ describe('RouteTree', () => { describe('toObject and fromObject', () => { it('should convert the route tree to a nested object and back', () => { - routeTree.insert('/home', { redirectTo: '/home-page' }); - routeTree.insert('/about', { redirectTo: '/about-page' }); - routeTree.insert('/products/:id', {}); - routeTree.insert('/api/details', { redirectTo: '/api/details-page' }); + routeTree.insert('/home', { redirectTo: '/home-page', renderMode: RenderMode.Server }); + routeTree.insert('/about', { redirectTo: '/about-page', renderMode: RenderMode.Server }); + routeTree.insert('/products/:id', { renderMode: RenderMode.Server }); + routeTree.insert('/api/details', { + redirectTo: '/api/details-page', + renderMode: RenderMode.Server, + }); const routeTreeObj = routeTree.toObject(); expect(routeTreeObj).toEqual([ - { redirectTo: '/home-page', route: '/home' }, - { redirectTo: '/about-page', route: '/about' }, - { route: '/products/:id' }, - { redirectTo: '/api/details-page', route: '/api/details' }, + { redirectTo: '/home-page', route: '/home', renderMode: RenderMode.Server }, + { redirectTo: '/about-page', route: '/about', renderMode: RenderMode.Server }, + { route: '/products/:id', renderMode: RenderMode.Server }, + { redirectTo: '/api/details-page', route: '/api/details', renderMode: RenderMode.Server }, ]); const newRouteTree = RouteTree.fromObject(routeTreeObj); - expect(newRouteTree.match('/home')).toEqual({ redirectTo: '/home-page', route: '/home' }); - expect(newRouteTree.match('/about')).toEqual({ redirectTo: '/about-page', route: '/about' }); - expect(newRouteTree.match('/products/123')).toEqual({ route: '/products/:id' }); + expect(newRouteTree.match('/home')).toEqual({ + redirectTo: '/home-page', + route: '/home', + renderMode: RenderMode.Server, + }); + expect(newRouteTree.match('/about')).toEqual({ + redirectTo: '/about-page', + route: '/about', + renderMode: RenderMode.Server, + }); + expect(newRouteTree.match('/products/123')).toEqual({ + route: '/products/:id', + renderMode: RenderMode.Server, + }); expect(newRouteTree.match('/api/details')).toEqual({ redirectTo: '/api/details-page', route: '/api/details', + renderMode: RenderMode.Server, }); }); it('should handle complex route structures when converting to and from object', () => { - routeTree.insert('/shop/categories/:category/products/:id', { redirectTo: '/shop/products' }); - routeTree.insert('/shop/cart', { redirectTo: '/shop/cart-page' }); + routeTree.insert('/shop/categories/:category/products/:id', { + redirectTo: '/shop/products', + renderMode: RenderMode.Server, + }); + routeTree.insert('/shop/cart', { + redirectTo: '/shop/cart-page', + renderMode: RenderMode.Server, + }); const routeTreeObj = routeTree.toObject(); const newRouteTree = RouteTree.fromObject(routeTreeObj); @@ -50,29 +72,41 @@ describe('RouteTree', () => { expect(newRouteTree.match('/shop/categories/electronics/products/123')).toEqual({ redirectTo: '/shop/products', route: '/shop/categories/:category/products/:id', + renderMode: RenderMode.Server, }); expect(newRouteTree.match('/shop/cart')).toEqual({ redirectTo: '/shop/cart-page', route: '/shop/cart', + renderMode: RenderMode.Server, }); }); it('should construct a RouteTree from a nested object representation', () => { const routeTreeObj = [ - { redirectTo: '/home-page', route: '/home' }, - { redirectTo: '/about-page', route: '/about' }, + { redirectTo: '/home-page', route: '/home', renderMode: RenderMode.Server }, + { redirectTo: '/about-page', route: '/about', renderMode: RenderMode.Server }, { redirectTo: '/api/details-page', route: '/api/*/details', + renderMode: RenderMode.Server, }, ]; const newRouteTree = RouteTree.fromObject(routeTreeObj); - expect(newRouteTree.match('/home')).toEqual({ redirectTo: '/home-page', route: '/home' }); - expect(newRouteTree.match('/about')).toEqual({ redirectTo: '/about-page', route: '/about' }); + expect(newRouteTree.match('/home')).toEqual({ + redirectTo: '/home-page', + route: '/home', + renderMode: RenderMode.Server, + }); + expect(newRouteTree.match('/about')).toEqual({ + redirectTo: '/about-page', + route: '/about', + renderMode: RenderMode.Server, + }); expect(newRouteTree.match('/api/users/details')).toEqual({ redirectTo: '/api/details-page', route: '/api/*/details', + renderMode: RenderMode.Server, }); expect(newRouteTree.match('/nonexistent')).toBeUndefined(); }); @@ -86,106 +120,159 @@ describe('RouteTree', () => { }); it('should preserve insertion order when converting to and from object', () => { - routeTree.insert('/first', {}); - routeTree.insert('/:id', {}); - routeTree.insert('/second', {}); + routeTree.insert('/first', { renderMode: RenderMode.Server }); + routeTree.insert('/:id', { renderMode: RenderMode.Server }); + routeTree.insert('/second', { renderMode: RenderMode.Server }); const routeTreeObj = routeTree.toObject(); - expect(routeTreeObj).toEqual([{ route: '/first' }, { route: '/:id' }, { route: '/second' }]); + expect(routeTreeObj).toEqual([ + { route: '/first', renderMode: RenderMode.Server }, + { route: '/:id', renderMode: RenderMode.Server }, + { route: '/second', renderMode: RenderMode.Server }, + ]); const newRouteTree = RouteTree.fromObject(routeTreeObj); - expect(newRouteTree.match('/first')).toEqual({ route: '/first' }); - expect(newRouteTree.match('/second')).toEqual({ route: '/:id' }); - expect(newRouteTree.match('/third')).toEqual({ route: '/:id' }); + expect(newRouteTree.match('/first')).toEqual({ + route: '/first', + renderMode: RenderMode.Server, + }); + expect(newRouteTree.match('/second')).toEqual({ + route: '/:id', + renderMode: RenderMode.Server, + }); + expect(newRouteTree.match('/third')).toEqual({ + route: '/:id', + renderMode: RenderMode.Server, + }); }); }); describe('match', () => { it('should handle empty routes', () => { - routeTree.insert('', {}); - expect(routeTree.match('')).toEqual({ route: '' }); + routeTree.insert('', { renderMode: RenderMode.Server }); + expect(routeTree.match('')).toEqual({ route: '', renderMode: RenderMode.Server }); }); it('should insert and match basic routes', () => { - routeTree.insert('/home', {}); - routeTree.insert('/about', {}); + routeTree.insert('/home', { renderMode: RenderMode.Server }); + routeTree.insert('/about', { renderMode: RenderMode.Server }); - expect(routeTree.match('/home')).toEqual({ route: '/home' }); - expect(routeTree.match('/about')).toEqual({ route: '/about' }); + expect(routeTree.match('/home')).toEqual({ route: '/home', renderMode: RenderMode.Server }); + expect(routeTree.match('/about')).toEqual({ route: '/about', renderMode: RenderMode.Server }); expect(routeTree.match('/contact')).toBeUndefined(); }); it('should handle wildcard segments', () => { - routeTree.insert('/api/users', {}); - routeTree.insert('/api/products', {}); - routeTree.insert('/api/*/details', {}); + routeTree.insert('/api/users', { renderMode: RenderMode.Server }); + routeTree.insert('/api/products', { renderMode: RenderMode.Server }); + routeTree.insert('/api/*/details', { renderMode: RenderMode.Server }); - expect(routeTree.match('/api/users')).toEqual({ route: '/api/users' }); - expect(routeTree.match('/api/products')).toEqual({ route: '/api/products' }); - expect(routeTree.match('/api/orders/details')).toEqual({ route: '/api/*/details' }); + expect(routeTree.match('/api/users')).toEqual({ + route: '/api/users', + renderMode: RenderMode.Server, + }); + expect(routeTree.match('/api/products')).toEqual({ + route: '/api/products', + renderMode: RenderMode.Server, + }); + expect(routeTree.match('/api/orders/details')).toEqual({ + route: '/api/*/details', + renderMode: RenderMode.Server, + }); }); it('should handle catch all (double wildcard) segments', () => { - routeTree.insert('/api/users', {}); - routeTree.insert('/api/*/users/**', {}); - routeTree.insert('/api/**', {}); + routeTree.insert('/api/users', { renderMode: RenderMode.Server }); + routeTree.insert('/api/*/users/**', { renderMode: RenderMode.Server }); + routeTree.insert('/api/**', { renderMode: RenderMode.Server }); - expect(routeTree.match('/api/users')).toEqual({ route: '/api/users' }); - expect(routeTree.match('/api/products')).toEqual({ route: '/api/**' }); - expect(routeTree.match('/api/info/users/details')).toEqual({ route: '/api/*/users/**' }); - expect(routeTree.match('/api/user/details')).toEqual({ route: '/api/**' }); + expect(routeTree.match('/api/users')).toEqual({ + route: '/api/users', + renderMode: RenderMode.Server, + }); + expect(routeTree.match('/api/products')).toEqual({ + route: '/api/**', + renderMode: RenderMode.Server, + }); + expect(routeTree.match('/api/info/users/details')).toEqual({ + route: '/api/*/users/**', + renderMode: RenderMode.Server, + }); + expect(routeTree.match('/api/user/details')).toEqual({ + route: '/api/**', + renderMode: RenderMode.Server, + }); }); it('should prioritize earlier insertions in case of conflicts', () => { - routeTree.insert('/blog/*', {}); - routeTree.insert('/blog/article', { redirectTo: 'blog' }); + routeTree.insert('/blog/*', { renderMode: RenderMode.Server }); + routeTree.insert('/blog/article', { redirectTo: 'blog', renderMode: RenderMode.Server }); - expect(routeTree.match('/blog/article')).toEqual({ route: '/blog/*' }); + expect(routeTree.match('/blog/article')).toEqual({ + route: '/blog/*', + renderMode: RenderMode.Server, + }); }); it('should handle parameterized segments as wildcards', () => { - routeTree.insert('/users/:id', {}); - expect(routeTree.match('/users/123')).toEqual({ route: '/users/:id' }); + routeTree.insert('/users/:id', { renderMode: RenderMode.Server }); + expect(routeTree.match('/users/123')).toEqual({ + route: '/users/:id', + renderMode: RenderMode.Server, + }); }); it('should handle complex route structures', () => { - routeTree.insert('/shop/categories/:category', {}); - routeTree.insert('/shop/categories/:category/products/:id', {}); + routeTree.insert('/shop/categories/:category', { renderMode: RenderMode.Server }); + routeTree.insert('/shop/categories/:category/products/:id', { + renderMode: RenderMode.Server, + }); expect(routeTree.match('/shop/categories/electronics')).toEqual({ route: '/shop/categories/:category', + renderMode: RenderMode.Server, }); expect(routeTree.match('/shop/categories/electronics/products/456')).toEqual({ route: '/shop/categories/:category/products/:id', + renderMode: RenderMode.Server, }); }); it('should return undefined for unmatched routes', () => { - routeTree.insert('/foo', {}); + routeTree.insert('/foo', { renderMode: RenderMode.Server }); expect(routeTree.match('/bar')).toBeUndefined(); }); it('should handle multiple wildcards in a path', () => { - routeTree.insert('/a/*/b/*/c', {}); - expect(routeTree.match('/a/1/b/2/c')).toEqual({ route: '/a/*/b/*/c' }); + routeTree.insert('/a/*/b/*/c', { renderMode: RenderMode.Server }); + expect(routeTree.match('/a/1/b/2/c')).toEqual({ + route: '/a/*/b/*/c', + renderMode: RenderMode.Server, + }); }); it('should handle trailing slashes', () => { - routeTree.insert('/foo/', {}); - expect(routeTree.match('/foo')).toEqual({ route: '/foo' }); - expect(routeTree.match('/foo/')).toEqual({ route: '/foo' }); + routeTree.insert('/foo/', { renderMode: RenderMode.Server }); + expect(routeTree.match('/foo')).toEqual({ route: '/foo', renderMode: RenderMode.Server }); + expect(routeTree.match('/foo/')).toEqual({ route: '/foo', renderMode: RenderMode.Server }); }); it('should handle case-sensitive matching', () => { - routeTree.insert('/case', {}); + routeTree.insert('/case', { renderMode: RenderMode.Server }); expect(routeTree.match('/CASE')).toBeUndefined(); }); it('should handle routes with special characters', () => { - routeTree.insert('/path with spaces', {}); - routeTree.insert('/path/with/slashes', {}); - expect(routeTree.match('/path with spaces')).toEqual({ route: '/path with spaces' }); - expect(routeTree.match('/path/with/slashes')).toEqual({ route: '/path/with/slashes' }); + routeTree.insert('/path with spaces', { renderMode: RenderMode.Server }); + routeTree.insert('/path/with/slashes', { renderMode: RenderMode.Server }); + expect(routeTree.match('/path with spaces')).toEqual({ + route: '/path with spaces', + renderMode: RenderMode.Server, + }); + expect(routeTree.match('/path/with/slashes')).toEqual({ + route: '/path/with/slashes', + renderMode: RenderMode.Server, + }); }); }); });