Back to Spree

Dashboard Typed Plugin Routes (Build-Time Route Composition)

docs/plans/5.6-dashboard-typed-plugin-routes.md

5.6.011.9 KB
Original Source

Dashboard Typed Plugin Routes (Build-Time Route Composition)

Status: In Progress (core + collision pre-flight + dep policy implemented; spike ✅ passed — remaining: preview-feedback review of registry deprecation) Target: Spree 5.6 (React Dashboard Developer Preview) Depends on: 5.6-project-layout-and-dashboard.md (shell export, packages/dashboard-starter), 6.0-admin-spa.md Author: Damian + Claude Last updated: 2026-07-06

Summary

Plugin routes originally went only through a runtime registry: defineDashboardPlugin({ routes }) entries matched by a catch-all $ route at navigation time. That works, but plugin pages were second-class — <Link>s to them needed as never casts, they got no route-level code splitting, and no router features (loaders, search-schema validation, per-route error boundaries). This plan makes packaged plugins ship ordinary TanStack file routes that the host build compiles into its route tree, giving plugin pages full typed-router citizenship, while the runtime registry remains the API for dynamic, host-app cases.

The enabling insight: TanStack Router's generator parses route files statically — it never executes them — so plugin route files can be composed at build time without running plugin code in Node (the fragility we deliberately avoid; compare Vendure executing the server config inside Vite).

