Skip to content

Latest commit

 

History

History
211 lines (158 loc) · 6.51 KB

File metadata and controls

211 lines (158 loc) · 6.51 KB

Routing & Registry

How Sovereign React handles routing at scale without bloating the main bundle.


The Problem

A typical React SPA puts route definitions and metadata together:

// The naive approach -- fine at 20 routes, breaks at 100+
import { Calculator, FileText, Clock, Image, Music, Lock } from 'lucide-react';

const routes = [
  {
    path: '/calculator',
    component: lazy(() => import('./pages/CalculatorPage')),
    name: 'Calculator',
    icon: Calculator,
    description: 'A full-featured calculator with history',
    category: 'math',
    tags: ['math', 'arithmetic', 'numbers'],
  },
  // ... 100+ more entries, each with an icon import
];

At 20 features, this is fine. At 100+, you're importing dozens of icons and hundreds of lines of metadata into the main bundle -- data that isn't needed until the user visits the Home page or opens search.

Measured impact: In a production app with 100+ features, this pattern reduced the main bundle from 126KB to 89KB gzipped. That's 37KB of metadata and icon imports that users download on every page load but rarely need immediately.


The Solution: Two-Tier Registry

Split routing data from display metadata into two files:

Tier 1: Route Manifest (Main Bundle)

src/data/routeManifest.js -- Lightweight. Only what the router needs.

import { lazy } from 'react';

// Routing-only data. No icons, descriptions, or categories.
// Full metadata lives in registry.js (lazy-loaded by consumers).
export const routeManifest = [
  { id: 'counter', path: '/counter', component: lazy(() => import('../pages/features/CounterPage')) },
  { id: 'file-processor', path: '/file-processor', component: lazy(() => import('../pages/features/FileProcessorPage')) },
  // Add new features here -- one line each
];

export function getRoutableFeatures() {
  return routeManifest.filter(f => f.component !== null);
}

Cost per entry: ~100 bytes. Even at 200 features, this file stays under 20KB.

Tier 2: Registry (Lazy-Loaded)

src/data/registry.js -- Rich metadata. Icons, descriptions, categories. Only loaded when needed.

import { Calculator, FileText, Clock, Wrench } from 'lucide-react';

export const features = [
  {
    id: 'counter',
    name: 'Counter',
    description: 'A simple counter with persistent state',
    icon: Calculator,
    category: 'examples',
    tags: ['counter', 'state', 'example'],
    status: 'stable',
  },
  // Full metadata for display, search, and categorization
];

export function getCategories() { /* ... */ }
export function searchFeatures(query) { /* ... */ }

This file is never imported eagerly. It's loaded by:

  • The Home page (to display the feature grid)
  • Search overlays (to search feature metadata)
  • Any component that needs icons or descriptions

How They Connect

main.jsx
  └── App.jsx
        ├── imports routeManifest.js (lightweight, ~5KB)
        │     └── generates <Route> elements in a loop
        │
        └── lazy-loads Home.jsx
              └── imports registry.js (heavy, 50-100KB+)
                    └── renders feature cards with icons

App.jsx only knows about paths and components. It doesn't know feature names, icons, or descriptions -- and it doesn't need to.


Auto-Generated Routes

Routes are generated dynamically from the manifest -- no manual <Route> elements:

// App.jsx
import { getRoutableFeatures } from './data/routeManifest';

function App() {
  const features = getRoutableFeatures();

  return (
    <Routes>
      <Route path="/" element={<Home />} />
      {features.map(f => (
        <Route
          key={f.id}
          path={f.path}
          element={<ErrorBoundary><f.component /></ErrorBoundary>}
        />
      ))}
      <Route path="*" element={<NotFound />} />
    </Routes>
  );
}

Adding a new feature never touches App.jsx. You add one line to routeManifest.js and one entry to registry.js. Routing, the home page, and search update automatically.


ESLint Enforcement

The two-tier split only works if nobody accidentally imports the heavy registry in an eagerly-loaded module. ESLint enforces this mechanically:

// eslint.config.js
'import-x/no-restricted-paths': ['error', {
  zones: [
    {
      target: './src/App.jsx',
      from: './src/data/registry.js',
      message: 'registry.js must not be imported in App.jsx (bloats main bundle). Use routeManifest.js for routing data.',
    },
    {
      target: './src/main.jsx',
      from: './src/data/registry.js',
      message: 'registry.js must not be imported in main.jsx. Use dynamic import() instead.',
    },
    {
      target: './src/data/routeManifest.js',
      from: './src/data/registry.js',
      message: 'routeManifest.js must not import registry.js. Keep routing data separate from metadata.',
    },
  ],
}]

If someone writes import { features } from './registry' in App.jsx, the linter catches it with a clear error message explaining what to do instead.

See Bundle Discipline for the full ESLint configuration.


Adding a New Feature

  1. Create the page: src/pages/features/MyFeaturePage.jsx
  2. Add to route manifest:
    { id: 'my-feature', path: '/my-feature', component: lazy(() => import('../pages/features/MyFeaturePage')) },
  3. Add to registry:
    { id: 'my-feature', name: 'My Feature', icon: Wrench, category: 'utilities', description: '...' },

That's it. Three files touched. Routing, home page, and search all update automatically.


Planned Features (component: null)

The registry supports features that exist in metadata but aren't implemented yet:

// routeManifest.js -- no entry needed for planned features

// registry.js
{ id: 'future-thing', name: 'Future Thing', icon: Clock, category: 'planned', status: 'planned' },

This lets you display planned features on the Home page (grayed out, "coming soon") without creating route entries or page components.


Scaling Characteristics

Features routeManifest.js registry.js Main bundle impact
10 ~1KB ~5KB Negligible
50 ~5KB ~25KB Minimal
100 ~10KB ~50KB registry stays lazy
200 ~20KB ~100KB+ registry stays lazy

The key insight: routeManifest.js scales linearly at ~100 bytes/feature. registry.js scales linearly too, but its cost is deferred -- users only pay for it when they visit the Home page or use search.