From e8741e81f28c17b4012d133de76325223dc78743 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Wed, 6 Nov 2024 07:56:31 +0000 Subject: [PATCH] refactor(@angular/ssr): remove duplicate code and streamline functionality This commit cleans up duplicate code left from the previous implementations of process, serve, and render. Additionally, prerender serve now exclusively handles HEAD and GET requests, aligning with updated handling requirements. The private `renderStatic` method has been removed in favor of the `handle` method for improved maintainability. --- goldens/public-api/angular/ssr/index.api.md | 1 + .../tools/vite/middlewares/ssr-middleware.ts | 7 +- .../src/utils/server-rendering/prerender.ts | 16 +- .../utils/server-rendering/render-worker.ts | 11 +- packages/angular/ssr/src/app-engine.ts | 34 +-- packages/angular/ssr/src/app.ts | 260 +++++++++--------- packages/angular/ssr/src/routes/ng-routes.ts | 1 + packages/angular/ssr/src/routes/route-tree.ts | 3 +- packages/angular/ssr/test/BUILD.bazel | 1 + packages/angular/ssr/test/app_spec.ts | 12 + .../ssr/test/routes/route-tree_spec.ts | 209 ++++++++++---- 11 files changed, 338 insertions(+), 217 deletions(-) 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, + }); }); }); });