diff --git a/packages/app-core/src/define-app-types.ts b/packages/app-core/src/define-app-types.ts index ced77007..928a23dd 100644 --- a/packages/app-core/src/define-app-types.ts +++ b/packages/app-core/src/define-app-types.ts @@ -87,7 +87,7 @@ export interface FSApi { path: PathApi; } -export interface IReactApp { +export interface IReactApp { /** * Should be isomorphic, should return the same result on the server and in a web worker * returns the app manifest containing a list of routes for the app. @@ -95,7 +95,7 @@ export interface IReactApp { * should call onManifestUpdate when the manifest is updated */ prepareApp: (options: IPrepareAppOptions) => Promise<{ - manifest: IAppManifest; + manifest: IAppManifest; dispose: () => void; }>; @@ -106,13 +106,13 @@ export interface IReactApp { * returns the information needed to create a new page * */ - getNewPageInfo?: (options: IGetNewPageInfoOptions) => { + getNewPageInfo?: (options: IGetNewPageInfoOptions) => { isValid: boolean; errorMessage?: string; warningMessage?: string; pageModule: string; newPageSourceCode: string; - newPageRoute?: RouteInfo; + newPageRoute?: RouteInfo; routingPattern?: RoutingPattern; }; @@ -121,12 +121,12 @@ export interface IReactApp { * * returns the information needed to move a page */ - getMovePageInfo?: (options: IMovePageInfoOptions) => { + getMovePageInfo?: (options: IMovePageInfoOptions) => { isValid: boolean; errorMessage?: string; warningMessage?: string; pageModule: string; - newPageRoute?: RouteInfo; + newPageRoute?: RouteInfo; routingPattern?: RoutingPattern; }; /** @@ -151,22 +151,25 @@ export interface IReactApp { */ hasGetStaticRoutes?: (options: ICallServerMethodOptions, forRouteAtFilePath: string) => Promise; - - App: React.ComponentType>; + App: React.ComponentType>; /** * Renders the App into an HTML element * * @returns a cleanup function */ - render: (targetElement: HTMLElement, props: IReactAppProps) => Promise<() => void>; + render: ( + targetElement: HTMLElement, + props: IReactAppProps, + ) => Promise<() => void>; } -export interface IAppManifest { - routes: RouteInfo[]; - homeRoute?: RouteInfo; - errorRoutes?: RouteInfo[]; +export interface IAppManifest { + extraData: MANIFEST_EXTRA_DATA; + routes: RouteInfo[]; + homeRoute?: RouteInfo; + errorRoutes?: RouteInfo[]; } -export interface RouteInfo { +export interface RouteInfo { pageModule: string; pageExportName?: string; /** @@ -183,7 +186,6 @@ export interface RouteInfo { */ hasGetStaticRoutes?: boolean; - /** * a list of export names of the page that should be editable * if the page is a function, the UI will edit its return value @@ -195,7 +197,7 @@ export interface RouteInfo { /** * any extra data that should be passed to the App component */ - extraData: T; + extraData: ROUTE_EXTRA_DATA; path: Array; /** * readable (and editable) text representation of the path @@ -215,8 +217,8 @@ export interface DynamicRoutePart { name: string; } -export interface IReactAppProps { - manifest: IAppManifest; +export interface IReactAppProps { + manifest: IAppManifest; importModule: DynamicImport; uri: string; setUri: (uri: string) => void; @@ -236,22 +238,23 @@ export type DynamicImport = ( export interface IPrepareAppOptions { // eslint-disable-next-line @typescript-eslint/no-explicit-any - onManifestUpdate: (appProps: IAppManifest) => void; + onManifestUpdate: (appProps: IAppManifest) => void; fsApi: FSApi; } export interface ICallServerMethodOptions { fsApi: FSApi; importModule: DynamicImport; } -export interface IGetNewPageInfoOptions { +export interface IGetNewPageInfoOptions { fsApi: FSApi; requestedURI: string; - manifest: IAppManifest; + manifest: IAppManifest; } export type RoutingPattern = 'file' | 'folder(route)' | 'folder(index)'; -export interface IMovePageInfoOptions extends IGetNewPageInfoOptions { +export interface IMovePageInfoOptions + extends IGetNewPageInfoOptions { movedFilePath: string; } export interface EditablePointOfInterest { @@ -265,4 +268,5 @@ export interface IResults { errorMessage?: string; } -export type OmitReactApp, D> = Omit; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type OmitReactApp> = Omit; diff --git a/packages/app-core/src/define-app.tsx b/packages/app-core/src/define-app.tsx index 4e18404b..5897988f 100644 --- a/packages/app-core/src/define-app.tsx +++ b/packages/app-core/src/define-app.tsx @@ -1,8 +1,10 @@ import { reactErrorHandledRendering } from '@wixc3/react-board/dist/react-error-handled-render'; import { IReactApp, OmitReactApp } from './define-app-types'; -export function defineApp(input: OmitReactApp, T>): IReactApp { - const res: IReactApp = { +export function defineApp( + input: OmitReactApp>, +): IReactApp { + const res: IReactApp = { ...input, async render(target, appProps) { diff --git a/packages/app-core/test-kit/index.ts b/packages/app-core/test-kit/index.ts index a0207f5f..dc85abb0 100644 --- a/packages/app-core/test-kit/index.ts +++ b/packages/app-core/test-kit/index.ts @@ -4,10 +4,10 @@ import { createMemoryFs, IMemFileSystem } from '@file-services/memory'; import { createRequestResolver } from '@file-services/resolve'; import path from '@file-services/path'; import { IDirectoryContents } from '@file-services/types'; -export interface AppDefDriverOptions { - app: IReactApp; +export interface AppDefDriverOptions { + app: IReactApp; initialFiles: IDirectoryContents; - evaluatedNodeModules: Record + evaluatedNodeModules: Record; /** * @default '/app-def.ts' */ @@ -18,22 +18,22 @@ export interface AppDefDriverOptions { projectPath?: string; } type DirListenerObj = { cb: (files: string[]) => void; dirPath: string }; -export class AppDefDriver { +export class AppDefDriver { private fs: IMemFileSystem; private moduleSystem: ICommonJsModuleSystem; private dirListeners: Array = []; - private manifestListeners: Set<(manifest: IAppManifest) => void> = new Set(); + private manifestListeners: Set<(manifest: IAppManifest) => void> = new Set(); private fileListeners: Record void>> = {}; private exportsListeners: Record void>> = {}; - private lastManifest: IAppManifest | null = null; + private lastManifest: IAppManifest | null = null; private disposeApp?: () => void; - constructor(private options: AppDefDriverOptions) { + constructor(private options: AppDefDriverOptions) { this.fs = createMemoryFs(options.initialFiles); - const resolver = createRequestResolver({fs: this.fs}); + const resolver = createRequestResolver({ fs: this.fs }); this.moduleSystem = createBaseCjsModuleSystem({ dirname: this.fs.dirname, readFileSync: (filePath) => { - const fileContents = this.fs.readFileSync(filePath, {encoding: 'utf8'}); + const fileContents = this.fs.readFileSync(filePath, { encoding: 'utf8' }); if (typeof fileContents !== 'string') { throw new Error(`No content for: ${filePath}`); } @@ -44,7 +44,7 @@ export class AppDefDriver { return request; } const resolved = resolver(contextPath, request); - return resolved.resolvedFile + return resolved.resolvedFile; }, globals: {}, }); @@ -73,7 +73,7 @@ export class AppDefDriver { addOrUpdateFile(filePath: string, contents: string) { const existingFile = !!this.fs.existsSync(filePath); this.fs.writeFileSync(filePath, contents); - + if (!existingFile) { for (const listener of this.dirListeners) { if (filePath.startsWith(listener.dirPath)) { @@ -123,10 +123,10 @@ export class AppDefDriver { movedFilePath, }); } - addManifestListener(cb: (manifest: IAppManifest) => void) { + addManifestListener(cb: (manifest: IAppManifest) => void) { this.manifestListeners.add(cb); } - removeManifestListener(cb: (manifest: IAppManifest) => void) { + removeManifestListener(cb: (manifest: IAppManifest) => void) { this.manifestListeners.delete(cb); } private dispatchManifestUpdate() { @@ -150,16 +150,20 @@ export class AppDefDriver { const createProps = (uri: string) => ({ callServerMethod(filePath: string, methodName: string, args: unknown[]) { return app.callServerMethod!( - { - fsApi, - importModule - }, - filePath, methodName, args + { + fsApi, + importModule, + }, + filePath, + methodName, + args, ); }, importModule: this.importModule, manifest: this.lastManifest!, - onCaughtError() {/**/}, + onCaughtError() { + /**/ + }, setUri(_uri: string) { // ToDo: implement }, @@ -167,24 +171,24 @@ export class AppDefDriver { }); const unmount = await app.render(container, createProps(uri)); let lastUri = uri; - const rerender = ({uri = '/'}: {uri?: string} = {})=>{ + const rerender = ({ uri = '/' }: { uri?: string } = {}) => { lastUri = uri; return app.render(container, createProps(uri)); - } + }; const manifestListener = () => { - void rerender({uri: lastUri}); + void rerender({ uri: lastUri }); }; if (testAutoRerenderOnManifestUpdate !== false) { this.addManifestListener(manifestListener); } return { - dispose: ()=> { + dispose: () => { unmount(); container.remove(); - this.removeManifestListener(manifestListener) + this.removeManifestListener(manifestListener); }, container, - rerender + rerender, }; } dispose() { @@ -215,7 +219,7 @@ export class AppDefDriver { stop: () => { listeners.delete(cb); }, - contents: Promise.resolve(this.fs.readFileSync(filePath, {encoding: 'utf8'}) ?? null), + contents: Promise.resolve(this.fs.readFileSync(filePath, { encoding: 'utf8' }) ?? null), }; }, watchFileExports: (filePath: string, cb) => { @@ -227,11 +231,11 @@ export class AppDefDriver { try { const module = this.moduleSystem.requireModule(filePath); moduleExports = Object.keys(module as Record); - } catch (e){ + } catch (e) { const errMsg = e instanceof Error ? e.message : String(e); throw new Error(`error requiring module ${filePath}: ${errMsg}`); } - + return { stop: () => { listeners.delete(cb); @@ -248,11 +252,11 @@ export class AppDefDriver { } catch (error) { errorMessage = error instanceof Error ? error.message : String(error); } - return { module, errorMessage } + return { module, errorMessage }; }; const { stop } = this.fsApi.watchFile(filePath, () => { this.moduleSystem.moduleCache.delete(filePath); - const {module, errorMessage} = requireModule(); + const { module, errorMessage } = requireModule(); onModuleChange?.({ results: module || null, status: errorMessage ? 'invalid' : 'ready', @@ -264,7 +268,7 @@ export class AppDefDriver { moduleResults: Promise.resolve({ status: errorMessage ? 'invalid' : 'ready', results: module || null, - errorMessage + errorMessage, }), dispose() { stop(); @@ -283,7 +287,4 @@ export class AppDefDriver { } return nestedPaths; } - } - - diff --git a/packages/define-remix-app/src/define-remix-app.tsx b/packages/define-remix-app/src/define-remix-app.tsx index f4a9867e..2c728591 100644 --- a/packages/define-remix-app/src/define-remix-app.tsx +++ b/packages/define-remix-app/src/define-remix-app.tsx @@ -24,6 +24,7 @@ import { pathToRemixRouterUrl, readableUriToFilePath, RouteExtraInfo, + RouteModuleInfo, routePartsToRoutePath, routePathId, serializeResponse, @@ -71,7 +72,7 @@ export default function defineRemixApp({ appPath, routingPattern = 'file' }: IDe requestedURI, manifest, layoutMap, - }: IGetNewPageInfoOptions & { layoutMap: Map }) => { + }: IGetNewPageInfoOptions & { layoutMap: Map }) => { const appDir = fsApi.path.join(fsApi.path.dirname(fsApi.appDefFilePath), appPath); const routeDir = fsApi.path.join(appDir, 'routes'); const varNames = new Set(); @@ -184,11 +185,7 @@ export default function defineRemixApp({ appPath, routingPattern = 'file' }: IDe newPageRoute: { pageModule, pageExportName: 'default', - extraData: { - parentLayouts, - routeId: filePathToRouteId(appDir, pageModule), - exportNames: varNames.size ? ['loader', 'meta', 'default'] : ['meta', 'default'], - }, + extraData: undefined, hasGetStaticRoutes: false, path: wantedPath, pathString: requestedURI, @@ -196,7 +193,7 @@ export default function defineRemixApp({ appPath, routingPattern = 'file' }: IDe }, }; }; - return defineApp({ + return defineApp({ App: ({ manifest, importModule, @@ -204,7 +201,7 @@ export default function defineRemixApp({ appPath, routingPattern = 'file' }: IDe uri, onCaughtError, callServerMethod, - }: IReactAppProps) => { + }: IReactAppProps) => { const uriRef = useRef(uri); uriRef.current = uri; const { Router } = useMemo( @@ -358,6 +355,15 @@ export default function defineRemixApp({ appPath, routingPattern = 'file' }: IDe const compute = async (filesInDir: string[], rootExportNames: string[]) => { const routeDirLength = routeDir.length + 1; + + const rootModuleInfo: RouteModuleInfo = { + exportNames: rootExportNames, + children: [], + file: rootPath, + id: 'root', + path: '/', + }; + rootLayouts = [ { id: filePathToRouteId(appDir, rootPath), @@ -471,71 +477,80 @@ export default function defineRemixApp({ appPath, routingPattern = 'file' }: IDe }, ); layoutMap = layouts; - const initialManifest: IAppManifest = { + const initialManifest: IAppManifest = { routes: [], errorRoutes: [], + extraData: rootModuleInfo, }; const sortedFilesByRoute = [...routes.entries()].sort(([, a], [, b]) => a.readableName.localeCompare(b.readableName), ); if (rootExportNames.includes('ErrorBoundary')) { - const errorRoute = anErrorRoute( - routeDir, - [], - rootPath, - { - parentLayouts: [], - routeId: 'error', - exportNames: rootExportNames, - }, - fsApi.path, - ); + const errorRoute = anErrorRoute(routeDir, [], rootPath, [], fsApi.path); initialManifest.errorRoutes!.push(errorRoute); } + const routeInfoById = new Map(); + routeInfoById.set(rootModuleInfo.id, rootModuleInfo); for (const [, value] of sortedFilesByRoute) { const exports = exportNames.get(value.file) || []; + const { parentLayouts } = getRouteLayouts(value.file.slice(routeDirLength), fsApi); + let parent = rootModuleInfo; + for (const layout of parentLayouts) { + if (layout.layoutModule === rootPath) { + continue; + } + const existingRoute = routeInfoById.get(layout.id); + if (!existingRoute) { + const layoutExports = exportNames.get(layout.layoutModule) || []; + const layoutRoute: RouteModuleInfo = { + id: layout.id, + children: [], + exportNames: layoutExports, + file: layout.layoutModule, + path: layout.path, + }; + routeInfoById.set(layout.id, layoutRoute); + parent.children.push(layoutRoute); + parent = layoutRoute; + } else { + parent = existingRoute; + } + } + + const routeId = filePathToRouteId(appDir, value.file); + const route: RouteModuleInfo = { + id: routeId, + children: [], + exportNames: exports, + file: value.file, + path: pathToRemixRouterUrl(value.path), + }; + routeInfoById.set(routeId, route); + parent.children.push(route); if (value.path.length === 0) { initialManifest.homeRoute = aRoute( routeDir, [], + rootLayouts, value.file, - { - parentLayouts: rootLayouts, - routeId: filePathToRouteId(appDir, value.file), - exportNames: exports, - }, fsApi.path, + rootExportNames.includes('getStaticRoutes'), ); if (exports.includes('ErrorBoundary')) { - const errorRoute = anErrorRoute( - routeDir, - value.path, - value.file, - { - parentLayouts: rootLayouts, - routeId: filePathToRouteId(appDir, value.file), - exportNames: exports, - }, - fsApi.path, - ); + const errorRoute = anErrorRoute(routeDir, value.path, value.file, rootLayouts, fsApi.path); initialManifest.errorRoutes!.push(errorRoute); } } else { - const { parentLayouts } = getRouteLayouts(value.file.slice(routeDirLength), fsApi); - if (exports.includes('default')) { const route = aRoute( routeDir, value.path, + parentLayouts, value.file, - { - parentLayouts, - routeId: filePathToRouteId(appDir, value.file), - exportNames: exports, - }, fsApi.path, + exports.includes('getStaticRoutes'), ); initialManifest.routes.push(route); if (exports.includes('ErrorBoundary')) { @@ -543,11 +558,7 @@ export default function defineRemixApp({ appPath, routingPattern = 'file' }: IDe routeDir, value.path, value.file, - { - parentLayouts, - routeId: filePathToRouteId(appDir, value.file), - exportNames: exports, - }, + parentLayouts, fsApi.path, ); initialManifest.errorRoutes!.push(errorRoute); diff --git a/packages/define-remix-app/src/manifest-to-router.tsx b/packages/define-remix-app/src/manifest-to-router.tsx index 40a9e1b1..1c9a9d5e 100644 --- a/packages/define-remix-app/src/manifest-to-router.tsx +++ b/packages/define-remix-app/src/manifest-to-router.tsx @@ -2,8 +2,7 @@ import { DynamicImport, IAppManifest, ErrorReporter, IResults } from '@wixc3/app import { deserializeResponse, isSerializedResponse, - pathToRemixRouterUrl, - RouteExtraInfo, + RouteModuleInfo, SerializedResponse, serializeRequest, } from './remix-app-utils'; @@ -19,7 +18,7 @@ import { Navigation } from './navigation'; type RouteObject = Parameters[0][0]; export const manifestToRouter = ( - manifest: IAppManifest, + manifest: IAppManifest, navigation: Navigation, requireModule: DynamicImport, setUri: (uri: string) => void, @@ -27,9 +26,10 @@ export const manifestToRouter = ( prevUri: { current: string }, callServerMethod: (filePath: string, methodName: string, args: unknown[]) => Promise, ) => { - const rootRouteInfo = manifest.homeRoute || manifest.routes[0]; - const rootFilePath = rootRouteInfo?.parentLayouts?.[0]?.layoutModule; - const rootExports = rootRouteInfo?.extraData.parentLayouts?.[0]?.exportNames; + const routerData = manifest.extraData; + + const rootFilePath = routerData.file; + const rootExports = routerData.exportNames; if (!rootFilePath || !rootExports) { return { Router: createRemixStub([]), @@ -50,13 +50,13 @@ export const manifestToRouter = ( callServerMethod, navigation, ); - const layoutMap = new Map(); - if (manifest.homeRoute) { - rootRoute.children = [ - fileToRoute( - '/', - manifest.homeRoute.pageModule, - manifest.homeRoute.extraData.exportNames, + + const addChildren = (route: RouteObject, children: RouteModuleInfo[]) => { + route.children = children.map((child) => { + const childRoute = fileToRoute( + child.path, + child.file, + child.exportNames, requireModule, setUri, onCaughtError, @@ -64,52 +64,12 @@ export const manifestToRouter = ( prevUri, callServerMethod, navigation, - ), - ]; - } - for (const route of manifest.routes) { - const routeObject = fileToRoute( - pathToRemixRouterUrl(route.path), - route.pageModule, - route.extraData.exportNames, - requireModule, - setUri, - onCaughtError, - false, - prevUri, - callServerMethod, - navigation, - ); - let parentRoute: RouteObject = rootRoute; - for (const parentLayout of route.extraData.parentLayouts) { - if (parentLayout.layoutModule === rootFilePath) { - continue; - } - if (!layoutMap.has(parentLayout.layoutModule)) { - layoutMap.set( - parentLayout.layoutModule, - fileToRoute( - parentLayout.path, - parentLayout.layoutModule, - route.extraData.exportNames, - requireModule, - setUri, - onCaughtError, - false, - prevUri, - callServerMethod, - navigation, - ), - ); - parentRoute.children = parentRoute.children || []; - parentRoute.children.push(layoutMap.get(parentLayout.layoutModule)!); - parentRoute = layoutMap.get(parentLayout.layoutModule)!; - } - parentRoute = layoutMap.get(parentLayout.layoutModule)!; - } - parentRoute.children = parentRoute.children || []; - parentRoute.children.push(routeObject); - } + ); + addChildren(childRoute, child.children); + return childRoute; + }); + }; + addChildren(rootRoute, routerData.children); const Router = createRemixStub([rootRoute]); diff --git a/packages/define-remix-app/src/remix-app-utils.ts b/packages/define-remix-app/src/remix-app-utils.ts index 6ec4f047..662f0aa1 100644 --- a/packages/define-remix-app/src/remix-app-utils.ts +++ b/packages/define-remix-app/src/remix-app-utils.ts @@ -1,5 +1,12 @@ import { DynamicRoutePart, PathApi, RouteInfo, RoutingPattern, StaticRoutePart } from '@wixc3/app-core'; +export interface RouteModuleInfo { + id: string; + path: string; + file: string; + exportNames: string[]; + children: RouteModuleInfo[]; +} export interface ParentLayoutWithExtra { layoutModule: string; layoutExportName: string; @@ -80,32 +87,33 @@ export function readableUriToFilePath( export const aRoute = ( routeDirPath: string, path: RouteInfo['path'], + parentLayouts: RouteInfo['parentLayouts'], pageModule: string, - extraData: RouteExtraInfo, pathApi: PathApi, -): RouteInfo => ({ + hasGetStaticRoutes: boolean, +): RouteInfo => ({ path, pageModule, pageExportName: 'default', - parentLayouts: extraData.parentLayouts, + parentLayouts, pathString: filePathToReadableUri(pageModule.slice(routeDirPath.length + 1), pathApi) || '', - extraData, - hasGetStaticRoutes: extraData.exportNames.includes('getStaticRoutes'), + hasGetStaticRoutes, + extraData: undefined, }); export const anErrorRoute = ( routeDirPath: string, path: RouteInfo['path'], pageModule: string, - extraData: RouteExtraInfo, + parentLayouts: RouteInfo['parentLayouts'], pathApi: PathApi, -): RouteInfo => ({ +): RouteInfo => ({ path, pageModule, pageExportName: 'ErrorBoundary', - parentLayouts: extraData.parentLayouts, + parentLayouts, pathString: filePathToReadableUri(pageModule.slice(routeDirPath.length + 1), pathApi) || '', - extraData, - hasGetStaticRoutes: extraData.exportNames.includes('getStaticRoutes'), + extraData: undefined, + hasGetStaticRoutes: false, }); export function filePathToURLParts(filePathInRouteDir: string, path: PathApi): string[] { const dirStructure = filePathInRouteDir.split(path.sep); diff --git a/packages/define-remix-app/test/define-remix.spec.ts b/packages/define-remix-app/test/define-remix.spec.ts index 79b2365c..1b02ffa8 100644 --- a/packages/define-remix-app/test/define-remix.spec.ts +++ b/packages/define-remix-app/test/define-remix.spec.ts @@ -20,11 +20,13 @@ import { clientLoaderWithFallbackPage, clientActionPage, pageWithLinks, + userApiConsumer, + userApiPage, loaderAndClientLoaderRoot, } from './test-cases/roots'; import chai, { expect } from 'chai'; import { IAppManifest, RouteInfo, RoutingPattern } from '@wixc3/app-core'; -import { ParentLayoutWithExtra, RouteExtraInfo } from '../src/remix-app-utils'; +import { ParentLayoutWithExtra, RouteExtraInfo, RouteModuleInfo } from '../src/remix-app-utils'; import { waitFor } from 'promise-assist'; import { IDirectoryContents } from '@file-services/types'; import * as React from 'react'; @@ -52,6 +54,24 @@ const root: ParentLayoutWithExtra = { path: '/', exportNames: ['Layout', 'default'], }; +const rootModuleInfo = ( + children: RouteModuleInfo[] = [], + overrides: Partial = {}, +): RouteModuleInfo => ({ + children, + exportNames: ['Layout', 'default'], + file: rootPath, + id: 'root', + path: '/', + ...overrides, +}); +const moduleInfo = ({ id, path, file, exportNames, children }: Partial): RouteModuleInfo => ({ + children: children || [], + exportNames: exportNames || ['default'], + file: file!, + id: id!, + path: path!, +}); describe('define-remix', () => { describe('flat routes', () => { @@ -60,7 +80,8 @@ describe('define-remix', () => { [indexPath]: simpleLayout, }); expectManifest(manifest, { - homeRoute: aRoute({ routeId: 'routes/_index', pageModule: indexPath, readableUri: '', path: [] }), + extraData: rootModuleInfo([moduleInfo({ id: 'routes/_index', path: '/', file: indexPath })]), + homeRoute: aRoute({ pageModule: indexPath, readableUri: '', path: [] }), }); }); it('manifest for: about.tsx', async () => { @@ -69,9 +90,9 @@ describe('define-remix', () => { [testedPath]: simpleLayout, }); expectManifest(manifest, { + extraData: rootModuleInfo([moduleInfo({ id: 'routes/about', path: '/about', file: testedPath })]), routes: [ aRoute({ - routeId: 'routes/about', pageModule: testedPath, readableUri: 'about', path: [urlSeg('about')], @@ -85,6 +106,9 @@ describe('define-remix', () => { [testedPath]: loaderOnly, }); expectManifest(manifest, { + extraData: rootModuleInfo([ + moduleInfo({ id: 'routes/about', path: '/about', file: testedPath, exportNames: ['loader'] }), + ]), routes: [], }); }); @@ -94,9 +118,11 @@ describe('define-remix', () => { [testedPath]: simpleLayout, }); expectManifest(manifest, { + extraData: rootModuleInfo([ + moduleInfo({ id: 'routes/about/_index', path: '/about', file: testedPath }), + ]), routes: [ aRoute({ - routeId: 'routes/about/_index', pageModule: testedPath, readableUri: 'about/_index', path: [urlSeg('about')], @@ -114,9 +140,16 @@ describe('define-remix', () => { }); expectManifest(manifest, { + extraData: rootModuleInfo([ + moduleInfo({ + id: 'routes/about', + path: '/about', + file: layoutPath, + children: [moduleInfo({ id: 'routes/about/_index', path: '/about', file: testedPath })], + }), + ]), routes: [ aRoute({ - routeId: 'routes/about/_index', pageModule: testedPath, readableUri: 'about/_index', path: [urlSeg('about')], @@ -143,15 +176,22 @@ describe('define-remix', () => { [aboutUsPath]: simpleLayout, }); expectManifest(manifest, { + extraData: rootModuleInfo([ + moduleInfo({ + id: 'routes/about', + path: '/about', + file: aboutPath, + children: [moduleInfo({ id: 'routes/about/us', path: '/about/us', file: aboutUsPath })], + }), + moduleInfo({ id: 'routes/middle', path: '/middle', file: middlePath }), + ]), routes: [ aRoute({ - routeId: 'routes/about', pageModule: aboutPath, readableUri: 'about', path: [urlSeg('about')], }), aRoute({ - routeId: 'routes/about/us', pageModule: aboutUsPath, readableUri: 'about/us', path: [urlSeg('about'), urlSeg('us')], @@ -166,7 +206,6 @@ describe('define-remix', () => { ], }), aRoute({ - routeId: 'routes/middle', pageModule: middlePath, readableUri: 'middle', path: [urlSeg('middle')], @@ -185,15 +224,21 @@ describe('define-remix', () => { }); expectManifest(manifest, { + extraData: rootModuleInfo([ + moduleInfo({ + id: 'routes/about', + path: '/about', + file: aboutPage, + children: [moduleInfo({ id: 'routes/about/us', path: '/about/us', file: aboutUsPage })], + }), + ]), routes: [ aRoute({ - routeId: 'routes/about', pageModule: aboutPage, readableUri: 'about', path: [urlSeg('about')], }), aRoute({ - routeId: 'routes/about/us', pageModule: aboutUsPage, readableUri: 'about/us', path: [urlSeg('about'), urlSeg('us')], @@ -219,15 +264,25 @@ describe('define-remix', () => { }); expectManifest(manifest, { + extraData: rootModuleInfo([ + moduleInfo({ + id: 'routes/about', + path: '/about', + file: aboutPage, + }), + moduleInfo({ + id: 'routes/about_/us', + path: '/about/us', + file: aboutUsPage, + }), + ]), routes: [ aRoute({ - routeId: 'routes/about', pageModule: aboutPage, readableUri: 'about', path: [urlSeg('about')], }), aRoute({ - routeId: 'routes/about_/us', pageModule: aboutUsPage, readableUri: 'about_/us', path: [urlSeg('about'), urlSeg('us')], @@ -248,15 +303,33 @@ describe('define-remix', () => { }); expectManifest(manifest, { + extraData: rootModuleInfo([ + moduleInfo({ + id: 'routes/about', + path: '/about', + file: aboutPage, + children: [ + moduleInfo({ + id: 'routes/about/us', + path: '/about/us', + file: aboutUsPage, + }), + moduleInfo({ + id: 'routes/about/us_/lang', + path: '/about/us/lang', + file: aboutUsLangPage, + }), + ], + }), + ]), + routes: [ aRoute({ - routeId: 'routes/about', pageModule: aboutPage, readableUri: 'about', path: [urlSeg('about')], }), aRoute({ - routeId: 'routes/about/us', pageModule: aboutUsPage, readableUri: 'about/us', path: [urlSeg('about'), urlSeg('us')], @@ -271,7 +344,6 @@ describe('define-remix', () => { ], }), aRoute({ - routeId: 'routes/about/us_/lang', pageModule: aboutUsLangPage, readableUri: 'about/us_/lang', path: [urlSeg('about'), urlSeg('us'), urlSeg('lang')], @@ -296,9 +368,15 @@ describe('define-remix', () => { }); expectManifest(manifest, { + extraData: rootModuleInfo([ + moduleInfo({ + id: 'routes/product/$productId', + path: '/product/:productId', + file: productPage, + }), + ]), routes: [ aRoute({ - routeId: 'routes/product/$productId', pageModule: productPage, readableUri: 'product/$productId', path: [urlSeg('product'), { kind: 'dynamic', name: 'productId' }], @@ -314,9 +392,15 @@ describe('define-remix', () => { }); expectManifest(manifest, { + extraData: rootModuleInfo([ + moduleInfo({ + id: 'routes/product/($productId)', + path: '/product/:productId', + file: productPage, + }), + ]), routes: [ aRoute({ - routeId: 'routes/product/($productId)', pageModule: productPage, readableUri: 'product/($productId)', path: [urlSeg('product'), { kind: 'dynamic', name: 'productId', isOptional: true }], @@ -332,9 +416,15 @@ describe('define-remix', () => { }); expectManifest(manifest, { + extraData: rootModuleInfo([ + moduleInfo({ + id: 'routes/product/$', + path: '/product/:$', + file: productPage, + }), + ]), routes: [ aRoute({ - routeId: 'routes/product/$', pageModule: productPage, readableUri: 'product/$', path: [urlSeg('product'), { kind: 'dynamic', name: '$', isCatchAll: true }], @@ -349,9 +439,15 @@ describe('define-remix', () => { }); expectManifest(manifest, { + extraData: rootModuleInfo([ + moduleInfo({ + id: 'routes/_layout/about', + path: '/about', + file: aboutPage, + }), + ]), routes: [ aRoute({ - routeId: 'routes/_layout/about', pageModule: aboutPage, readableUri: '_layout/about', path: [urlSeg('about')], @@ -368,9 +464,22 @@ describe('define-remix', () => { }); expectManifest(manifest, { + extraData: rootModuleInfo([ + moduleInfo({ + id: 'routes/_layout', + path: '/', + file: layout, + children: [ + moduleInfo({ + id: 'routes/_layout/about', + path: '/about', + file: aboutPage, + }), + ], + }), + ]), routes: [ aRoute({ - routeId: 'routes/_layout/about', pageModule: aboutPage, readableUri: '_layout/about', path: [urlSeg('about')], @@ -396,9 +505,9 @@ describe('define-remix', () => { [testedPath]: simpleLayout, }); expectManifest(manifest, { + extraData: rootModuleInfo([moduleInfo({ id: 'routes/about/index', path: '/about', file: testedPath })]), routes: [ aRoute({ - routeId: 'routes/about/index', pageModule: testedPath, readableUri: 'about', path: [urlSeg('about')], @@ -412,9 +521,9 @@ describe('define-remix', () => { [testedPath]: simpleLayout, }); expectManifest(manifest, { + extraData: rootModuleInfo([moduleInfo({ id: 'routes/about/route', path: '/about', file: testedPath })]), routes: [ aRoute({ - routeId: 'routes/about/route', pageModule: testedPath, readableUri: 'about', path: [urlSeg('about')], @@ -428,9 +537,11 @@ describe('define-remix', () => { [testedPath]: simpleLayout, }); expectManifest(manifest, { + extraData: rootModuleInfo([ + moduleInfo({ id: 'routes/about/_index/route', path: '/about', file: testedPath }), + ]), routes: [ aRoute({ - routeId: 'routes/about/_index/route', pageModule: testedPath, readableUri: 'about/_index', path: [urlSeg('about')], @@ -444,9 +555,11 @@ describe('define-remix', () => { [testedPath]: simpleLayout, }); expectManifest(manifest, { + extraData: rootModuleInfo([ + moduleInfo({ id: 'routes/about/_index/route', path: '/about', file: testedPath }), + ]), routes: [ aRoute({ - routeId: 'routes/about/_index/route', pageModule: testedPath, readableUri: 'about/_index', path: [urlSeg('about')], @@ -462,9 +575,22 @@ describe('define-remix', () => { [layoutPath]: simpleLayout, }); expectManifest(manifest, { + extraData: rootModuleInfo([ + moduleInfo({ + id: 'routes/about/route', + path: '/about', + file: layoutPath, + children: [ + moduleInfo({ + id: 'routes/about/_index/route', + path: '/about', + file: testedPath, + }), + ], + }), + ]), routes: [ aRoute({ - routeId: 'routes/about/_index/route', pageModule: testedPath, readableUri: 'about/_index', path: [urlSeg('about')], @@ -491,15 +617,32 @@ describe('define-remix', () => { [aboutUsPath]: simpleLayout, }); expectManifest(manifest, { + extraData: rootModuleInfo([ + moduleInfo({ + id: 'routes/about/route', + path: '/about', + file: aboutPath, + children: [ + moduleInfo({ + id: 'routes/about/us/route', + path: '/about/us', + file: aboutUsPath, + }), + ], + }), + moduleInfo({ + id: 'routes/middle/route', + path: '/middle', + file: middlePath, + }), + ]), routes: [ aRoute({ - routeId: 'routes/about/route', pageModule: aboutPath, readableUri: 'about', path: [urlSeg('about')], }), aRoute({ - routeId: 'routes/about/us/route', pageModule: aboutUsPath, readableUri: 'about/us', path: [urlSeg('about'), urlSeg('us')], @@ -514,7 +657,6 @@ describe('define-remix', () => { ], }), aRoute({ - routeId: 'routes/middle/route', pageModule: middlePath, readableUri: 'middle', path: [urlSeg('middle')], @@ -538,8 +680,10 @@ describe('define-remix', () => { [indexPath]: simpleLayout, }); expectManifest(manifest, { + extraData: rootModuleInfo([moduleInfo({ id: 'routes/_index', path: '/', file: indexPath })], { + exportNames: ['Layout', 'ErrorBoundary', 'default'], + }), homeRoute: anyRoute({ - routeId: 'routes/_index', pageModule: indexPath, readableUri: '', path: [], @@ -547,11 +691,9 @@ describe('define-remix', () => { }), errorRoutes: [ anErrorRoute({ - routeId: 'error', pageModule: rootPath, readableUri: '', path: [], - exportNames: ['Layout', 'ErrorBoundary', 'default'], }), ], }); @@ -562,21 +704,25 @@ describe('define-remix', () => { [indexPath]: layoutWithErrorBoundary, }); expectManifest(manifest, { + extraData: rootModuleInfo([ + moduleInfo({ + id: 'routes/_index', + path: '/', + file: indexPath, + exportNames: ['ErrorBoundary', 'default'], + }), + ]), homeRoute: aRoute({ - routeId: 'routes/_index', pageModule: indexPath, readableUri: '', path: [], - exportNames: ['ErrorBoundary', 'default'], }), errorRoutes: [ anErrorRoute({ - routeId: 'routes/_index', pageModule: indexPath, readableUri: '', path: [], parentLayouts: [rootLayout, root], - exportNames: ['ErrorBoundary', 'default'], }), ], }); @@ -589,23 +735,27 @@ describe('define-remix', () => { [aboutPage]: layoutWithErrorBoundary, }); expectManifest(manifest, { + extraData: rootModuleInfo([ + moduleInfo({ + id: 'routes/about', + path: '/about', + file: aboutPage, + exportNames: ['ErrorBoundary', 'default'], + }), + ]), routes: [ aRoute({ - routeId: 'routes/about', pageModule: aboutPage, readableUri: 'about', path: [urlSeg('about')], - exportNames: ['ErrorBoundary', 'default'], }), ], errorRoutes: [ anErrorRoute({ - routeId: 'routes/about', pageModule: aboutPage, readableUri: 'about', path: [urlSeg('about')], parentLayouts: [rootLayout, root], - exportNames: ['ErrorBoundary', 'default'], }), ], }); @@ -621,10 +771,13 @@ describe('define-remix', () => { driver.addOrUpdateFile(testedPath, simpleLayout); await waitFor(() => expectManifest(driver.getManifest()!, { - homeRoute: aRoute({ routeId: 'routes/_index', pageModule: indexPath, readableUri: '', path: [] }), + extraData: rootModuleInfo([ + moduleInfo({ id: 'routes/_index', path: '/', file: indexPath }), + moduleInfo({ id: 'routes/about', path: '/about', file: testedPath }), + ]), + homeRoute: aRoute({ pageModule: indexPath, readableUri: '', path: [] }), routes: [ aRoute({ - routeId: 'routes/about', pageModule: testedPath, readableUri: 'about', path: [urlSeg('about')], @@ -642,9 +795,9 @@ describe('define-remix', () => { driver.addOrUpdateFile(testedPath, simpleLayout); await waitFor(() => expectManifest(driver.getManifest()!, { + extraData: rootModuleInfo([moduleInfo({ id: 'routes/about', path: '/about', file: testedPath })]), routes: [ aRoute({ - routeId: 'routes/about', pageModule: testedPath, readableUri: 'about', path: [urlSeg('about')], @@ -664,9 +817,11 @@ describe('define-remix', () => { exportNames: ['default'], }; expectManifest(manifest, { + extraData: rootModuleInfo([moduleInfo({ id: 'routes/about', path: '/about', file: testedPath })], { + exportNames: ['default'], + }), routes: [ anyRoute({ - routeId: 'routes/about', pageModule: testedPath, readableUri: 'about', path: [urlSeg('about')], @@ -678,9 +833,11 @@ describe('define-remix', () => { driver.addOrUpdateFile(rootPath, rootWithLayout); await waitFor(() => expectManifest(driver.getManifest()!, { + extraData: rootModuleInfo([moduleInfo({ id: 'routes/about', path: '/about', file: testedPath })], { + exportNames: ['Layout', 'default'], + }), routes: [ aRoute({ - routeId: 'routes/about', pageModule: testedPath, readableUri: 'about', path: [urlSeg('about')], @@ -702,11 +859,9 @@ describe('define-remix', () => { expect(pageModule).to.eql('/app/routes/about.tsx'); expect(newPageRoute).to.eql( aRoute({ - routeId: 'routes/about', pageModule: '/app/routes/about.tsx', readableUri: 'about', path: [urlSeg('about')], - exportNames: ['meta', 'default'], }), ); expect(newPageSourceCode).to.include('export default'); @@ -724,11 +879,9 @@ describe('define-remix', () => { expect(pageModule).to.eql('/app/routes/about/route.tsx'); expect(newPageRoute).to.eql( aRoute({ - routeId: 'routes/about/route', pageModule: '/app/routes/about/route.tsx', readableUri: 'about', path: [urlSeg('about')], - exportNames: ['meta', 'default'], }), ); expect(routingPattern).to.eql('folder(route)'); @@ -745,11 +898,9 @@ describe('define-remix', () => { expect(pageModule).to.eql('/app/routes/about/index.tsx'); expect(newPageRoute).to.eql( aRoute({ - routeId: 'routes/about/index', pageModule: '/app/routes/about/index.tsx', readableUri: 'about', path: [urlSeg('about')], - exportNames: ['meta', 'default'], }), ); expect(routingPattern).to.eql('folder(index)'); @@ -766,7 +917,6 @@ describe('define-remix', () => { expect(pageModule).to.eql(aboutUsPage); expect(newPageRoute).to.eql( aRoute({ - routeId: 'routes/about/us', pageModule: aboutUsPage, readableUri: 'about/us', path: [urlSeg('about'), urlSeg('us')], @@ -779,7 +929,6 @@ describe('define-remix', () => { exportNames: ['default'], }, ], - exportNames: ['meta', 'default'], }), ); expect(warningMessage).to.include(parentLayoutWarning('about', 'about_/us')); @@ -796,7 +945,6 @@ describe('define-remix', () => { expect(pageModule).to.eql(aboutUsPage); expect(newPageRoute).to.eql( aRoute({ - routeId: 'routes/about/_index', pageModule: aboutUsPage, readableUri: 'about/_index', path: [urlSeg('about')], @@ -809,7 +957,6 @@ describe('define-remix', () => { exportNames: ['default'], }, ], - exportNames: ['meta', 'default'], }), ); expect(warningMessage).to.include(parentLayoutWarning('about', 'about_/_index')); @@ -936,11 +1083,9 @@ describe('define-remix', () => { expect(pageModule).to.eql('/app/routes/about2.tsx'); expect(newPageRoute).to.eql( aRoute({ - routeId: 'routes/about2', pageModule: '/app/routes/about2.tsx', readableUri: 'about2', path: [urlSeg('about2')], - exportNames: ['meta', 'default'], }), ); expect(routingPattern).to.eql('file'); @@ -1226,6 +1371,40 @@ describe('define-remix', () => { dispose(); }); }); + describe('fetchers', () => { + const fetcherSuite = (fetcherType: 'page' | 'api') => { + it('should load data using fetcher for ' + fetcherType, async () => { + const { driver } = await getInitialManifest({ + [rootPath]: rootWithLayout2, + '/app/routes/api.users.tsx': fetcherType === 'page' ? actionPage('Users') : userApiPage, + '/app/routes/contact.$nickname.tsx': userApiConsumer('/api/users'), + }); + + const { dispose, container } = await driver.render({ uri: 'contact/yossi' }); + + await expect(() => container.textContent) + .retry() + .to.include('Layout|App|UserPage|User does not exist'); + + const nameField = container.querySelector('input[name=fullName]') as HTMLInputElement; + const emailField = container.querySelector('input[name=email]') as HTMLInputElement; + const submitButton = container.querySelector('button[type=submit]') as HTMLButtonElement; + nameField.value = 'John Doe'; + emailField.value = 'jhon@doe.com'; + submitButton.click(); + await expect(() => container.textContent) + .retry() + .to.include('Layout|App|UserPage|User exist'); + await expect(() => container.textContent) + .retry() + .to.include('User created'); + dispose(); + }); + }; + fetcherSuite('page'); + fetcherSuite('api'); + }); + describe('handle', () => { it('exported handled should be available using useMatches', async () => { const aboutUsPath = '/app/routes/about.us.tsx'; @@ -1248,7 +1427,7 @@ describe('define-remix', () => { }); }); - describe('links function', ()=>{ + describe('links function', () => { it('should render links returned by the links function', async () => { const { driver } = await getInitialManifest({ [rootPath]: rootWithLayout2, @@ -1261,10 +1440,9 @@ describe('define-remix', () => { .retry() .to.include('Layout|App|Home'); - const link = container.querySelector('link') - expect(link?.getAttribute('href')) - .to.include('some.css'); - + const link = container.querySelector('link'); + expect(link?.getAttribute('href')).to.include('some.css'); + dispose(); }); it('should support having the links function added and removed', async () => { @@ -1279,24 +1457,21 @@ describe('define-remix', () => { .retry() .to.include('Layout|App|Home'); - - const link = container.querySelector('link') + const link = container.querySelector('link'); expect(link).to.equal(null); driver.addOrUpdateFile(indexPath, pageWithLinks('HomeUpdated')); - await expect(() => container.textContent) .retry() .to.include('Layout|App|HomeUpdated'); - const updatedLink = container.querySelector('link') - expect(updatedLink?.getAttribute('href')) - .to.include('some.css'); - + const updatedLink = container.querySelector('link'); + expect(updatedLink?.getAttribute('href')).to.include('some.css'); + dispose(); }); - }) + }); }); }); @@ -1322,7 +1497,7 @@ const createAppAndDriver = async ( appPath, routingPattern, }); - const driver = new AppDefDriver({ + const driver = new AppDefDriver({ app, initialFiles, evaluatedNodeModules: { @@ -1336,7 +1511,10 @@ const createAppAndDriver = async ( return { app, driver, manifest }; }; -const expectManifest = (manifest: IAppManifest, expected: Partial>) => { +const expectManifest = ( + manifest: IAppManifest, + expected: Partial>, +) => { const fullExpected = { errorRoutes: [], routes: [], @@ -1346,92 +1524,72 @@ const expectManifest = (manifest: IAppManifest, expected: Partia }; const anyRoute = ({ - routeId, pageModule, pageExportName = 'default', readableUri: pathString = '', path = [], parentLayouts = [], - exportNames = ['default'], hasGetStaticRoutes = false, }: { - routeId: string; pageModule: string; pageExportName?: string; readableUri?: string; path?: RouteInfo['path']; parentLayouts?: RouteExtraInfo['parentLayouts']; - exportNames?: string[]; hasGetStaticRoutes?: boolean; -}): RouteInfo => { +}): RouteInfo => { return { path, pathString, pageModule, hasGetStaticRoutes, pageExportName, - extraData: { - parentLayouts, - routeId, - exportNames, - }, + extraData: undefined, parentLayouts, }; }; const aRoute = ({ - routeId, pageModule, readableUri: pathString = '', path = [], parentLayouts = [], includeRootLayout = true, - exportNames, hasGetStaticRoutes = false, }: { - routeId: string; pageModule: string; readableUri?: string; path?: RouteInfo['path']; parentLayouts?: RouteExtraInfo['parentLayouts']; includeRootLayout?: boolean; - exportNames?: string[]; hasGetStaticRoutes?: boolean; -}): RouteInfo => { +}): RouteInfo => { const expectedParentLayouts = includeRootLayout ? [rootLayout, root, ...parentLayouts] : [root, ...parentLayouts]; return anyRoute({ - routeId, pageModule, readableUri: pathString, path, parentLayouts: expectedParentLayouts, - exportNames, hasGetStaticRoutes, }); }; const anErrorRoute = ({ - routeId, pageModule, readableUri: pathString = '', path = [], parentLayouts = [], - exportNames, }: { - routeId: string; pageModule: string; readableUri?: string; path?: RouteInfo['path']; parentLayouts?: RouteExtraInfo['parentLayouts']; - exportNames?: string[]; }) => { return anyRoute({ - routeId, pageModule, readableUri: pathString, path, parentLayouts, pageExportName: 'ErrorBoundary', - exportNames, }); }; diff --git a/packages/define-remix-app/test/test-cases/roots.ts b/packages/define-remix-app/test/test-cases/roots.ts index a8a8ee10..1792c560 100644 --- a/packages/define-remix-app/test/test-cases/roots.ts +++ b/packages/define-remix-app/test/test-cases/roots.ts @@ -78,7 +78,8 @@ export const simpleLayout = transformTsx(` } `); -export const pageWithLinks =(name: string)=> transformTsx(` +export const pageWithLinks = (name: string) => + transformTsx(` import React from 'react'; import { Outlet, @@ -197,7 +198,7 @@ export const rootWithBreadCrumbs = transformTsx( Breadcrumbs:
{matches.map((match) => ( -
{match?.handle?.name}!
+ match?.handle?.name ?
{match.handle.name}!
: null ))}
@@ -427,14 +428,7 @@ export default function ${name}() { ); } `); - -export const actionPage = (name: string) => - transformTsx(` - import React from 'react'; - import { Outlet, Form, useLoaderData, useActionData, json } from '@remix-run/react'; - - - const userNames = new Map(); @@ -467,7 +461,22 @@ export const actionPage = (name: string) => exists: false, }; - }; + };`; +export const userApiPage = transformTsx(` + import React from 'react'; + import { Outlet, Form, json ,} from '@remix-run/react'; + + + ${userApi} + +`); + +export const actionPage = (name: string) => + transformTsx(` + import React from 'react'; + import { Outlet, Form, useLoaderData, useActionData, json ,} from '@remix-run/react'; + + ${userApi} export default function ${name}() { const data = useLoaderData(); const actionData = useActionData(); @@ -485,7 +494,35 @@ export const actionPage = (name: string) => ; } `); +export const userApiConsumer = (apiRoute: string) => + transformTsx(` + import React, { useEffect } from 'react'; + import { Outlet, Form, useFetcher, } from '@remix-run/react'; + + export default function UserPage() { + + const fetcher = useFetcher(); + useEffect(()=>{ + fetcher.load("${apiRoute}"); + }) + const data = fetcher.data || {}; + + const actionFetcher = useFetcher(); + const actionData = actionFetcher.data || {}; + return
+ UserPage| + +

{data.exists ? 'User exists' : 'User does not exist'}

+ + + + + +

{actionData?.message}

+
; + } +`); export const clientActionPage = (name: string) => transformTsx(` @@ -557,4 +594,4 @@ export const clientActionPage = (name: string) => ; } -`); \ No newline at end of file +`);