Skip to content

Pulling feat/lazy-loading into develop#1271

Open
github-actions[bot] wants to merge 5 commits into
developfrom
feat/lazy-loading
Open

Pulling feat/lazy-loading into develop#1271
github-actions[bot] wants to merge 5 commits into
developfrom
feat/lazy-loading

Conversation

@github-actions
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot commented Jun 2, 2026

Summary

Implements lazy loading on the dynamic page routes for stock-center, dicty-frontpage, and genome-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 existing FullPageLoadingDisplay fallback.

Apps out of scope: publication (static <Routes> definitions, three routes), image-component-demo (no router).

Bundle impact

App Entry chunk before Entry chunk after Per-page chunks
stock-center 3,410 kB 3,266 kB ~25 split files (cart, create, edit, show, …)
dicty-frontpage 502 kB 305 kB (-40%) ~40 split files
genome-page 1,481 kB 1,215 kB ~10 split files (goannotations, phenotypes, references, …)

The remaining mass in each entry chunk is shared vendor code (MUI, Apollo, editor, fontawesome) that the existing manualChunks config already handles for dicty-frontpage. Further reductions there are orthogonal to route-level lazy loading.

How it works

Each app's routes.tsx previously did:

const dynamicRoutes = import.meta.glob("/src/pages/**/*.tsx", { eager: true })

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.glob and React.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 access and roles metadata up front to build the public / protected / private branches of the route tree.

