From 93f06235107bd79bd01e48768503f04ea8680942 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 29 Nov 2023 13:05:36 -0500 Subject: [PATCH] Add clientAction support --- integration/client-data-test.ts | 381 +++++++++++++++++- .../remix-dev/compiler/js/plugins/routes.ts | 1 + packages/remix-dev/compiler/manifest.ts | 1 + packages/remix-dev/manifest.ts | 1 + packages/remix-dev/vite/plugin.ts | 2 + packages/remix-react/index.tsx | 2 + packages/remix-react/routeModules.ts | 17 + packages/remix-react/routes.tsx | 40 +- packages/remix-server-runtime/routeModules.ts | 17 + packages/remix-testing/create-remix-stub.tsx | 1 + 10 files changed, 448 insertions(+), 15 deletions(-) diff --git a/integration/client-data-test.ts b/integration/client-data-test.ts index 0c5f9e86005..1a86755a064 100644 --- a/integration/client-data-test.ts +++ b/integration/client-data-test.ts @@ -89,10 +89,13 @@ function getFiles({ `, "app/routes/parent.child.tsx": js` import { json } from '@remix-run/node' - import { Outlet, useLoaderData } from '@remix-run/react' + import { Form, Outlet, useActionData, useLoaderData } from '@remix-run/react' export function loader() { return json({ message: 'Child Server Loader'}); } + export function action() { + return json({ message: 'Child Server Action'}); + } ${ childClientLoader ? js` @@ -119,10 +122,14 @@ function getFiles({ ${childAdditions || ""} export default function Component() { let data = useLoaderData(); + let actionData = useActionData(); return ( <>

{data.message}

- +
+ + {actionData ?

{actionData.message}

: null} +
); } @@ -137,7 +144,7 @@ test.describe("Client Data", () => { appFixture.close(); }); - test.describe("Initial Hydration", () => { + test.describe("clientLoader - critical route module", () => { test("no client loaders or fallbacks", async ({ page }) => { appFixture = await createAppFixture( await createFixture({ @@ -191,8 +198,6 @@ test.describe("Client Data", () => { ); let app = new PlaywrightFixture(appFixture, page); - // Renders parent fallback on initial render and calls parent clientLoader - // Does not call child clientLoader await app.goto("/parent/child"); let html = await app.getHtml("main"); expect(html).toMatch("Parent Fallback"); @@ -219,8 +224,6 @@ test.describe("Client Data", () => { ); let app = new PlaywrightFixture(appFixture, page); - // Renders child fallback on initial render and calls child clientLoader - // Does not call parent clientLoader due to lack of HydrateFallback await app.goto("/parent/child"); let html = await app.getHtml("main"); expect(html).toMatch("Parent Server Loader"); @@ -249,7 +252,6 @@ test.describe("Client Data", () => { ); let app = new PlaywrightFixture(appFixture, page); - // Renders parent fallback on initial render and calls both clientLoader's await app.goto("/parent/child"); let html = await app.getHtml("main"); expect(html).toMatch("Parent Fallback"); @@ -297,8 +299,6 @@ test.describe("Client Data", () => { appFixture = await createAppFixture(fixture); let app = new PlaywrightFixture(appFixture, page); - - // Renders parent fallback on initial render and calls both clientLoader's await app.goto("/parent/child"); await page.waitForSelector("#child-data"); html = await app.getHtml("main"); @@ -377,7 +377,7 @@ test.describe("Client Data", () => { }); }); - test.describe("SPA Navigations", () => { + test.describe("clientLoader - lazy route module", () => { test("no client loaders or fallbacks", async ({ page }) => { appFixture = await createAppFixture( await createFixture({ @@ -416,12 +416,32 @@ test.describe("Client Data", () => { await app.clickLink("/parent/child"); await page.waitForSelector("#child-data"); - // Parent client loader should run let html = await app.getHtml("main"); expect(html).toMatch("Parent Server Loader (mutated by client)"); expect(html).toMatch("Child Server Loader"); }); + test("child.clientLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture({ + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + }); + test("parent.clientLoader/child.clientLoader", async ({ page }) => { appFixture = await createAppFixture( await createFixture({ @@ -438,10 +458,345 @@ test.describe("Client Data", () => { await app.clickLink("/parent/child"); await page.waitForSelector("#child-data"); - // Both clientLoaders should run let html = await app.getHtml("main"); expect(html).toMatch("Parent Server Loader (mutated by client)"); expect(html).toMatch("Child Server Loader (mutated by client"); }); }); + + test.describe("clientAction - critical route module", () => { + test("child.clientAction", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture({ + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/parent.childLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Parent Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/child.clientLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture({ + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Child Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/parent.childLoader/child.clientLoader", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); // still revalidating + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Child Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + }); + + test.describe("clientAction - lazy route module", () => { + test("child.clientAction", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture({ + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/parent.childLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Parent Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/child.clientLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture({ + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Child Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/parent.childLoader/child.clientLoader", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); // still revalidating + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Child Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + }); }); diff --git a/packages/remix-dev/compiler/js/plugins/routes.ts b/packages/remix-dev/compiler/js/plugins/routes.ts index 91efd7b8695..d6b2cad3aa8 100644 --- a/packages/remix-dev/compiler/js/plugins/routes.ts +++ b/packages/remix-dev/compiler/js/plugins/routes.ts @@ -11,6 +11,7 @@ type Route = RemixConfig["routes"][string]; // If you change this, make sure you update loadRouteModuleWithBlockingLinks in // remix-react/routes.ts const browserSafeRouteExports: { [name: string]: boolean } = { + clientAction: true, clientLoader: true, ErrorBoundary: true, HydrateFallback: true, diff --git a/packages/remix-dev/compiler/manifest.ts b/packages/remix-dev/compiler/manifest.ts index ed44fa4c3ff..b75da0a1b89 100644 --- a/packages/remix-dev/compiler/manifest.ts +++ b/packages/remix-dev/compiler/manifest.ts @@ -98,6 +98,7 @@ export async function create({ imports: resolveImports(output.imports), hasAction: sourceExports.includes("action"), hasLoader: sourceExports.includes("loader"), + hasClientAction: sourceExports.includes("clientAction"), hasClientLoader: sourceExports.includes("clientLoader"), hasErrorBoundary: sourceExports.includes("ErrorBoundary"), }; diff --git a/packages/remix-dev/manifest.ts b/packages/remix-dev/manifest.ts index 7fef0cfee46..e9b628aa63b 100644 --- a/packages/remix-dev/manifest.ts +++ b/packages/remix-dev/manifest.ts @@ -16,6 +16,7 @@ export type Manifest = { imports?: string[]; hasAction: boolean; hasLoader: boolean; + hasClientAction: boolean; hasClientLoader: boolean; hasErrorBoundary: boolean; }; diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts index bd1b5c1295e..76b58c9041e 100644 --- a/packages/remix-dev/vite/plugin.ts +++ b/packages/remix-dev/vite/plugin.ts @@ -381,6 +381,7 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { caseSensitive: route.caseSensitive, hasAction: sourceExports.includes("action"), hasLoader: sourceExports.includes("loader"), + hasClientAction: sourceExports.includes("clientAction"), hasClientLoader: sourceExports.includes("clientLoader"), hasErrorBoundary: sourceExports.includes("ErrorBoundary"), ...resolveBuildAssetPaths(pluginConfig, viteManifest, routeFilePath), @@ -431,6 +432,7 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { }`, hasAction: sourceExports.includes("action"), hasLoader: sourceExports.includes("loader"), + hasClientAction: sourceExports.includes("clientAction"), hasClientLoader: sourceExports.includes("clientLoader"), hasErrorBoundary: sourceExports.includes("ErrorBoundary"), imports: [], diff --git a/packages/remix-react/index.tsx b/packages/remix-react/index.tsx index 66b912210b9..50571ac7591 100644 --- a/packages/remix-react/index.tsx +++ b/packages/remix-react/index.tsx @@ -78,6 +78,8 @@ export { export type { HtmlLinkDescriptor } from "./links"; export type { + ClientActionFunction, + ClientActionFunctionArgs, ClientLoaderFunction, ClientLoaderFunctionArgs, MetaArgs, diff --git a/packages/remix-react/routeModules.ts b/packages/remix-react/routeModules.ts index 17292608479..0e9f2994475 100644 --- a/packages/remix-react/routeModules.ts +++ b/packages/remix-react/routeModules.ts @@ -1,5 +1,7 @@ import type { ComponentType } from "react"; import type { + ActionFunction as RRActionFunction, + ActionFunctionArgs as RRActionFunctionArgs, LoaderFunction as RRLoaderFunction, LoaderFunctionArgs as RRLoaderFunctionArgs, DataRouteMatch, @@ -18,6 +20,7 @@ export interface RouteModules { } export interface RouteModule { + clientAction?: ClientActionFunction; clientLoader?: ClientLoaderFunction; ErrorBoundary?: ErrorBoundaryComponent; HydrateFallback?: HydrateFallbackComponent; @@ -28,6 +31,20 @@ export interface RouteModule { shouldRevalidate?: ShouldRevalidateFunction; } +/** + * A function that handles data mutations for a route on the client + */ +export type ClientActionFunction = ( + args: ClientActionFunctionArgs +) => ReturnType; + +/** + * Arguments passed to a route `clientAction` function + */ +export type ClientActionFunctionArgs = RRActionFunctionArgs & { + serverAction: () => Promise>; +}; + /** * A function that loads data for a route on the client */ diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx index 91e3d99e009..012a40249a2 100644 --- a/packages/remix-react/routes.tsx +++ b/packages/remix-react/routes.tsx @@ -42,6 +42,7 @@ interface Route { export interface EntryRoute extends Route { hasAction: boolean; hasLoader: boolean; + hasClientAction: boolean; hasClientLoader: boolean; hasErrorBoundary: boolean; imports?: string[]; @@ -189,8 +190,6 @@ export function createClientRoutes( id: route.id, index: route.index, path: route.path, - action: ({ request }: ActionFunctionArgs) => - prefetchStylesAndCallHandler(() => fetchServerAction(request)), }; if (routeModule) { @@ -262,6 +261,24 @@ export function createClientRoutes( }); }); }; + + dataRoute.action = ({ request, params }: ActionFunctionArgs) => { + return prefetchStylesAndCallHandler(async () => { + if (!routeModule.clientAction) { + return fetchServerAction(request); + } + + return routeModule.clientAction({ + request, + params, + async serverAction() { + let result = await fetchServerAction(request); + let unwrapped = await unwrapServerResponse(result); + return unwrapped; + }, + }); + }); + }; } else { // If the lazy route does not have a client loader/action we want to call // the server loader/action in parallel with the module load so we add @@ -270,6 +287,10 @@ export function createClientRoutes( dataRoute.loader = ({ request }: LoaderFunctionArgs) => prefetchStylesAndCallHandler(() => fetchServerLoader(request)); } + if (!route.hasClientAction) { + dataRoute.action = ({ request }: ActionFunctionArgs) => + prefetchStylesAndCallHandler(() => fetchServerAction(request)); + } // Load all other modules via route.lazy() dataRoute.lazy = async () => { @@ -292,6 +313,19 @@ export function createClientRoutes( }); } + if (mod.clientAction) { + let clientAction = mod.clientAction; + lazyRoute.action = (args) => + clientAction({ + ...args, + async serverAction() { + let response = await fetchServerAction(args.request); + let result = await unwrapServerResponse(response); + return result; + }, + }); + } + if (needsRevalidation) { lazyRoute.shouldRevalidate = wrapShouldRevalidateForHdr( route.id, @@ -302,6 +336,7 @@ export function createClientRoutes( return { ...(lazyRoute.loader ? { loader: lazyRoute.loader } : {}), + ...(lazyRoute.action ? { action: lazyRoute.action } : {}), hasErrorBoundary: lazyRoute.hasErrorBoundary, shouldRevalidate: lazyRoute.shouldRevalidate, handle: lazyRoute.handle, @@ -357,6 +392,7 @@ async function loadRouteModuleWithBlockingLinks( return { Component: getRouteModuleComponent(routeModule), ErrorBoundary: routeModule.ErrorBoundary, + clientAction: routeModule.clientAction, clientLoader: routeModule.clientLoader, handle: routeModule.handle, links: routeModule.links, diff --git a/packages/remix-server-runtime/routeModules.ts b/packages/remix-server-runtime/routeModules.ts index 180df91e46f..f9bacaf542c 100644 --- a/packages/remix-server-runtime/routeModules.ts +++ b/packages/remix-server-runtime/routeModules.ts @@ -42,6 +42,22 @@ export type ActionFunctionArgs = RRActionFunctionArgs & { context: AppLoadContext; }; +/** + * A function that handles data mutations for a route on the client + * @private Public API is exported from @remix-run/react + */ +type ClientActionFunction = ( + args: ClientActionFunctionArgs +) => ReturnType; + +/** + * Arguments passed to a route `clientAction` function + * @private Public API is exported from @remix-run/react + */ +type ClientActionFunctionArgs = RRActionFunctionArgs & { + serverAction: () => Promise>; +}; + /** * A function that loads data for a route on the server */ @@ -233,6 +249,7 @@ type LdJsonValue = LdJsonPrimitive | LdJsonObject | LdJsonArray; export type RouteHandle = unknown; export interface EntryRouteModule { + clientAction?: ClientActionFunction; clientLoader?: ClientLoaderFunction; ErrorBoundary?: any; // Weakly typed because server-runtime is not React-aware HydrateFallback?: any; // Weakly typed because server-runtime is not React-aware diff --git a/packages/remix-testing/create-remix-stub.tsx b/packages/remix-testing/create-remix-stub.tsx index 7f1cc0af21c..ee35e1c62aa 100644 --- a/packages/remix-testing/create-remix-stub.tsx +++ b/packages/remix-testing/create-remix-stub.tsx @@ -182,6 +182,7 @@ function processRoutes( // When testing routes, you should just be stubbing loader/action, not // trying to re-implement the full loader/clientLoader/SSR/hydration flow. // That is better tested via E2E tests. + hasClientAction: false, hasClientLoader: false, hasErrorBoundary: route.ErrorBoundary != null, module: "build/stub-path-to-module.js", // any need for this?