Pulling feat/lazy-loading into develop#1271
Conversation
…plugin Wrap each route's page in a Suspense boundary inside routeManager so React.lazy()-wrapped components can be used as the `default` export, and introduce a Vite plugin (`virtual:dictybase/page-metadata`) that extracts each page's `access` / `roles` exports at build time so consuming apps can stop statically importing page modules. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pages Each page module under apps/stock-center and apps/dicty-frontpage now declares an explicit `access` value. Pages that previously omitted the export were treated as public; the new declarations preserve that behaviour while letting the page-metadata Vite plugin extract a value for every route. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch routes.tsx to a non-eager glob wrapped in React.lazy and read access/roles from the build-time virtual manifest. Pages now ship as on-demand chunks instead of being bundled into the entry module. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch routes.tsx to a non-eager glob wrapped in React.lazy and read access/roles from the build-time virtual manifest. Entry chunk shrinks from 502 kB to 305 kB and each page now ships as its own chunk loaded on demand. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch routes.tsx to a non-eager glob wrapped in React.lazy and read access/roles from the build-time virtual manifest. Pages now ship as on-demand chunks instead of being bundled into the entry module. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan: Simplify Lazy Loading with Dual import.meta.glob PatternContextThe current lazy loading implementation uses a complex multi-part system:
Why This Needs to Change:
User Priorities:
Proposed Solution: Dual import.meta.glob PatternKey Insight: Vite's New Flow (Simple):
Benefits:
Implementation StepsStep 1: Update routes.tsx in Each AppFiles to modify:
Import changes: // Remove this import:
import { pagesMetadata } from "virtual:dictybase/page-metadata"
// Add ACCESS to the existing @dictybase/auth-mui5 import:
import {
type dynamicRoutesProperties,
publicRoutes,
protectedRoutes,
privateRoutes,
buildMergedRoutes,
ACCESS, // Add this
} from "@dictybase/auth-mui5"Replace the glob/metadata pattern: // OLD - Remove this:
const componentLoaders = import.meta.glob("/src/pages/**/**/*.tsx") as Record<
string,
() => Promise<{ default: ComponentType }>
>
const dynamicRoutes: dynamicRoutesProperties = Object.fromEntries(
Object.entries(componentLoaders).map(([path, loader]) => {
const metadata = pagesMetadata[path] ?? {
access: undefined,
roles: undefined,
}
return [
path,
{
default: lazy(loader),
access: metadata.access,
roles: metadata.roles,
},
]
}),
)With this new pattern: // NEW - Three import.meta.glob calls with different configurations:
// This eliminates the Vite plugin while preserving lazy loading and code splitting.
// Components are lazy-loaded (separate chunks), metadata is eager-loaded (tiny bundle).
// 1. Lazy load components (preserves code splitting)
const componentLoaders = import.meta.glob("/src/pages/**/**/*.tsx") as Record<
string,
() => Promise<{ default: ComponentType }>
>
// 2. Eagerly load 'access' metadata (tiny, just enum values)
const accessMetadata = import.meta.glob(
"/src/pages/**/**/*.tsx",
{
import: "access",
eager: true,
}
) as Record<string, { access?: ACCESS }>
// 3. Eagerly load 'roles' metadata (tiny, just arrays)
const rolesMetadata = import.meta.glob(
"/src/pages/**/**/*.tsx",
{
import: "roles",
eager: true,
}
) as Record<string, { roles?: Array<string> }>
// Merge component loaders with metadata
const dynamicRoutes: dynamicRoutesProperties = Object.fromEntries(
Object.keys(componentLoaders).map((path) => [
path,
{
default: lazy(componentLoaders[path]),
access: accessMetadata[path]?.access,
roles: rolesMetadata[path]?.roles,
},
])
)Why this works:
Step 2: Remove Virtual Module DeclarationsFiles to modify:
Delete this entire block from each file: declare module "virtual:dictybase/page-metadata" {
import type { ACCESS } from "@dictybase/auth-mui5"
export const pagesMetadata: Record<
string,
{ access: ACCESS | undefined; roles: Array<string> | undefined }
>
}Step 3: Remove Plugin from Vite ConfigsFiles to modify:
Remove the plugin import and usage: // Remove this import:
import { pageMetadataPlugin } from "@dictybase/auth-mui5/vite/pageMetadata.mjs"
// In the config, change from:
export default defineConfig({
plugins: [pageMetadataPlugin(), react()],
// ...
})
// To:
export default defineConfig({
plugins: [react()],
// ...
})Step 4: Delete the Plugin File (After Verification)File to delete:
Note: Only delete this after all apps are migrated and verified working. Migration StrategyPhase 1: Migrate stock-center (proof of concept)
Phase 2: Migrate genome-page and dicty-frontpage
Phase 3: Cleanup
Files Requiring ChangesPer-app changes (3 apps × 3 files = 9 files):
Package cleanup (1 file):
No changes required:
VerificationFunctional testing:
Build verification:
Testing commands: # Lint all apps
yarn lint
yarn oxlint
# Build each app
yarn workspace stock-center build
yarn workspace genome-page build
yarn workspace dicty-frontpage build
# Run unit tests
yarn test
# Run dev server to manually test routes
yarn workspace stock-center devBundle analysis:
Risk MitigationRisk: TypeScript errors on glob imports
Risk: Missing metadata for some pages
Risk: Glob patterns don't match
Risk: Breaking existing routes
|
Summary
Implements lazy loading on the dynamic page routes for
stock-center,dicty-frontpage, andgenome-page. Each page now ships as its own chunk loaded on demand instead of being bundled into the entry module, and the visible loading state during a route transition renders the existingFullPageLoadingDisplayfallback.Apps out of scope:
publication(static<Routes>definitions, three routes),image-component-demo(no router).Bundle impact
The remaining mass in each entry chunk is shared vendor code (MUI, Apollo, editor, fontawesome) that the existing
manualChunksconfig already handles fordicty-frontpage. Further reductions there are orthogonal to route-level lazy loading.How it works
Each app's
routes.tsxpreviously did:This eager glob is the reason the original bundle could not code-split: even when you also reference the same modules through a non-eager
import.meta.globandReact.lazy, Rollup sees that every page is statically imported and keeps it in the entry chunk. The dynamic import then resolves to the already-loaded module and Vite emits the warning: "dynamic import will not move module into another chunk."To actually split, the static reference to page modules has to go away entirely — but routing still needs each page's
accessandrolesmetadata up front to build the public / protected / private branches of the route tree.The solution has three pieces:
pageMetadataPlugin(packages/auth-mui5/vite/pageMetadata.mjs) — a Vite plugin that scanssrc/pages/**/*.tsxat build / dev-server start, extractsaccessandrolesexports via regex, and serves them through a virtual module:Because the metadata is generated from source text,
routes.tsxnever has to import the page modules to read it.Suspenseboundary inrouteManager—mapToRouteObjectnow wraps<PageComponent />in<Suspense fallback={<FullPageLoadingDisplay />}>.PageComponentData.defaultwas widened fromFunctionComponenttoComponentTypesoReact.lazy()return values are accepted, andaccesswas widened toACCESS | undefinedto match the actual runtime contract (pages that don't declareaccesswere already treated as public).Per-app
routes.tsx— switched to a non-eagerimport.meta.globwrapped inReact.lazy, withaccess/rolesread from the virtual manifest. The route-construction pipeline (publicRoutes/protectedRoutes/privateRoutes/buildMergedRoutes) is otherwise unchanged.Decisions and trade-offs
.mjswith JSDoc types, not.ts. Vite externalises workspace package imports when bundlingvite.config.ts, and Node 20 can't load.tsfromnode_modules. A relative cross-package import worked but trippedimport/no-relative-packagesingenome-page. Writing the plugin as a Node ESM file with JSDoc-typed exports is the only path that loads cleanly from@dictybase/auth-mui5/vite/pageMetadata.mjsin all three apps without compiling or disabling lint rules.\0-prefixed resolved ID is the Rollup convention that prevents other plugins from trying to read it from the filesystem.access/rolesis intentional. Importing each page just to read its named exports defeats the purpose — that's exactly the static-import-keeps-the-module-in-the-entry-chunk problem this PR is solving. A real AST parse (e.g.@typescript/parser) would be more robust but the convention is narrow (export const access = ACCESS.X/export const roles = [...]), so regex is sufficient and adds no dependencies.ACCESS.publicadded to the 11 pages that previously omitted it. The originalRfilter((v) => v.access !== ACCESS.protected)filter letundefinedcount as public. The plugin emitsundefinedfor missing exports so behaviour is preserved either way, but adding an explicit value makes every page declare its own access tier and makes a future "fail the build if a page has noaccess" check trivial to add. It's a no-op behaviourally and a small documentation win.publicationapp left untouched. It uses three static<Route>elements inmain.tsxrather than the convention-based glob and isn't part of "the dynamic page routes." WiringReact.lazyinto three explicit routes would be a separate change with a different motivation.Verification
yarn lintandyarn oxlintclean forstock-center,dicty-frontpage,genome-page, and@dictybase/auth-mui5yarn testpasses: 41 stock-center, 110 dicty-frontpage, 244 genome-page, 11 auth-mui5yarn buildsucceeds for all three apps and produces the per-page chunk output shown aboveTest plan
.jschunks fetched on demand in the Network panelFullPageLoadingDisplayappears during the navigation transition/user/showin stock-center) still gate on auth/content/create) still gate on thecontent-adminrole🤖 Generated with Claude Code