Key Decisions (do not deviate without discussion)

  • Plugin routes are plain TanStack route files in a directory the plugin declares via its existing marker: "spree": { "dashboard": { "plugin": true, "routes": "./src/routes" } }. Route files may import anything (they compile in the browser build); the plugin's entry module no longer registers routes.
  • Route files commit the FINAL composed path literal (e.g. createFileRoute('/_authenticated/$storeId/brands/')). Plugin routes mount under the shell's authenticated store scope. The generator verifies literals and rewrites mismatches — committing them correctly means it never writes into the package (verified: files pass through untouched).
  • The host generates the route tree (src/routeTree.gen.ts, committed). @spree/dashboard/vite's spreeDashboardPlugin() wraps @tanstack/router-plugin with a @tanstack/virtual-file-routes config: a hand-maintained skeleton of the shell's top-level layout (__rootlogin/accept-invitation_authenticated$storeId) that physical()-mounts the shell's $storeId pages plus each discovered plugin's routes directory. Regeneration happens on every dev start and build, from installed package versions — no publish-time snapshot exists to go stale, and upgrades that add/rename pages show up as a reviewable gen-file diff and as type errors on stale links.
  • Router ownership is inverted. @spree/dashboard exports createDashboardRouter(routeTree) and <Dashboard router={...} />; the Register/FileRoutesByPath augmentations live in host code only (the shell's own live in its main.tsx dev harness, which hosts never import). Two augmentations of the same interface in one program conflict — this split is load-bearing.
  • The registry + catch-all stay for dynamic and in-app registration (plugins.ts, conditional routes). File routes always win: the catch-all $ is by definition the lowest-priority match.
  • Composition lives in @spree/dashboard/vite, not dashboard-core/vite. Custom dashboards built on core + ui have no shell routes to compose; they keep using @spree/dashboard-core/vite.
  • Plugin route files are excluded from the plugin's standalone tsc ("exclude": ["src/routes"]). createFileRoute paths only type-check against a generated tree, which only exists in a host program. In this monorepo the starter compiles them (it is the standing consumer test); out-of-repo authors develop against a host app.

Design Details

The convention (plugin side)

my-plugin/packages/dashboard/
├── package.json      # "spree": { "dashboard": { "plugin": true, "routes": "./src/routes" } }
└── src/
    ├── index.tsx     # entry: slots, nav, tables, translations — NO route registration
    ├── pages/        # page components
    └── routes/       # TanStack route files, full final path literals
        ├── brands.index.tsx      →  /$storeId/brands
        └── brands.$brandId.tsx   →  /$storeId/brands/br_123

Route files use everything the router offers — validateSearch (e.g. resourceSearchSchema from dashboard-core for <ResourceTable> pages), loaders, error components. Links to plugin routes are fully typed in the host program: no as string / as never casts.

The composition (host side)

spreeDashboardPlugin() from @spree/dashboard/vite:

  1. Resolves the shell's routes/ directory from the host's installed @spree/dashboard.
  2. Resolves each plugin's declared routes dir via discoverDashboardPluginManifests (same discovery/whitelist as activation and Tailwind scanning — one plugin list drives all three).
  3. Configures TanStackRouterVite with the virtual skeleton + physical('') mounts and generatedRouteTree: src/routeTree.gen.ts (host-relative; option to override).

Host main.tsx (the starter ships this):

tsx
import { createDashboardRouter, Dashboard } from '@spree/dashboard'
import 'virtual:spree-dashboard-plugins'
import './plugins'
import './styles.css'
import { routeTree } from './routeTree.gen'

const router = createDashboardRouter(routeTree)

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

createRoot(document.getElementById('root')!).render(<Dashboard router={router} />)

Verified in-repo

  • Flag-off regeneration of the shell's own tree via the virtual skeleton produces the identical route set (44 routes) as the previous file-scan config; the shell's Vite config uses the same composition (dogfooding — a skeleton omission breaks shell dev/e2e in-repo, not silently in hosts).
  • The starter's composed tree contains the example plugin's routes (46 = 44 + 2) with correct IDs and full paths; plugin route files pass through generation unmodified.
  • Starter typecheck passes with the example plugin's cast-free <Link to="/$storeId/brands/$brandId" params={{ brandId }}> — typed plugin links proven end-to-end.
  • The starter must declare @tanstack/react-router as a direct dependency: its gen file augments the router module, and augmentation only lands on the copy the host resolves.

Published-tarball spike (✅ passed)

Packed the example plugin (pnpm pack) and installed it into a throwaway host via file:<tarball> — a real extracted directory in node_modules, not a workspace symlink (@spree/* were symlinked; the plugin was the variable under test). Ran a real vite build through spreeDashboardPlugin():

  • Discovery resolved the plugin's routesDir to …/node_modules/@spree/dashboard-plugin-example/src/routes and the generator composed both brands routes (46 total) with correct full paths/IDs; the emitted import points into node_modules.
  • No writes into the installed package — route files' SHA-256 unchanged, committed createFileRoute literals intact. (The generator only rewrites a file whose literal disagrees with the composed path; committing the final literal keeps published packages read-only.)
  • Route generation runs at enforce: 'pre' / buildStart, before Tailwind and bundling — confirmed by isolating it (a config without spreeDashboardPlugin()'s virtual module still generated the full tree before failing at the unrelated virtual:spree-dashboard-plugins bundle step).

Conclusion: published plugins are cleared. The ../node_modules/… relative mount that physical() + path.relative produce resolves correctly through the generator in the mixed symlink-shell + tarball-plugin case (the harder resolution shape); a uniform registry install is strictly simpler.

Migration Path

  1. (shipped) Shell: @/ → relative imports, createDashboardRouter + <Dashboard router> exports, Register moved to main.tsx.
  2. (shipped) @spree/dashboard/vite route composition; the dashboard app itself and packages/dashboard-starter build through it.
  3. (shipped) spree.dashboard.routes marker; example plugin and CLI scaffold template migrated to file routes.
  4. (shipped) Docs: routes guide teaches file routes as the packaged-plugin path, registry for dynamic cases.
  5. (shipped) Packed-tarball spike passed — generator parses route files under real node_modules and never writes into the package; published plugins cleared.
  6. (shipped) Cross-package route-collision pre-flight with package-named errors; TanStack dep classification fixed (router-plugin → dependency).
  7. Registry routes: stays indefinitely for in-app use; revisit whether to deprecate it for packaged plugins after the preview feedback round.

Constraints on Current Work

  • New shell top-level routes must be added to the virtual skeleton in packages/dashboard/src/vite/index.ts (files under _authenticated/$storeId/ need nothing — they're physically mounted).
  • Never declare Register/route-tree augmentations in shell code hosts compile — host main.tsx only.
  • Plugin scaffolds/examples put pages in src/pages/, route files in src/routes/, and exclude src/routes from standalone tsc.
  • Don't hand-edit routeTree.gen.ts anywhere; it regenerates on every build.

Resolved Decisions

  1. Published-tarball behavior — ✅ spike passed (see "Published-tarball spike" above). The generator parses route files under real node_modules and never writes into the package; published plugins are cleared.
  2. Route-path collisions — ✅ implemented. @spree/dashboard/vite pre-flights collisions before invoking the generator (assertNoRouteCollisions, src/vite/route-collisions.ts) and throws an error naming the conflicting packages (the shell counts as @spree/dashboard), the route path, and each file — replacing TanStack's file-path-only Conflicting configuration paths error. Only cross-package collisions are pre-flighted; within-package duplicates stay the generator's to report. Covered by unit tests and verified live against two plugins colliding on /$storeId/brands/.
  3. TanStack dependency classification + pin policy — ✅ settled:
    • @tanstack/react-router is a peerDependency (^1.x) — the host owns the singleton router; a single copy is required for the Register augmentation and the router instance. Matches dashboard-core/dashboard-ui. (Peer-only, no dev copy, per the repo's existing convention — the workspace root hoists it for in-repo build/test.)
    • @tanstack/router-plugin and @tanstack/virtual-file-routes are dependencies — they load through @spree/dashboard/vite at the host's vite.config.ts evaluation, so they must install transitively for consumers. (This is a fix: router-plugin was a devDependency, which would have left a published @spree/dashboard's /vite import unresolved in host projects — verified via the starter, which builds with no direct router-plugin dep.)
    • Pin ranges are per-package carets on the shared 1.x major, not a shared minor band: the three packages version independently (observed react-router 1.170, router-plugin 1.168, virtual-file-routes 1.162). Floors are set to the known-good lockfile versions. On a router major bump, bump all three together and re-run the composition + starter typecheck.

References

  • docs/plans/5.6-project-layout-and-dashboard.md — shell export, starter, packaging decisions (open question 5 there is resolved by this plan)
  • docs/plans/6.0-admin-spa.md — SPA architecture and extension points
  • packages/dashboard/src/vite/index.ts — the composition
  • packages/dashboard-core/src/lib/route-registry.ts — the runtime registry this coexists with
  • TanStack virtual file routes: https://tanstack.com/router/latest/docs/framework/react/routing/virtual-file-routes