diff --git a/packages/ts/file-router/src/runtime/RouterConfigurationBuilder.ts b/packages/ts/file-router/src/runtime/RouterConfigurationBuilder.ts index a2f4f86c96..87e9dafbc8 100644 --- a/packages/ts/file-router/src/runtime/RouterConfigurationBuilder.ts +++ b/packages/ts/file-router/src/runtime/RouterConfigurationBuilder.ts @@ -66,7 +66,7 @@ export class RouterConfigurationBuilder { withFileRoutes(routes: readonly AgnosticRoute[]): this { return this.update(routes, (original, added, children) => { if (added) { - const { module, path } = added; + const { module, path, flowLayout } = added; if (!isReactRouteModule(module)) { throw new Error(`The module for the "${path}" section doesn't have the React component exported by default`); } @@ -75,6 +75,7 @@ export class RouterConfigurationBuilder { const handle = { ...module?.config, title: module?.config?.title ?? convertComponentNameToTitle(module?.default), + flowLayout: module?.config?.flowLayout ?? flowLayout, }; if (path === '' && !children) { @@ -118,7 +119,8 @@ export class RouterConfigurationBuilder { ]; this.update(fallbackRoutes, (original, added, children) => { - if (original) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (original && !original.handle?.ignoreFallback) { if (!children) { return original; } @@ -157,6 +159,9 @@ export class RouterConfigurationBuilder { { element: createElement(layoutComponent), children: nestedRoutes, + handle: { + ignoreFallback: true, + }, }, ]; } @@ -164,6 +169,7 @@ export class RouterConfigurationBuilder { 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; } diff --git a/packages/ts/file-router/src/runtime/createRoute.ts b/packages/ts/file-router/src/runtime/createRoute.ts index d2d5ebf096..7dadff17df 100644 --- a/packages/ts/file-router/src/runtime/createRoute.ts +++ b/packages/ts/file-router/src/runtime/createRoute.ts @@ -9,10 +9,22 @@ import type { AgnosticRoute, Module } from '../types.js'; * * @returns A framework-agnostic route object. */ -export function createRoute(path: string, children?: readonly AgnosticRoute[]): AgnosticRoute; -export function createRoute(path: string, module: Module, children?: readonly AgnosticRoute[]): AgnosticRoute; +export function createRoute(path: string, flowLayout: boolean, children?: readonly AgnosticRoute[]): AgnosticRoute; export function createRoute( path: string, + flowLayout: boolean, + module: Module, + children?: readonly AgnosticRoute[], +): AgnosticRoute; +export function createRoute( + path: string, + flowLayout: boolean, + module: Module, + children?: readonly AgnosticRoute[], +): AgnosticRoute; +export function createRoute( + path: string, + flowLayout: boolean, moduleOrChildren?: Module | readonly AgnosticRoute[], children?: readonly AgnosticRoute[], ): AgnosticRoute { @@ -28,5 +40,6 @@ export function createRoute( path, module, children, + flowLayout, }; } diff --git a/packages/ts/file-router/src/types.d.ts b/packages/ts/file-router/src/types.d.ts index deff5b0436..8ecdf36de4 100644 --- a/packages/ts/file-router/src/types.d.ts +++ b/packages/ts/file-router/src/types.d.ts @@ -77,6 +77,7 @@ export type AgnosticRoute = Readonly<{ path: string; module?: Module; children?: readonly AgnosticRoute[]; + flowLayout?: boolean; }>; /** diff --git a/packages/ts/file-router/src/vite-plugin.ts b/packages/ts/file-router/src/vite-plugin.ts index 0f2ab98762..5f9b36e50b 100644 --- a/packages/ts/file-router/src/vite-plugin.ts +++ b/packages/ts/file-router/src/vite-plugin.ts @@ -81,6 +81,7 @@ export default function vitePluginFileSystemRouter({ runtimeUrls = { json: new URL('file-routes.json', isDevMode ? _generatedDir : _outDir), code: new URL('file-routes.ts', _generatedDir), + layouts: new URL('layouts.json', _generatedDir), }; }, async buildStart() { diff --git a/packages/ts/file-router/src/vite-plugin/collectRoutesFromFS.ts b/packages/ts/file-router/src/vite-plugin/collectRoutesFromFS.ts index a1e0d9bbae..ff39528ae8 100644 --- a/packages/ts/file-router/src/vite-plugin/collectRoutesFromFS.ts +++ b/packages/ts/file-router/src/vite-plugin/collectRoutesFromFS.ts @@ -9,6 +9,7 @@ export type RouteMeta = Readonly<{ path: string; file?: URL; layout?: URL; + flowLayout?: boolean; children?: readonly RouteMeta[]; }>; diff --git a/packages/ts/file-router/src/vite-plugin/createRoutesFromMeta.ts b/packages/ts/file-router/src/vite-plugin/createRoutesFromMeta.ts index 3d3ef44c1c..c45f2473b9 100644 --- a/packages/ts/file-router/src/vite-plugin/createRoutesFromMeta.ts +++ b/packages/ts/file-router/src/vite-plugin/createRoutesFromMeta.ts @@ -50,9 +50,15 @@ function createImport(mod: string, file: string): ImportDeclaration { * @param mod - The name of the route module imported as a namespace. * @param children - The list of child route call expressions. */ -function createRouteData(path: string, mod: string | undefined, children?: readonly CallExpression[]): CallExpression { +function createRouteData( + path: string, + flowLayout: boolean | undefined, + mod: string | undefined, + children?: readonly CallExpression[], +): CallExpression { + const serverLayout = flowLayout ?? false; return template( - `const route = createRoute("${path}"${mod ? `, ${mod}` : ''}${children ? `, CHILDREN` : ''})`, + `const route = createRoute("${path}",${serverLayout}${mod ? `, ${mod}` : ''}${children ? `, CHILDREN` : ''})`, ([statement]) => (statement as VariableStatement).declarationList.declarations[0].initializer as CallExpression, [ transformer((node) => @@ -84,7 +90,7 @@ export default function createRoutesFromMeta(views: readonly RouteMeta[], { code .map((dup) => `console.error("Two views share the same path: ${dup}");`), ); - return metas.map(({ file, layout, path, children }) => { + return metas.map(({ file, layout, path, children, flowLayout }) => { let _children: readonly CallExpression[] | undefined; if (children) { @@ -103,7 +109,7 @@ export default function createRoutesFromMeta(views: readonly RouteMeta[], { code imports.push(createImport(mod, relativize(layout, codeDir))); } - return createRouteData(convertFSRouteSegmentToURLPatternFormat(path), mod, _children); + return createRouteData(convertFSRouteSegmentToURLPatternFormat(path), flowLayout, mod, _children); }); }); diff --git a/packages/ts/file-router/src/vite-plugin/createViewConfigJson.ts b/packages/ts/file-router/src/vite-plugin/createViewConfigJson.ts index 3d2d281e2d..eab23f6638 100644 --- a/packages/ts/file-router/src/vite-plugin/createViewConfigJson.ts +++ b/packages/ts/file-router/src/vite-plugin/createViewConfigJson.ts @@ -32,7 +32,7 @@ export default async function createViewConfigJson(views: readonly RouteMeta[]): views, async (routes, next) => await Promise.all( - routes.map(async ({ path, file, layout, children }) => { + routes.map(async ({ path, file, layout, children, flowLayout }) => { const newChildren = children ? await next(...children) : undefined; if (!file && !layout) { @@ -59,6 +59,12 @@ export default async function createViewConfigJson(views: readonly RouteMeta[]): const code = node.initializer.getText(sourceFile); const script = new Script(`(${code})`); config = script.runInThisContext() as ViewConfig; + if (config.flowLayout === undefined) { + const copy = JSON.parse(JSON.stringify(config)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + copy.flowLayout = flowLayout ?? false; + config = copy; + } } } else if (node.getText(sourceFile).startsWith('export default')) { waitingForIdentifier = true; diff --git a/packages/ts/file-router/src/vite-plugin/generateRuntimeFiles.ts b/packages/ts/file-router/src/vite-plugin/generateRuntimeFiles.ts index 57e52bda07..593af7e123 100644 --- a/packages/ts/file-router/src/vite-plugin/generateRuntimeFiles.ts +++ b/packages/ts/file-router/src/vite-plugin/generateRuntimeFiles.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import type { Logger } from 'vite'; -import collectRoutesFromFS from './collectRoutesFromFS.js'; +import collectRoutesFromFS, { type RouteMeta } from './collectRoutesFromFS.js'; import createRoutesFromMeta from './createRoutesFromMeta.js'; import createViewConfigJson from './createViewConfigJson.js'; @@ -18,6 +18,10 @@ export type RuntimeFileUrls = Readonly<{ * The URL of the module with the routes tree in a framework-agnostic format. */ code: URL; + /** + * The URL of the JSON file containing server layout path information. + */ + layouts: URL; }>; /** @@ -43,6 +47,44 @@ async function generateRuntimeFile(url: URL, data: string): Promise { } } +async function applyLayouts(routeMeta: readonly RouteMeta[], layouts: URL): Promise { + if (!existsSync(layouts)) { + return routeMeta; + } + const layoutContents = await readFile(layouts, 'utf-8'); + const availableLayouts: any[] = JSON.parse(layoutContents); + function layoutExists(routePath: string) { + return ( + availableLayouts.filter((layout: any) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-call + const normalizedLayout = layout.path[0] === '/' ? layout.path.substring(1) : layout.path; + const normalizedRoute = routePath.startsWith('/') ? routePath.substring(1) : routePath; + return normalizedRoute.startsWith(normalizedLayout); + }).length > 0 + ); + } + function enableFlowLayout(route: RouteMeta) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + route.flowLayout = true; + if (route.children) { + // eslint-disable-next-line @typescript-eslint/no-for-in-array,no-restricted-syntax + for (const position in route.children) { + enableFlowLayout(route.children[position]); + } + } + } + + routeMeta + .filter((route) => route.layout === undefined && layoutExists(route.path)) + .map((route) => { + enableFlowLayout(route); + return route; + }); + + return routeMeta; +} + /** * Collects all file-based routes from the given directory, and based on them generates two files * described by {@link RuntimeFileUrls} type. @@ -59,10 +101,11 @@ export async function generateRuntimeFiles( logger: Logger, debug: boolean, ): Promise { - const routeMeta = existsSync(viewsDir) ? await collectRoutesFromFS(viewsDir, { extensions, logger }) : []; + let routeMeta = existsSync(viewsDir) ? await collectRoutesFromFS(viewsDir, { extensions, logger }) : []; if (debug) { logger.info('Collected file-based routes'); } + routeMeta = await applyLayouts(routeMeta, urls.layouts); const runtimeRoutesCode = createRoutesFromMeta(routeMeta, urls); const viewConfigJson = await createViewConfigJson(routeMeta); diff --git a/packages/ts/file-router/test/vite-plugin/createRoutesFromMeta.spec.ts b/packages/ts/file-router/test/vite-plugin/createRoutesFromMeta.spec.ts index 4aaa19cd95..d092998d96 100644 --- a/packages/ts/file-router/test/vite-plugin/createRoutesFromMeta.spec.ts +++ b/packages/ts/file-router/test/vite-plugin/createRoutesFromMeta.spec.ts @@ -19,6 +19,7 @@ describe('@vaadin/hilla-file-router', () => { runtimeUrls = { json: new URL('server/file-routes.json', dir), code: new URL('generated/file-routes.ts', dir), + layouts: new URL('generated/layouts.json', dir), }; }); @@ -41,30 +42,30 @@ import * as Page12 from "../views/test/issue-002378/{requiredParam}/edit.js"; import * as Layout15 from "../views/test/issue-002571-empty-layout/@layout.js"; import * as Page16 from "../views/test/issue-002879-config-below.js"; const routes: readonly AgnosticRoute[] = [ - createRoute("nameToReplace", Page0), - createRoute("profile", [ - createRoute("", Page1), - createRoute("account", Layout5, [ - createRoute("security", [ - createRoute("password", Page2), - createRoute("two-factor-auth", Page3) + createRoute("nameToReplace", false, Page0), + createRoute("profile", false, [ + createRoute("", false, Page1), + createRoute("account", false, Layout5, [ + createRoute("security", false, [ + createRoute("password", false, Page2), + createRoute("two-factor-auth", false, Page3) ]) ]), - createRoute("friends", Layout8, [ - createRoute("list", Page6), - createRoute(":user", Page7) + createRoute("friends", false, Layout8, [ + createRoute("list", false, Page6), + createRoute(":user", false, Page7) ]) ]), - createRoute("test", [ - createRoute(":optional?", Page10), - createRoute("*", Page11), - createRoute("issue-002378", [ - createRoute(":requiredParam", [ - createRoute("edit", Page12) + createRoute("test", false, [ + createRoute(":optional?", false, Page10), + createRoute("*", false, Page11), + createRoute("issue-002378", false, [ + createRoute(":requiredParam", false, [ + createRoute("edit", false, Page12) ]) ]), - createRoute("issue-002571-empty-layout", Layout15, []), - createRoute("issue-002879-config-below", Page16) + createRoute("issue-002571-empty-layout", false, Layout15, []), + createRoute("issue-002879-config-below", false, Page16) ]) ]; export default routes; diff --git a/packages/ts/file-router/test/vite-plugin/generateRuntimeFiles.spec.ts b/packages/ts/file-router/test/vite-plugin/generateRuntimeFiles.spec.ts index aae4ea139e..14a9408182 100644 --- a/packages/ts/file-router/test/vite-plugin/generateRuntimeFiles.spec.ts +++ b/packages/ts/file-router/test/vite-plugin/generateRuntimeFiles.spec.ts @@ -22,6 +22,7 @@ describe('@vaadin/hilla-file-router', () => { runtimeUrls = { json: new URL('server/file-routes.json', tmp), code: new URL('generated/file-routes.ts', tmp), + layouts: new URL('generated/layouts.json', tmp), }; await createTestingRouteFiles(viewsDir);