From 9d55b78d30e22d1e925e120dfbffce459b7d8d7d Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 27 Feb 2026 17:57:20 +0900 Subject: [PATCH 01/12] Fix redirect guard routing behaviour --- .changeset/fix-redirect-guard-routing.md | 7 +++++++ packages/core/src/routing/navigation.tsx | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-redirect-guard-routing.md diff --git a/.changeset/fix-redirect-guard-routing.md b/.changeset/fix-redirect-guard-routing.md new file mode 100644 index 00000000..aefd0249 --- /dev/null +++ b/.changeset/fix-redirect-guard-routing.md @@ -0,0 +1,7 @@ +--- +"@tailor-platform/app-shell": patch +--- + +Fixed `redirectTo()` guard not working on modules/resources without a component. Index routes are now created for loader-only routes, enabling redirect guards to execute properly. + +Fixed sidebar navigation hiding modules/resources that use `redirectTo()` guards. Navigation filtering now only excludes `hidden()` guards, so redirect-guarded items remain visible in the sidebar. diff --git a/packages/core/src/routing/navigation.tsx b/packages/core/src/routing/navigation.tsx index 05bee95e..4c82151c 100644 --- a/packages/core/src/routing/navigation.tsx +++ b/packages/core/src/routing/navigation.tsx @@ -62,7 +62,7 @@ const buildNavItems = async (props: BuildNavItemsProps) => { if (module.path.startsWith(":")) return null; const guardResult = await runGuards(module.guards); - if (guardResult.type !== "pass") return null; + if (guardResult.type === "hidden") return null; const visibleResources = await filterVisibleResources( module.resources, @@ -99,7 +99,7 @@ const filterVisibleResources = async ( if (resource.path.startsWith(":")) return null; const guardResult = await runGuards(resource.guards); - if (guardResult.type !== "pass") return null; + if (guardResult.type === "hidden") return null; const resourcePath = `${basePath}/${resource.path}`; const resourceTitle = resolveTitle(resource.meta.title, resource.path); From 249bf2b852ba4ce9866251ac7b9791c1d2275718 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 27 Feb 2026 18:06:21 +0900 Subject: [PATCH 02/12] Add tests --- packages/core/src/routing/navigation.test.tsx | 111 +++++++++++++++++- packages/core/src/routing/router.test.tsx | 37 ++++++ packages/core/src/routing/routes.test.tsx | 79 +++++++++++++ 3 files changed, 226 insertions(+), 1 deletion(-) diff --git a/packages/core/src/routing/navigation.test.tsx b/packages/core/src/routing/navigation.test.tsx index d1b85f7b..3ff1a931 100644 --- a/packages/core/src/routing/navigation.test.tsx +++ b/packages/core/src/routing/navigation.test.tsx @@ -2,7 +2,7 @@ import { renderHook, waitFor } from "@testing-library/react"; import { describe, it, expect, beforeEach } from "vitest"; import { AppShell } from "../components/appshell"; import { useNavItems } from "./navigation"; -import { defineModule, defineResource, hidden } from "@/resource"; +import { defineModule, defineResource, hidden, redirectTo } from "@/resource"; const renderNavItems = ( modules: Array>, @@ -226,6 +226,115 @@ describe("useNavItems", () => { expect(resources.map((r) => r.url)).not.toContain("users/:id"); }); + it("keeps modules with redirectTo guards visible in navigation", async () => { + const modules = [ + defineModule({ + path: "redirected", + meta: { title: "Redirected Module" }, + resources: [ + defineResource({ + path: "target", + component: () =>
Target
, + }), + ], + guards: [() => redirectTo("/other")], + }), + defineModule({ + path: "dashboard", + meta: { title: "Dashboard" }, + component: () =>
Dashboard Root
, + resources: [ + defineResource({ + path: "overview", + component: () =>
Overview
, + }), + ], + }), + ]; + + const { result } = renderNavItems(modules, "/dashboard/overview"); + + await waitFor(async () => { + expect(await result.current!).toHaveLength(2); + }); + + const navItems = await result.current!; + expect(navItems.map((i) => i.title)).toEqual([ + "Redirected Module", + "Dashboard", + ]); + }); + + it("keeps resources with redirectTo guards visible in navigation", async () => { + const modules = [ + defineModule({ + path: "workspace", + meta: { title: "Workspace" }, + component: () =>
Workspace Root
, + resources: [ + defineResource({ + path: "visible", + component: () =>
Visible
, + }), + defineResource({ + path: "redirected", + component: () =>
Redirected
, + guards: [() => redirectTo("/other")], + }), + ], + }), + ]; + + const { result } = renderNavItems(modules, "/workspace/visible"); + + await waitFor(async () => { + expect(await result.current!).toHaveLength(1); + }); + + const navItems = await result.current!; + const resources = navItems[0].items; + expect(resources).toHaveLength(2); + expect(resources.map((r) => r.title)).toEqual(["Visible", "Redirected"]); + }); + + it("keeps subResources with redirectTo guards visible in navigation", async () => { + const modules = [ + defineModule({ + path: "admin", + meta: { title: "Admin" }, + component: () =>
Admin Root
, + resources: [ + defineResource({ + path: "panel", + component: () =>
Panel
, + subResources: [ + defineResource({ + path: "visible", + component: () =>
Visible
, + }), + defineResource({ + path: "redirected", + component: () =>
Redirected
, + guards: [() => redirectTo("/other")], + }), + ], + }), + ], + }), + ]; + + const { result } = renderNavItems(modules, "/admin/panel"); + + await waitFor(async () => { + expect(await result.current!).toHaveLength(1); + }); + + const navItems = await result.current!; + const subItems = navItems[0].items[0].items!; + expect(subItems).toHaveLength(2); + expect(subItems.map((s) => s.title)).toEqual(["Visible", "Redirected"]); + }); + it("filters out hidden subResources by guards", async () => { const modules = [ defineModule({ diff --git a/packages/core/src/routing/router.test.tsx b/packages/core/src/routing/router.test.tsx index 5373c55c..c97ece6b 100644 --- a/packages/core/src/routing/router.test.tsx +++ b/packages/core/src/routing/router.test.tsx @@ -284,6 +284,43 @@ describe("RouterContainer (memory)", () => { expect(await screen.findByText("Overview")).toBeDefined(); }); + it("redirects when module has no component and only redirectTo guard", async () => { + const dashboardModule = defineModule({ + path: "dashboard", + component: () =>
Dashboard
, + meta: { title: "Dashboard" }, + resources: [ + defineResource({ + path: "overview", + component: () =>
Overview
, + meta: { title: "Overview" }, + }), + ], + }); + + // Module without component — only redirectTo guard + const redirectModule = defineModule({ + path: "redirect-only", + meta: { title: "Redirect Only" }, + guards: [() => redirectTo("/dashboard/overview")], + resources: [ + defineResource({ + path: "child", + component: () =>
Child
, + meta: { title: "Child" }, + }), + ], + }); + + renderWithConfig({ + modules: [dashboardModule, redirectModule], + initialEntries: ["/redirect-only"], + }); + + // Should redirect to dashboard/overview, not render a blank page + expect(await screen.findByText("Overview")).toBeDefined(); + }); + it("redirects resource when guard returns redirectTo", async () => { const dashboardModule = defineModule({ path: "dashboard", diff --git a/packages/core/src/routing/routes.test.tsx b/packages/core/src/routing/routes.test.tsx index 92376a45..7969421a 100644 --- a/packages/core/src/routing/routes.test.tsx +++ b/packages/core/src/routing/routes.test.tsx @@ -201,6 +201,85 @@ describe("createContentRoutes", () => { expect(childResource?.loader).toBeUndefined(); }); + it("creates index route with loader and dummy component for module without component but with guards", () => { + const module = defineModule({ + path: "redirect-module", + meta: { title: "Redirect Module" }, + guards: [() => redirectTo("/dashboard")], + resources: [createMockResource("child")], + }); + + const routes = createContentRoutes({ + modules: [module], + settingsResources: [], + }); + + const moduleContainer = routes[1]; + const moduleRoute = moduleContainer.children?.[0]; + expect(moduleRoute?.path).toBe("redirect-module"); + + // Index route should exist with loader and dummy component + const indexRoute = moduleRoute?.children?.[0]; + expect(indexRoute?.index).toBe(true); + expect(indexRoute?.loader).toBeDefined(); + expect(typeof indexRoute?.loader).toBe("function"); + expect(typeof indexRoute?.Component).toBe("function"); + + // Child resource should still be present + const childRoute = moduleRoute?.children?.[1]; + expect(childRoute?.path).toBe("child"); + }); + + it("does not create index route for module without component and without guards", () => { + // This case should throw in defineModule, but we test createRoute's behavior + // by using a module with component (which produces index route) + const module = defineModule({ + path: "no-comp-no-guard", + meta: { title: "Test" }, + component: () =>
Test
, + resources: [createMockResource("child")], + }); + + const routes = createContentRoutes({ + modules: [module], + settingsResources: [], + }); + + const moduleContainer = routes[1]; + const moduleRoute = moduleContainer.children?.[0]; + const indexRoute = moduleRoute?.children?.[0]; + expect(indexRoute?.index).toBe(true); + expect(indexRoute?.Component).toBeDefined(); + // No guards = no loader + expect(indexRoute?.loader).toBeUndefined(); + }); + + it("redirect guard loader returns a redirect response", async () => { + const module = defineModule({ + path: "redirect-module", + meta: { title: "Redirect Module" }, + guards: [() => redirectTo("/dashboard")], + resources: [createMockResource("child")], + }); + + const routes = createContentRoutes({ + modules: [module], + settingsResources: [], + }); + + const moduleContainer = routes[1]; + const moduleRoute = moduleContainer.children?.[0]; + const indexRoute = moduleRoute?.children?.[0]; + const loader = indexRoute?.loader; + expect(typeof loader).toBe("function"); + + // Execute the loader - it should return a redirect + const result = await (loader as Function)({} as never); + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(302); + expect((result as Response).headers.get("Location")).toBe("/dashboard"); + }); + it("attaches loader to index route only for resource (no cascade to sub-resources)", () => { const module = defineModule({ path: "dashboard", From eb4d9eea964fc1ce1a07df4ce7e1026a941666dc Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 27 Feb 2026 18:10:32 +0900 Subject: [PATCH 03/12] Update changeset --- .changeset/fix-redirect-guard-routing.md | 25 +++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/.changeset/fix-redirect-guard-routing.md b/.changeset/fix-redirect-guard-routing.md index aefd0249..f8663c93 100644 --- a/.changeset/fix-redirect-guard-routing.md +++ b/.changeset/fix-redirect-guard-routing.md @@ -4,4 +4,27 @@ Fixed `redirectTo()` guard not working on modules/resources without a component. Index routes are now created for loader-only routes, enabling redirect guards to execute properly. -Fixed sidebar navigation hiding modules/resources that use `redirectTo()` guards. Navigation filtering now only excludes `hidden()` guards, so redirect-guarded items remain visible in the sidebar. +Fixed auto-generated sidebar navigation hiding modules/resources that use `redirectTo()` guards. Navigation filtering now only excludes `hidden()` guards, so redirect-guarded items remain visible in the sidebar. This only affects the auto-generation mode of `SidebarLayout`; composition mode (where children are explicitly provided) is not affected. + +```tsx +// This pattern previously rendered a blank page — now correctly redirects: +defineModule({ + path: "old-dashboard", + guards: [() => redirectTo("/new-dashboard")], + resources: [...], +}); + +// Modules/resources with redirectTo guards were previously hidden from +// the sidebar — now they remain visible: +defineModule({ + path: "legacy", + guards: [() => redirectTo("/modern")], + resources: [ + defineResource({ + path: "page", + component: () =>
Page
, + }), + ], +}); +// "Legacy" and its resources will appear in the sidebar navigation. +``` From 117d3ab7bc5a356c1ddaeff8a70b2606d054badc Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 27 Feb 2026 23:22:56 +0900 Subject: [PATCH 04/12] Fix lint error --- packages/core/src/routing/routes.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/routing/routes.test.tsx b/packages/core/src/routing/routes.test.tsx index 7969421a..d71ad448 100644 --- a/packages/core/src/routing/routes.test.tsx +++ b/packages/core/src/routing/routes.test.tsx @@ -274,7 +274,7 @@ describe("createContentRoutes", () => { expect(typeof loader).toBe("function"); // Execute the loader - it should return a redirect - const result = await (loader as Function)({} as never); + const result = await (loader as (...args: unknown[]) => unknown)({} as never); expect(result).toBeInstanceOf(Response); expect((result as Response).status).toBe(302); expect((result as Response).headers.get("Location")).toBe("/dashboard"); From f8748b46a1b780edf0dd201b58515c3d31269e4a Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 27 Feb 2026 23:46:41 +0900 Subject: [PATCH 05/12] docs: clarify redirect guard nav visibility and update changeset --- .changeset/fix-redirect-guard-routing.md | 15 ++++++++++++--- packages/core/src/routing/navigation.tsx | 7 +++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.changeset/fix-redirect-guard-routing.md b/.changeset/fix-redirect-guard-routing.md index f8663c93..56407783 100644 --- a/.changeset/fix-redirect-guard-routing.md +++ b/.changeset/fix-redirect-guard-routing.md @@ -6,6 +6,16 @@ Fixed `redirectTo()` guard not working on modules/resources without a component. Fixed auto-generated sidebar navigation hiding modules/resources that use `redirectTo()` guards. Navigation filtering now only excludes `hidden()` guards, so redirect-guarded items remain visible in the sidebar. This only affects the auto-generation mode of `SidebarLayout`; composition mode (where children are explicitly provided) is not affected. +### Guard result types and their effect on navigation visibility + +| Guard result | Nav visibility | Routing behaviour | +|---|---|---| +| `pass()` | Visible | Renders the component | +| `hidden()` | **Hidden** | Returns 404 | +| `redirectTo(path)` | Visible | Redirects to the specified path | + +`redirectTo()` guards intentionally keep the item visible in the sidebar. This supports the common pattern where a module has no component of its own but should still appear as a sidebar item that redirects elsewhere (e.g. aliasing a legacy path to a new location). Additionally, if a module with `redirectTo()` were hidden from the sidebar, all of its child resources would also disappear from navigation, even though those children may have real pages that users need to access. + ```tsx // This pattern previously rendered a blank page — now correctly redirects: defineModule({ @@ -14,8 +24,8 @@ defineModule({ resources: [...], }); -// Modules/resources with redirectTo guards were previously hidden from -// the sidebar — now they remain visible: +// Modules/resources with redirectTo guards remain visible in the sidebar. +// Clicking "Legacy" navigates to /legacy, which then redirects to /modern. defineModule({ path: "legacy", guards: [() => redirectTo("/modern")], @@ -26,5 +36,4 @@ defineModule({ }), ], }); -// "Legacy" and its resources will appear in the sidebar navigation. ``` diff --git a/packages/core/src/routing/navigation.tsx b/packages/core/src/routing/navigation.tsx index 4c82151c..6c1d247a 100644 --- a/packages/core/src/routing/navigation.tsx +++ b/packages/core/src/routing/navigation.tsx @@ -61,6 +61,13 @@ const buildNavItems = async (props: BuildNavItemsProps) => { // Skip param routes at module level if (module.path.startsWith(":")) return null; + // Only `hidden` guards are excluded from navigation. + // `redirect` guards intentionally remain visible — this supports the + // pattern where a module has no component but should still appear as + // a sidebar item (e.g. aliasing an old path to a new one). + // Additionally, hiding a module would also hide all of its child + // resources from the sidebar, which is undesirable when the module + // itself simply redirects but its children have real pages. const guardResult = await runGuards(module.guards); if (guardResult.type === "hidden") return null; From 5a19020cb86991e314117f75607114aa114fb05c Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 6 Mar 2026 16:43:34 +0900 Subject: [PATCH 06/12] fix: throw 404 when guards pass on component-less route Add hasComponent option to withGuardsLoader so that when guards return pass() on a route without a component, a 404 is thrown instead of rendering a blank page via the placeholder Component. Also updated the comment in routes.tsx to accurately reflect that the loader may throw a not-found error (not only redirect/throw). --- .changeset/fix-redirect-guard-routing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-redirect-guard-routing.md b/.changeset/fix-redirect-guard-routing.md index 56407783..bd2fbc1a 100644 --- a/.changeset/fix-redirect-guard-routing.md +++ b/.changeset/fix-redirect-guard-routing.md @@ -1,5 +1,5 @@ --- -"@tailor-platform/app-shell": patch +"@tailor-platform/app-shell": minor --- Fixed `redirectTo()` guard not working on modules/resources without a component. Index routes are now created for loader-only routes, enabling redirect guards to execute properly. From a3fc155bc09e82106010be355b9655ceea507683 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 6 Mar 2026 16:48:40 +0900 Subject: [PATCH 07/12] Fix format --- .changeset/fix-redirect-guard-routing.md | 10 +++++----- packages/core/src/routing/navigation.test.tsx | 5 +---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/.changeset/fix-redirect-guard-routing.md b/.changeset/fix-redirect-guard-routing.md index bd2fbc1a..031d9e0a 100644 --- a/.changeset/fix-redirect-guard-routing.md +++ b/.changeset/fix-redirect-guard-routing.md @@ -8,11 +8,11 @@ Fixed auto-generated sidebar navigation hiding modules/resources that use `redir ### Guard result types and their effect on navigation visibility -| Guard result | Nav visibility | Routing behaviour | -|---|---|---| -| `pass()` | Visible | Renders the component | -| `hidden()` | **Hidden** | Returns 404 | -| `redirectTo(path)` | Visible | Redirects to the specified path | +| Guard result | Nav visibility | Routing behaviour | +| ------------------ | -------------- | ------------------------------- | +| `pass()` | Visible | Renders the component | +| `hidden()` | **Hidden** | Returns 404 | +| `redirectTo(path)` | Visible | Redirects to the specified path | `redirectTo()` guards intentionally keep the item visible in the sidebar. This supports the common pattern where a module has no component of its own but should still appear as a sidebar item that redirects elsewhere (e.g. aliasing a legacy path to a new location). Additionally, if a module with `redirectTo()` were hidden from the sidebar, all of its child resources would also disappear from navigation, even though those children may have real pages that users need to access. diff --git a/packages/core/src/routing/navigation.test.tsx b/packages/core/src/routing/navigation.test.tsx index 3ff1a931..30f9f29b 100644 --- a/packages/core/src/routing/navigation.test.tsx +++ b/packages/core/src/routing/navigation.test.tsx @@ -259,10 +259,7 @@ describe("useNavItems", () => { }); const navItems = await result.current!; - expect(navItems.map((i) => i.title)).toEqual([ - "Redirected Module", - "Dashboard", - ]); + expect(navItems.map((i) => i.title)).toEqual(["Redirected Module", "Dashboard"]); }); it("keeps resources with redirectTo guards visible in navigation", async () => { From d2f25cf7a35244c69ad85fbc73aca9e91e4fad5c Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 6 Mar 2026 17:54:30 +0900 Subject: [PATCH 08/12] refactor: separate guardLoader from loader in Module/Resource types Instead of using a boolean hasGuardLoader flag alongside loader, introduce a dedicated guardLoader field on Module and Resource types. This eliminates the possibility of inconsistent states (e.g. loader=undefined + hasGuardLoader=true). - guardLoader: set by withGuardsLoader() when guards are present - loader: reserved for user-provided loaders (e.g. fs-routes data loaders) - routes.tsx uses effectiveLoader = guardLoader ?? loader - fs-routes converter now chains guards with baseLoader via withGuardsLoader() so guards are evaluated before the user loader runs - Export withGuardsLoader for use by fs-routes converter --- packages/core/src/fs-routes/converter.tsx | 26 ++++++++++++-------- packages/core/src/resource.tsx | 30 ++++++++++++++++++----- packages/core/src/routing/routes.tsx | 21 ++++++++++++---- 3 files changed, 56 insertions(+), 21 deletions(-) diff --git a/packages/core/src/fs-routes/converter.tsx b/packages/core/src/fs-routes/converter.tsx index 2555f582..43da0842 100644 --- a/packages/core/src/fs-routes/converter.tsx +++ b/packages/core/src/fs-routes/converter.tsx @@ -79,10 +79,7 @@ function nodeToResource(node: PageNode): Resource { const title = getTitle(Component, node.path); const icon = Component?.appShellPageProps?.meta?.icon; const breadcrumbTitle = Component?.appShellPageProps?.meta?.breadcrumbTitle; - const explicitLoader = Component?.appShellPageProps?.loader; - const loader = - explicitLoader ?? - (node.guards && node.guards.length > 0 ? withGuardsLoader(node.guards) : undefined); + const loader = Component?.appShellPageProps?.loader; // Recursively convert children to subResources const subResources: Resource[] = []; @@ -90,6 +87,11 @@ function nodeToResource(node: PageNode): Resource { subResources.push(nodeToResource(child)); } + const hasGuards = node.guards.length > 0; + const guardLoader = hasGuards + ? withGuardsLoader(node.guards, { hasComponent: !!Component, baseLoader: loader }) + : undefined; + return { // oxlint-disable-next-line no-underscore-dangle _type: "resource", @@ -104,7 +106,8 @@ function nodeToResource(node: PageNode): Resource { subResources: subResources.length > 0 ? subResources : undefined, errorBoundary: , guards: node.guards, - loader, + loader: hasGuards ? undefined : loader, + guardLoader, }; } @@ -116,10 +119,7 @@ function nodeToModule(node: PageNode): Module { const title = getTitle(Component, node.path); const icon = Component?.appShellPageProps?.meta?.icon; const breadcrumbTitle = Component?.appShellPageProps?.meta?.breadcrumbTitle; - const explicitLoader = Component?.appShellPageProps?.loader; - const loader = - explicitLoader ?? - (node.guards && node.guards.length > 0 ? withGuardsLoader(node.guards) : undefined); + const loader = Component?.appShellPageProps?.loader; // Convert children to resources const resources: Resource[] = []; @@ -127,6 +127,11 @@ function nodeToModule(node: PageNode): Module { resources.push(nodeToResource(child)); } + const hasGuards = node.guards.length > 0; + const guardLoader = hasGuards + ? withGuardsLoader(node.guards, { hasComponent: !!Component, baseLoader: loader }) + : undefined; + return { // oxlint-disable-next-line no-underscore-dangle _type: "module", @@ -142,7 +147,8 @@ function nodeToModule(node: PageNode): Module { resources, errorBoundary: , guards: node.guards, - loader, + loader: hasGuards ? undefined : loader, + guardLoader, }; } diff --git a/packages/core/src/resource.tsx b/packages/core/src/resource.tsx index 05f2c618..82c51d15 100644 --- a/packages/core/src/resource.tsx +++ b/packages/core/src/resource.tsx @@ -158,8 +158,18 @@ export const runGuards = async (guards: Guard[] | undefined): Promise { +export const withGuardsLoader = ( + guards: Guard[] | undefined, + options?: { hasComponent?: boolean; baseLoader?: LoaderHandler }, +) => { + const { hasComponent, baseLoader } = options ?? {}; + return async (args: LoaderFunctionArgs) => { const result = await runGuards(guards); switch (result.type) { @@ -168,7 +178,9 @@ export const withGuardsLoader = (guards: Guard[] | undefined, baseLoader?: Loade case "redirect": return redirect(result.to); case "pass": - return baseLoader ? baseLoader(args) : null; + if (baseLoader) return baseLoader(args); + if (!hasComponent) throw createNotFoundError(); + return null; } }; }; @@ -209,6 +221,7 @@ export type Module = Omit & { errorBoundary: ErrorBoundaryComponent; guards?: Guard[]; loader?: LoaderHandler; + guardLoader?: LoaderHandler; }; /** @@ -225,6 +238,7 @@ export type Resource = CommonPageResource & { errorBoundary: ErrorBoundaryComponent; guards?: Guard[]; loader?: LoaderHandler; + guardLoader?: LoaderHandler; }; export type Modules = Array; @@ -373,7 +387,10 @@ export function defineModule(props: DefineModuleProps): Module { const { path, meta, component, resources, errorBoundary, guards } = props; const metaTitle: LocalizedString = meta?.title ?? titleFromPath(path); const fallbackTitle = titleFromPath(path); - const loader = guards && guards.length > 0 ? withGuardsLoader(guards) : undefined; + const guardLoader = + guards && guards.length > 0 + ? withGuardsLoader(guards, { hasComponent: !!component }) + : undefined; const wrappedComponent = component ? makeComponent({ metaTitle, fallbackTitle }, (title) => component({ title, resources })) : undefined; @@ -384,7 +401,7 @@ export function defineModule(props: DefineModuleProps): Module { // oxlint-disable-next-line no-underscore-dangle _type: "module" as const, component: wrappedComponent, - loader, + guardLoader, meta: { title: metaTitle, ...(meta?.breadcrumbTitle !== undefined ? { breadcrumbTitle: meta.breadcrumbTitle } : {}), @@ -473,7 +490,8 @@ export function defineResource(props: DefineResourceProps): Resource { const { path, component, subResources, meta, errorBoundary, guards } = props; const metaTitle: LocalizedString = meta?.title ?? capitalCase(path); const fallbackTitle = capitalCase(path); - const loader = guards && guards.length > 0 ? withGuardsLoader(guards) : undefined; + const guardLoader = + guards && guards.length > 0 ? withGuardsLoader(guards, { hasComponent: true }) : undefined; const wrappedComponent = component ? makeComponent({ metaTitle, fallbackTitle }, (title) => component({ title, resources: subResources }), @@ -494,6 +512,6 @@ export function defineResource(props: DefineResourceProps): Resource { subResources, errorBoundary: errorBoundary ?? , guards, - loader, + guardLoader, }; } diff --git a/packages/core/src/routing/routes.tsx b/packages/core/src/routing/routes.tsx index b20133e4..adeecb2b 100644 --- a/packages/core/src/routing/routes.tsx +++ b/packages/core/src/routing/routes.tsx @@ -18,6 +18,7 @@ type RouteSource = { path: string; component?: () => ReactNode; loader?: LoaderHandler; + guardLoader?: LoaderHandler; errorBoundary?: ErrorBoundaryComponent; }; @@ -32,14 +33,16 @@ type RouteSource = { * match the parent and render nothing) */ const resolveIndexRoute = (source: RouteSource): RouteObject => { + const effectiveLoader = source.guardLoader ?? source.loader; + if (source.component) { return { index: true, Component: source.component, - ...(source.loader && { loader: source.loader }), + ...(effectiveLoader && { loader: effectiveLoader }), }; } - if (source.loader) { + if (effectiveLoader) { return { index: true, // No-op component to suppress React Router's warning about a matched @@ -47,7 +50,7 @@ const resolveIndexRoute = (source: RouteSource): RouteObject => { // returns a redirect Response, so this component never actually renders. Component: () => null, loader: async (args: Parameters[0]) => { - const result = await source.loader!(args); + const result = await effectiveLoader(args); if (result instanceof Response) return result; throw createNotFoundError(); }, @@ -86,7 +89,9 @@ const createRoute = ( }; const routesFromModules = (modules: Modules) => - modules.map((module) => createRoute(module, module.resources, module.errorBoundary)); + modules.map((module) => + createRoute(module, module.resources, module.errorBoundary), + ); type CreateContentRoutesParams = { modules: Modules; @@ -114,7 +119,13 @@ export const createContentRoutes = ({ { path: "settings", index: true, - Component: () => , + Component: () => ( + + ), }, { path: "settings", From 5ee76dce26974c4aff26526eec74a37f1b98bc20 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 6 Mar 2026 17:54:38 +0900 Subject: [PATCH 09/12] test: fix test name and add tests for guardLoader behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename misleading test: 'does not create index route...' → 'creates index route with Component but no loader for a module with component and no guards' - Add test: multiple pass guards then loader executes - Add test: hidden guard throws 404 (loader not called) - Add test: fs-routes module with guards+loader generates guardLoader - Add test: fs-routes resource with guards+loader generates guardLoader --- .../core/src/fs-routes/converter.test.tsx | 37 ++++++++++++ packages/core/src/routing/routes.test.tsx | 57 ++++++++++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/core/src/fs-routes/converter.test.tsx b/packages/core/src/fs-routes/converter.test.tsx index 1956d4f3..01840256 100644 --- a/packages/core/src/fs-routes/converter.test.tsx +++ b/packages/core/src/fs-routes/converter.test.tsx @@ -102,6 +102,43 @@ describe("convertPagesToModules", () => { expect(modules[0].resources[0].guards).not.toContain(parentGuard); }); + it("generates guardLoader when guards and loader co-exist", async () => { + const guard = async () => ({ type: "pass" as const }); + const loader = async () => ({ data: "test" }); + + const pages = [createMockPage("/dashboard", { guards: [guard], loader })]; + const modules = convertPagesToModules(pages); + + // guardLoader should be generated (wraps guards + baseLoader) + expect(modules[0].guardLoader).toBeDefined(); + expect(typeof modules[0].guardLoader).toBe("function"); + + // loader should be cleared since guardLoader takes over + expect(modules[0].loader).toBeUndefined(); + + // Execute the guardLoader — guard passes, so baseLoader should run + const result = await modules[0].guardLoader!({} as never); + expect(result).toEqual({ data: "test" }); + }); + + it("generates guardLoader for resource when guards and loader co-exist", async () => { + const guard = async () => ({ type: "pass" as const }); + const loader = async () => ({ items: [1, 2, 3] }); + + const pages = [ + createMockPage("/dashboard"), + createMockPage("/dashboard/orders", { guards: [guard], loader }), + ]; + const modules = convertPagesToModules(pages); + + const resource = modules[0].resources[0]; + expect(resource.guardLoader).toBeDefined(); + expect(resource.loader).toBeUndefined(); + + const result = await resource.guardLoader!({} as never); + expect(result).toEqual({ items: [1, 2, 3] }); + }); + it("handles multiple top-level modules", () => { const pages = [ createMockPage("/dashboard"), diff --git a/packages/core/src/routing/routes.test.tsx b/packages/core/src/routing/routes.test.tsx index d71ad448..a75061a3 100644 --- a/packages/core/src/routing/routes.test.tsx +++ b/packages/core/src/routing/routes.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, assert } from "vitest"; +import { describe, expect, it, assert, vi } from "vitest"; import { createContentRoutes } from "./routes"; import { EmptyOutlet, SettingsWrapper } from "@/components/content"; import { defineModule, defineResource, pass, redirectTo, hidden } from "@/resource"; @@ -323,6 +323,61 @@ describe("createContentRoutes", () => { expect(subResource?.loader).toBeUndefined(); }); + it("calls loader after all guards pass", async () => { + const guard1 = vi.fn().mockReturnValue(pass()); + const guard2 = vi.fn().mockReturnValue(pass()); + + const module = defineModule({ + path: "multi-guard", + component: () =>
Multi Guard
, + meta: { title: "Multi Guard" }, + guards: [guard1, guard2], + resources: [], + }); + + const routes = createContentRoutes({ + modules: [module], + settingsResources: [], + }); + + const moduleRoute = routes[1].children?.[0]; + const indexRoute = moduleRoute?.children?.[0]; + const routeLoader = indexRoute?.loader; + expect(typeof routeLoader).toBe("function"); + + const result = await (routeLoader as (...args: unknown[]) => unknown)({} as never); + expect(guard1).toHaveBeenCalled(); + expect(guard2).toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it("does not call loader when a guard returns hidden", async () => { + const module = defineModule({ + path: "hidden-module", + component: () =>
Hidden
, + meta: { title: "Hidden" }, + guards: [() => pass(), () => hidden()], + resources: [], + }); + + const routes = createContentRoutes({ + modules: [module], + settingsResources: [], + }); + + const moduleRoute = routes[1].children?.[0]; + const indexRoute = moduleRoute?.children?.[0]; + const routeLoader = indexRoute?.loader; + + try { + await (routeLoader as (...args: unknown[]) => unknown)({} as never); + expect.unreachable("Loader should throw for hidden guard"); + } catch (error) { + expect(error).toBeInstanceOf(Response); + expect((error as Response).status).toBe(404); + } + }); + it("creates module without component or guards (path-only module)", () => { const module = defineModule({ path: "admin", From 66fd119935c90628b5c83ae32900b29a4269e914 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Wed, 24 Jun 2026 15:30:34 +0900 Subject: [PATCH 10/12] Format # Conflicts: # packages/core/src/routing/routes.tsx --- packages/core/src/routing/routes.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/core/src/routing/routes.tsx b/packages/core/src/routing/routes.tsx index adeecb2b..acd18cde 100644 --- a/packages/core/src/routing/routes.tsx +++ b/packages/core/src/routing/routes.tsx @@ -89,9 +89,7 @@ const createRoute = ( }; const routesFromModules = (modules: Modules) => - modules.map((module) => - createRoute(module, module.resources, module.errorBoundary), - ); + modules.map((module) => createRoute(module, module.resources, module.errorBoundary)); type CreateContentRoutesParams = { modules: Modules; @@ -119,13 +117,7 @@ export const createContentRoutes = ({ { path: "settings", index: true, - Component: () => ( - - ), + Component: () => , }, { path: "settings", From 5fc10acdb470da496c6b491d28bd2e71db04c06c Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 6 Mar 2026 17:59:03 +0900 Subject: [PATCH 11/12] Fix lint errors --- packages/core/src/fs-routes/converter.test.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/core/src/fs-routes/converter.test.tsx b/packages/core/src/fs-routes/converter.test.tsx index 01840256..62f6bbf9 100644 --- a/packages/core/src/fs-routes/converter.test.tsx +++ b/packages/core/src/fs-routes/converter.test.tsx @@ -103,10 +103,12 @@ describe("convertPagesToModules", () => { }); it("generates guardLoader when guards and loader co-exist", async () => { - const guard = async () => ({ type: "pass" as const }); - const loader = async () => ({ data: "test" }); - - const pages = [createMockPage("/dashboard", { guards: [guard], loader })]; + const pages = [ + createMockPage("/dashboard", { + guards: [async () => ({ type: "pass" as const })], + loader: async () => ({ data: "test" }), + }), + ]; const modules = convertPagesToModules(pages); // guardLoader should be generated (wraps guards + baseLoader) @@ -122,12 +124,12 @@ describe("convertPagesToModules", () => { }); it("generates guardLoader for resource when guards and loader co-exist", async () => { - const guard = async () => ({ type: "pass" as const }); - const loader = async () => ({ items: [1, 2, 3] }); - const pages = [ createMockPage("/dashboard"), - createMockPage("/dashboard/orders", { guards: [guard], loader }), + createMockPage("/dashboard/orders", { + guards: [async () => ({ type: "pass" as const })], + loader: async () => ({ items: [1, 2, 3] }), + }), ]; const modules = convertPagesToModules(pages); From 6e2f4d53e89be8efe889e6f2594b6203c685a2f4 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 6 Mar 2026 18:24:10 +0900 Subject: [PATCH 12/12] Add and update changesets --- .changeset/fix-fs-routes-guard-loader-chain.md | 5 +++++ .changeset/fix-redirect-guard-routing.md | 2 ++ 2 files changed, 7 insertions(+) create mode 100644 .changeset/fix-fs-routes-guard-loader-chain.md diff --git a/.changeset/fix-fs-routes-guard-loader-chain.md b/.changeset/fix-fs-routes-guard-loader-chain.md new file mode 100644 index 00000000..430f7a6c --- /dev/null +++ b/.changeset/fix-fs-routes-guard-loader-chain.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/app-shell": patch +--- + +Fixed file-based routing (vite plugin) not chaining guards with user-provided loaders. When a page module defines both guards and a `loader` export, guards are now evaluated first via `withGuardsLoader`, and the user loader runs only after guards pass. Previously, guards could overwrite the user loader. diff --git a/.changeset/fix-redirect-guard-routing.md b/.changeset/fix-redirect-guard-routing.md index 031d9e0a..0492cd97 100644 --- a/.changeset/fix-redirect-guard-routing.md +++ b/.changeset/fix-redirect-guard-routing.md @@ -6,6 +6,8 @@ Fixed `redirectTo()` guard not working on modules/resources without a component. Fixed auto-generated sidebar navigation hiding modules/resources that use `redirectTo()` guards. Navigation filtering now only excludes `hidden()` guards, so redirect-guarded items remain visible in the sidebar. This only affects the auto-generation mode of `SidebarLayout`; composition mode (where children are explicitly provided) is not affected. +Fixed a component-less module with a mixed guard (one that sometimes returns `pass()`) rendering a blank page. When guards pass on a route without a component, a 404 error is now thrown instead of rendering an empty page. + ### Guard result types and their effect on navigation visibility | Guard result | Nav visibility | Routing behaviour |