From 9cf1539ac9df125496fc4c3b8e4582c4ed757a15 Mon Sep 17 00:00:00 2001 From: Vaadin Bot Date: Fri, 15 Nov 2024 16:53:31 +0100 Subject: [PATCH] fix: handle child layouts (#2853) (CP: 24.5) (#2905) fix: handle child layouts (#2853) * fix: handle child layouts Handle child layouts depending on if they have flowLayout true or false. Fixes all layouts having flowLayout if the parent layout or one child has flowLayout true. Fixes #20261 * refactor(file-router): flowLayout subtree implementation cleanup --------- Co-authored-by: caalador Co-authored-by: Mikhail Shabarov <61410877+mshabarov@users.noreply.github.com> Co-authored-by: Anton Platonov --- .../src/runtime/RouterConfigurationBuilder.ts | 118 ++++++++++++------ .../RouterConfigurationBuilder.spec.tsx | 57 ++++++++- 2 files changed, 136 insertions(+), 39 deletions(-) diff --git a/packages/ts/file-router/src/runtime/RouterConfigurationBuilder.ts b/packages/ts/file-router/src/runtime/RouterConfigurationBuilder.ts index e49fef96b9..d62a762560 100644 --- a/packages/ts/file-router/src/runtime/RouterConfigurationBuilder.ts +++ b/packages/ts/file-router/src/runtime/RouterConfigurationBuilder.ts @@ -39,6 +39,18 @@ function createRouteEntry(route: T): readonly [key: string, return [`${route.path ?? ''}-${route.children ? 'n' : 'i'}`, route]; } +enum RouteHandleFlags { + FLOW_LAYOUT = 'flowLayout', + IGNORE_FALLBACK = 'ignoreFallback', +} + +function hasRouteHandleFlag( + route: RouteObject, + flag: T, +): route is { readonly handle: Readonly> } { + return typeof route.handle === 'object' && flag in route.handle && (route.handle as Record)[flag]; +} + /** * A builder for creating a Vaadin-specific router for React with * authentication and server routes support. @@ -119,8 +131,7 @@ export class RouterConfigurationBuilder { ]; this.update(fallbackRoutes, (original, added, children) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (original && !original.handle?.ignoreFallback) { + if (original && !hasRouteHandleFlag(original, RouteHandleFlags.IGNORE_FALLBACK)) { if (!children) { return original; } @@ -153,45 +164,78 @@ export class RouterConfigurationBuilder { * @param layoutComponent - layout component to use, usually Flow */ withLayout(layoutComponent: ComponentType): this { - function applyLayouts(routes: readonly RouteObject[]): readonly RouteObject[] { - if (routes.length === 0) { - return routes; + this.#modifiers.push((originalRoutes: readonly RouteObject[] | undefined) => { + if (originalRoutes === undefined) { + return originalRoutes; } - const nestedRoutes = routes.map((route) => route); + // Split the routes tree onto two subtrees with and without + // a server layout. + const [serverRoutesTree, clientRoutesTree]: [RouteObject[] | undefined, RouteObject[] | undefined] = + transformTree( + originalRoutes, + ( + routes: readonly RouteObject[], + next: (...nodes: readonly RouteObject[]) => [RouteObject[] | undefined, RouteObject[] | undefined], + ) => + // Split single routes list onto two filtered lists + routes.reduce<[RouteObject[] | undefined, RouteObject[] | undefined]>( + ([serverRoutesList, clientRoutesList], route) => { + if (hasRouteHandleFlag(route, RouteHandleFlags.FLOW_LAYOUT)) { + // Server layout is explicitly declared: move to the entire + // route to the server list, taking also all the children. + return [[...(serverRoutesList ?? []), route], clientRoutesList]; + } + if (!route.children?.length) { + // Leaf routes and empty layouts: move to the client list. + return [serverRoutesList, [...(clientRoutesList ?? []), route]]; + } + // Nested children: collect server and client subtrees, and + // copy the current route to either or both the server and + // the client lists with the respective subtree as children. + const [serverRouteSubtree, clientRouteSubtree] = next(...route.children); + return [ + serverRouteSubtree + ? [ + ...(serverRoutesList ?? []), + { + ...route, + children: serverRouteSubtree, + }, + ] + : serverRoutesList, + clientRouteSubtree + ? [ + ...(clientRoutesList ?? []), + { + ...route, + children: clientRouteSubtree, + }, + ] + : clientRoutesList, + ]; + }, + [undefined, undefined], + ), + ); + return [ - { - element: createElement(layoutComponent), - children: nestedRoutes, - handle: { - ignoreFallback: true, - }, - }, + // The server subtree is wrapped with the server layout component, + // which applies the top-level server layout to all matches. + ...(serverRoutesTree + ? [ + { + element: createElement(layoutComponent), + children: serverRoutesTree, + handle: { + [RouteHandleFlags.IGNORE_FALLBACK]: true, + }, + }, + ] + : []), + // The client route subtree is preserved without wrapping. + ...(clientRoutesTree ?? []), ]; - } - - function checkFlowLayout(route: RouteObject): boolean { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - let flowLayout = typeof route.handle === 'object' && 'flowLayout' in route.handle && route.handle.flowLayout; - // Check children if they have layout. If yes then parent should have layout also. - if (!flowLayout && route.children) { - flowLayout = route.children.filter((child) => checkFlowLayout(child)).length > 0; - } - return flowLayout; - } - - this.#modifiers.push((routes: readonly RouteObject[] | undefined) => { - if (!routes) { - return routes; - } - const withLayout = routes.filter((route) => checkFlowLayout(route)); - const allRoutes = routes.filter((route) => !withLayout.includes(route)); - const catchAll = [routes.find((route) => route.path === '*')].filter((route) => route !== undefined); - withLayout.push(...catchAll); // Add * fallback to all child routes - - allRoutes.unshift(...applyLayouts(withLayout)); - return allRoutes; }); - return this; } diff --git a/packages/ts/file-router/test/runtime/RouterConfigurationBuilder.spec.tsx b/packages/ts/file-router/test/runtime/RouterConfigurationBuilder.spec.tsx index e751c500e4..1c4d7eff33 100644 --- a/packages/ts/file-router/test/runtime/RouterConfigurationBuilder.spec.tsx +++ b/packages/ts/file-router/test/runtime/RouterConfigurationBuilder.spec.tsx @@ -99,6 +99,9 @@ describe('RouterBuilder', () => { children: [ { path: '/child', + handle: { + flowLayout: true, + }, }, ], }, @@ -119,6 +122,9 @@ describe('RouterBuilder', () => { children: [ { path: '/child', + handle: { + flowLayout: true, + }, }, ], path: '/test', @@ -128,6 +134,9 @@ describe('RouterBuilder', () => { }, ], element: createElement(Server), + handle: { + ignoreFallback: true, + }, }, { children: [ @@ -176,6 +185,16 @@ describe('RouterBuilder', () => { flowLayout: true, }, }, + { + path: '/outside', + handle: { + flowLayout: false, + }, + }, + { + path: '/nested-empty-layout', + children: [], + }, ], }, { @@ -187,8 +206,16 @@ describe('RouterBuilder', () => { { path: '/child', }, + { + path: '/empty-layout', + children: [], + }, ], }, + { + path: '/empty-layout-outside', + children: [], + }, ]) .withLayout(Server) .build(); @@ -199,16 +226,16 @@ describe('RouterBuilder', () => { { children: [ { - path: '', handle: { flowLayout: true, }, + path: '', }, { - path: '/nested', handle: { flowLayout: true, }, + path: '/nested', }, ], path: 'nest', @@ -218,6 +245,10 @@ describe('RouterBuilder', () => { { path: '/child', }, + { + path: '/empty-layout', + children: [], + }, ], path: '/test', handle: { @@ -226,6 +257,9 @@ describe('RouterBuilder', () => { }, ], element: createElement(Server), + handle: { + ignoreFallback: true, + }, }, { children: [ @@ -236,6 +270,25 @@ describe('RouterBuilder', () => { ], path: '', }, + { + children: [ + { + path: '/outside', + handle: { + flowLayout: false, + }, + }, + { + path: '/nested-empty-layout', + children: [], + }, + ], + path: 'nest', + }, + { + path: '/empty-layout-outside', + children: [], + }, ]); });