The solution has three pieces:

  1. pageMetadataPlugin (packages/auth-mui5/vite/pageMetadata.mjs) — a Vite plugin that scans src/pages/**/*.tsx at build / dev-server start, extracts access and roles exports via regex, and serves them through a virtual module:

    import { pagesMetadata } from "virtual:dictybase/page-metadata"

    Because the metadata is generated from source text, routes.tsx never has to import the page modules to read it.

  2. Suspense boundary in routeManagermapToRouteObject now wraps <PageComponent /> in <Suspense fallback={<FullPageLoadingDisplay />}>. PageComponentData.default was widened from FunctionComponent to ComponentType so React.lazy() return values are accepted, and access was widened to ACCESS | undefined to match the actual runtime contract (pages that don't declare access were already treated as public).

  3. Per-app routes.tsx — switched to a non-eager import.meta.glob wrapped in React.lazy, with access / roles read from the virtual manifest. The route-construction pipeline (publicRoutes / protectedRoutes / privateRoutes / buildMergedRoutes) is otherwise unchanged.

Decisions and trade-offs

  • Plugin shipped as .mjs with JSDoc types, not .ts. Vite externalises workspace package imports when bundling vite.config.ts, and Node 20 can't load .ts from node_modules. A relative cross-package import worked but tripped import/no-relative-packages in genome-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.mjs in all three apps without compiling or disabling lint rules.
  • Virtual module instead of generating a real file on disk. No artifact to gitignore; the manifest is always in sync with the page sources because it's regenerated each build. The \0-prefixed resolved ID is the Rollup convention that prevents other plugins from trying to read it from the filesystem.
  • Regex extraction of access / roles is 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.
  • Explicit ACCESS.public added to the 11 pages that previously omitted it. The original Rfilter((v) => v.access !== ACCESS.protected) filter let undefined count as public. The plugin emits undefined for 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 no access" check trivial to add. It's a no-op behaviourally and a small documentation win.
  • publication app left untouched. It uses three static <Route> elements in main.tsx rather than the convention-based glob and isn't part of "the dynamic page routes." Wiring React.lazy into three explicit routes would be a separate change with a different motivation.

Verification

  • yarn lint and yarn oxlint clean for stock-center, dicty-frontpage, genome-page, and @dictybase/auth-mui5
  • yarn test passes: 41 stock-center, 110 dicty-frontpage, 244 genome-page, 11 auth-mui5
  • yarn build succeeds for all three apps and produces the per-page chunk output shown above

Test plan

  • Cold-load each app and confirm the entry chunk size in DevTools matches the table above
  • Navigate between routes and observe per-page .js chunks fetched on demand in the Network panel
  • Throttle network to "Slow 3G" and confirm FullPageLoadingDisplay appears during the navigation transition
  • Verify protected routes (e.g. /user/show in stock-center) still gate on auth
  • Verify private routes (e.g. /content/create) still gate on the content-admin role

🤖 Generated with Claude Code

ktun95 and others added 5 commits June 2, 2026 09:28
…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>
@ktun95
Copy link
Copy Markdown
Collaborator

ktun95 commented Jun 5, 2026

Plan: Simplify Lazy Loading with Dual import.meta.glob Pattern

Context

The current lazy loading implementation uses a complex multi-part system:

  1. Custom Vite plugin (pageMetadataPlugin) scans src/pages with regex to extract access and roles
  2. Plugin generates a virtual module (virtual:dictybase/page-metadata) with metadata only
  3. Apps use import.meta.glob() for lazy component loading
  4. Apps import virtual module for metadata
  5. Route manager merges components with metadata and wraps in Suspense

Why This Needs to Change:

  • Too much indirection - Four moving parts: plugin → virtual module → glob imports → route merger
  • Brittle regex parsing - Breaks with formatting variations, comments, complex TypeScript
  • Maintenance burden - Virtual module requires TypeScript declarations in each app
  • Hard to understand - Flow obscured by build-time code generation

User Priorities:

  • ✅ Reduce complexity and indirection
  • ✅ Prioritize simplicity and maintainability
  • ✅ Open to alternatives for metadata pattern
  • ✅ Must preserve lazy loading and code splitting

Proposed Solution: Dual import.meta.glob Pattern

Key Insight: Vite's import.meta.glob with { import: "namedExport", eager: true } can load metadata separately from components without a custom plugin.

New Flow (Simple):

  1. Pages export metadata as named exports (no change to page files)
  2. Use THREE import.meta.glob calls in routes.tsx:
    • Lazy load default exports (components) for code splitting
    • Eager load access named exports (tiny metadata bundle)
    • Eager load roles named exports (tiny metadata bundle)
  3. Merge the three in routes.tsx
  4. Delete the entire Vite plugin
  5. Remove virtual module declarations

Benefits:

  • ✅ No custom Vite plugin
  • ✅ No virtual module
  • ✅ No regex parsing
  • ✅ All logic visible in one file (routes.tsx)
  • ✅ TypeScript infers types from actual exports
  • ✅ Code splitting fully preserved
  • ✅ Bundle size unchanged (metadata is tiny)

Implementation Steps

Step 1: Update routes.tsx in Each App

Files to modify:

  • /Users/wck6299/repos/frontendx/apps/stock-center/src/routes.tsx
  • /Users/wck6299/repos/frontendx/apps/genome-page/src/routes.tsx
  • /Users/wck6299/repos/frontendx/apps/dicty-frontpage/src/routes.tsx

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:

  • import.meta.glob() without options: creates lazy dynamic imports (code splitting preserved)
  • { import: "access", eager: true }: extracts only the access export, bundles it immediately (tiny)
  • { import: "roles", eager: true }: extracts only the roles export, bundles it immediately (tiny)
  • Component code is NEVER included in the eager metadata bundles
  • Each page component remains a separate lazy-loaded chunk

Step 2: Remove Virtual Module Declarations

Files to modify:

  • /Users/wck6299/repos/frontendx/apps/stock-center/src/viteEnvironment.d.ts
  • /Users/wck6299/repos/frontendx/apps/genome-page/src/viteEnvironment.d.ts
  • /Users/wck6299/repos/frontendx/apps/dicty-frontpage/src/viteEnvironment.d.ts

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 Configs

Files to modify:

  • /Users/wck6299/repos/frontendx/apps/stock-center/vite.config.ts
  • /Users/wck6299/repos/frontendx/apps/genome-page/vite.config.ts
  • /Users/wck6299/repos/frontendx/apps/dicty-frontpage/vite.config.ts

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:

  • /Users/wck6299/repos/frontendx/packages/auth-mui5/vite/pageMetadata.mjs

Note: Only delete this after all apps are migrated and verified working.

Migration Strategy

Phase 1: Migrate stock-center (proof of concept)

  1. Update routes.tsx with dual glob pattern
  2. Remove virtual module declaration
  3. Remove plugin from vite.config.ts
  4. Test all routes load correctly
  5. Verify bundle analysis shows code splitting still works

Phase 2: Migrate genome-page and dicty-frontpage

  1. Apply identical changes to genome-page
  2. Apply identical changes to dicty-frontpage
  3. Test each app thoroughly

Phase 3: Cleanup

  1. Delete /packages/auth-mui5/vite/pageMetadata.mjs
  2. Update any developer documentation

Files Requiring Changes

Per-app changes (3 apps × 3 files = 9 files):

  • apps/stock-center/src/routes.tsx - Update glob imports

  • apps/stock-center/src/viteEnvironment.d.ts - Remove virtual module declaration

  • apps/stock-center/vite.config.ts - Remove plugin

  • apps/genome-page/src/routes.tsx - Update glob imports

  • apps/genome-page/src/viteEnvironment.d.ts - Remove virtual module declaration

  • apps/genome-page/vite.config.ts - Remove plugin

  • apps/dicty-frontpage/src/routes.tsx - Update glob imports

  • apps/dicty-frontpage/src/viteEnvironment.d.ts - Remove virtual module declaration

  • apps/dicty-frontpage/vite.config.ts - Remove plugin

Package cleanup (1 file):

  • packages/auth-mui5/vite/pageMetadata.mjs - Delete after migration

No changes required:

  • packages/auth-mui5/src/functional/routeManager.tsx - Works as-is (already handles Suspense)
  • Any page files in src/pages/ - Keep exporting access and roles as before

Verification

Functional testing:

  1. All routes must load correctly
  2. Protected routes must redirect correctly
  3. Role-based routes must work as before
  4. Lazy loading must trigger on navigation (check Network tab)

Build verification:

  1. Run yarn workspace <app-name> build for each app
  2. Verify build succeeds without errors
  3. Analyze bundle with yarn workspace <app-name> vite build --mode analyze
  4. Confirm each page is a separate chunk
  5. Confirm metadata bundle is tiny (~few KB)

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 dev

Bundle analysis:

  • Compare before/after bundle sizes
  • Verify component chunks are still separate (route-based code splitting)
  • Verify eager metadata is small (~few KB total for access + roles)

Risk Mitigation

Risk: TypeScript errors on glob imports

  • Solution: Type assertions included in the pattern above
  • Generic types explicitly declared for each glob

Risk: Missing metadata for some pages

  • Solution: Optional chaining handles undefined: accessMetadata[path]?.access
  • Behavior identical to current implementation (falls back to undefined)

Risk: Glob patterns don't match

  • Solution: Use identical pattern string for all three globs
  • Consider extracting to constant if needed

Risk: Breaking existing routes

  • Solution: Migrate one app at a time
  • Test thoroughly before moving to next app
  • Keep plugin file until all apps migrated

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant