From 1b342f708281933ac7ed96407e4d630bd01394ad Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 07:11:11 +0000 Subject: [PATCH 1/2] fix: hide unregistered admin tiles, detect Pages/Index.tsx barrel collision - Admin hub now probes the registered EndpointDataSource and only renders tiles whose target route is actually registered. Minimal installs no longer surface 404 tiles for missing peer modules (#134). - defineModuleConfig validates Pages/index.ts at config-evaluation time and throws a clear, actionable error when the barrel imports a case-insensitively colliding sibling (e.g. import('./Index') next to Pages/Index.tsx). Prevents the silent self-referential chunk that breaks builds on macOS/Windows (#131). --- .../SimpleModule.Admin/Pages/Admin/Hub.tsx | 16 ++++- .../Pages/Admin/HubEndpoint.cs | 38 +++++++++++- .../src/define-module-config.ts | 62 +++++++++++++++++++ 3 files changed, 113 insertions(+), 3 deletions(-) diff --git a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/Hub.tsx b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/Hub.tsx index b5fcb315..f2d34020 100644 --- a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/Hub.tsx +++ b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/Hub.tsx @@ -124,7 +124,19 @@ function CardIcon({ icon }: { icon: string }) { ); } -export default function Hub() { +interface HubProps { + availableUrls?: string[]; +} + +export default function Hub({ availableUrls }: HubProps) { + const available = new Set((availableUrls ?? []).map((u) => u.toLowerCase())); + const visibleGroups = groups + .map((group) => ({ + ...group, + items: group.items.filter((i) => available.has(i.url.toLowerCase())), + })) + .filter((group) => group.items.length > 0); + return (
- {groups.map((group) => ( + {visibleGroups.map((group) => (

{group.title} diff --git a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/HubEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/HubEndpoint.cs index d388e112..1db628f8 100644 --- a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/HubEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/HubEndpoint.cs @@ -10,9 +10,45 @@ public class HubEndpoint : IViewEndpoint { public const string Route = AdminConstants.Routes.Hub; + // Tile URLs whose presence we probe before rendering. Anything not in the + // app's endpoint table is filtered out so we don't surface 404 tiles when + // the corresponding peer module isn't installed. + private static readonly string[] CandidateUrls = + [ + "/admin/users", + "/admin/roles", + "/openiddict/clients", + "/tenants/manage", + "/pages/manage", + "/email/templates", + "/email/history", + "/settings/menus", + "/feature-flags/manage", + "/rate-limiting/manage", + "/admin/jobs", + "/audit-logs/browse", + "/settings/manage", + ]; + public void Map(IEndpointRouteBuilder app) { - app.MapGet(Route, () => Inertia.Render("Admin/Admin/Hub")) + app.MapGet( + Route, + (EndpointDataSource endpointDataSource) => + { + var registered = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var endpoint in endpointDataSource.Endpoints) + { + if (endpoint is RouteEndpoint route) + { + registered.Add("/" + route.RoutePattern.RawText?.TrimStart('/')); + } + } + + var availableUrls = CandidateUrls.Where(registered.Contains).ToArray(); + return Inertia.Render("Admin/Admin/Hub", new { availableUrls }); + } + ) .RequireAuthorization(policy => policy.RequireRole("Admin")); } } diff --git a/packages/SimpleModule.Client/src/define-module-config.ts b/packages/SimpleModule.Client/src/define-module-config.ts index 80a430f0..7a4652a9 100644 --- a/packages/SimpleModule.Client/src/define-module-config.ts +++ b/packages/SimpleModule.Client/src/define-module-config.ts @@ -1,9 +1,69 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { basename, resolve } from 'node:path'; import react from '@vitejs/plugin-react'; import type { UserConfig } from 'vite'; import { defineConfig } from 'vite'; import { defaultVendors } from './vite-plugin-vendor.ts'; +/** + * Detect the case-insensitive filesystem trap where `Pages/index.ts` (the + * framework-required barrel) sits next to a page like `Pages/Index.tsx` and + * the barrel uses an extension-less dynamic import (`() => import('./Index')`). + * + * On macOS/Windows, Rolldown resolves `./Index` back to `./index.ts` (the + * barrel itself), producing a self-referential chunk that throws at runtime + * with `Cannot assign to property 'layout' of [object Module]`. + * + * Throw early with a clear, actionable error. + */ +function assertNoBarrelCollision(dir: string): void { + const pagesDir = resolve(dir, 'Pages'); + const barrelPath = resolve(pagesDir, 'index.ts'); + if (!existsSync(barrelPath)) return; + + let entries: string[]; + try { + entries = readdirSync(pagesDir); + } catch { + return; + } + + // Page filenames whose stem case-insensitively equals 'index' would collide + // with the barrel on case-insensitive filesystems. + const colliding = entries.filter((f) => /^index\.(tsx|jsx|js)$/i.test(f) && f !== 'index.ts'); + if (colliding.length === 0) return; + + let barrel: string; + try { + barrel = readFileSync(barrelPath, 'utf8'); + } catch { + return; + } + + // Look for extension-less dynamic imports whose specifier case-insensitively + // resolves to the barrel itself (`import('./Index')` next to `index.ts`). + // On case-insensitive filesystems Rolldown silently picks `./index.ts` + // (the barrel) rather than the sibling `./Index.tsx`, producing a chunk + // that re-exports the barrel and crashes at runtime. + const importRe = /import\(\s*['"]\.\/([A-Za-z0-9_-]+)['"]\s*\)/g; + for (const match of barrel.matchAll(importRe)) { + const specifier = match[1]; + if (specifier.toLowerCase() !== 'index') continue; + const offending = colliding.find( + (f) => f.replace(/\.(tsx|jsx|js)$/i, '').toLowerCase() === specifier.toLowerCase(), + ); + if (!offending) continue; + throw new Error( + `[@simplemodule/client] Pages/index.ts contains \`import('./${specifier}')\` which collides ` + + `with the barrel on case-insensitive filesystems (macOS/Windows). Rolldown will silently ` + + `emit a self-referential chunk and the page will fail at runtime with ` + + `"Cannot assign to property 'layout' of [object Module]".\n\n` + + `Fix: use the explicit file extension, e.g. \`import('./${offending}')\`, or rename ` + + `Pages/${offending} to a name that does not case-insensitively match 'index'.`, + ); + } +} + /** * Unified Vite config for SimpleModule modules. * @@ -20,6 +80,8 @@ import { defaultVendors } from './vite-plugin-vendor.ts'; * ``` */ export function defineModuleConfig(dir: string): UserConfig { + assertNoBarrelCollision(dir); + const name = basename(dir); const isDev = process.env.VITE_MODE !== 'prod'; From b6f777a320629e8579ab85aa1bf7fd7dfc419a18 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 07:23:11 +0000 Subject: [PATCH 2/2] refactor: simplify hub endpoint and barrel-collision validator - Cache the computed availableUrls in HubEndpoint instead of rebuilding the registered-route set on every request. The endpoint table is fixed after startup so the first request populates the cache. - Drop the redundant existsSync pre-flight in assertNoBarrelCollision and rely on the existing readdirSync try/catch. Tighten the import-detection regex to look for `./index` directly rather than capture-then-compare. --- .../Pages/Admin/HubEndpoint.cs | 37 ++++++++++------ .../src/define-module-config.ts | 43 ++++++++----------- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/HubEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/HubEndpoint.cs index 1db628f8..8e24e868 100644 --- a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/HubEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/HubEndpoint.cs @@ -10,9 +10,9 @@ public class HubEndpoint : IViewEndpoint { public const string Route = AdminConstants.Routes.Hub; - // Tile URLs whose presence we probe before rendering. Anything not in the - // app's endpoint table is filtered out so we don't surface 404 tiles when - // the corresponding peer module isn't installed. + // Must stay in sync with the `url` values in Hub.tsx's `groups`. URLs not + // present in the app's endpoint table are filtered out so the hub doesn't + // surface 404 tiles for peer modules that aren't installed. private static readonly string[] CandidateUrls = [ "/admin/users", @@ -30,25 +30,34 @@ public class HubEndpoint : IViewEndpoint "/settings/manage", ]; + private string[]? _availableUrls; + public void Map(IEndpointRouteBuilder app) { app.MapGet( Route, (EndpointDataSource endpointDataSource) => { - var registered = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var endpoint in endpointDataSource.Endpoints) - { - if (endpoint is RouteEndpoint route) - { - registered.Add("/" + route.RoutePattern.RawText?.TrimStart('/')); - } - } - - var availableUrls = CandidateUrls.Where(registered.Contains).ToArray(); - return Inertia.Render("Admin/Admin/Hub", new { availableUrls }); + _availableUrls ??= ComputeAvailableUrls(endpointDataSource); + return Inertia.Render( + "Admin/Admin/Hub", + new { availableUrls = _availableUrls } + ); } ) .RequireAuthorization(policy => policy.RequireRole("Admin")); } + + private static string[] ComputeAvailableUrls(EndpointDataSource endpointDataSource) + { + var registered = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var endpoint in endpointDataSource.Endpoints) + { + if (endpoint is RouteEndpoint route) + { + registered.Add("/" + route.RoutePattern.RawText?.TrimStart('/')); + } + } + return CandidateUrls.Where(registered.Contains).ToArray(); + } } diff --git a/packages/SimpleModule.Client/src/define-module-config.ts b/packages/SimpleModule.Client/src/define-module-config.ts index 7a4652a9..16d2713e 100644 --- a/packages/SimpleModule.Client/src/define-module-config.ts +++ b/packages/SimpleModule.Client/src/define-module-config.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; import { basename, resolve } from 'node:path'; import react from '@vitejs/plugin-react'; import type { UserConfig } from 'vite'; @@ -18,8 +18,6 @@ import { defaultVendors } from './vite-plugin-vendor.ts'; */ function assertNoBarrelCollision(dir: string): void { const pagesDir = resolve(dir, 'Pages'); - const barrelPath = resolve(pagesDir, 'index.ts'); - if (!existsSync(barrelPath)) return; let entries: string[]; try { @@ -27,41 +25,34 @@ function assertNoBarrelCollision(dir: string): void { } catch { return; } + if (!entries.includes('index.ts')) return; - // Page filenames whose stem case-insensitively equals 'index' would collide - // with the barrel on case-insensitive filesystems. - const colliding = entries.filter((f) => /^index\.(tsx|jsx|js)$/i.test(f) && f !== 'index.ts'); - if (colliding.length === 0) return; + const offending = entries.find((f) => /^index\.(tsx|jsx|js)$/i.test(f) && f !== 'index.ts'); + if (!offending) return; let barrel: string; try { - barrel = readFileSync(barrelPath, 'utf8'); + barrel = readFileSync(resolve(pagesDir, 'index.ts'), 'utf8'); } catch { return; } - // Look for extension-less dynamic imports whose specifier case-insensitively + // Match extension-less dynamic imports whose specifier case-insensitively // resolves to the barrel itself (`import('./Index')` next to `index.ts`). // On case-insensitive filesystems Rolldown silently picks `./index.ts` // (the barrel) rather than the sibling `./Index.tsx`, producing a chunk // that re-exports the barrel and crashes at runtime. - const importRe = /import\(\s*['"]\.\/([A-Za-z0-9_-]+)['"]\s*\)/g; - for (const match of barrel.matchAll(importRe)) { - const specifier = match[1]; - if (specifier.toLowerCase() !== 'index') continue; - const offending = colliding.find( - (f) => f.replace(/\.(tsx|jsx|js)$/i, '').toLowerCase() === specifier.toLowerCase(), - ); - if (!offending) continue; - throw new Error( - `[@simplemodule/client] Pages/index.ts contains \`import('./${specifier}')\` which collides ` + - `with the barrel on case-insensitive filesystems (macOS/Windows). Rolldown will silently ` + - `emit a self-referential chunk and the page will fail at runtime with ` + - `"Cannot assign to property 'layout' of [object Module]".\n\n` + - `Fix: use the explicit file extension, e.g. \`import('./${offending}')\`, or rename ` + - `Pages/${offending} to a name that does not case-insensitively match 'index'.`, - ); - } + if (!/import\(\s*['"]\.\/index['"]\s*\)/i.test(barrel)) return; + + const specifier = offending.replace(/\.(tsx|jsx|js)$/i, ''); + throw new Error( + `[@simplemodule/client] Pages/index.ts contains \`import('./${specifier}')\` which collides ` + + `with the barrel on case-insensitive filesystems (macOS/Windows). Rolldown will silently ` + + `emit a self-referential chunk and the page will fail at runtime with ` + + `"Cannot assign to property 'layout' of [object Module]".\n\n` + + `Fix: use the explicit file extension, e.g. \`import('./${offending}')\`, or rename ` + + `Pages/${offending} to a name that does not case-insensitively match 'index'.`, + ); } /**