Files
classroomio/apps/dashboard/ROUTE-SPLITTING.md
T

220 lines
12 KiB
Markdown
Raw Normal View History

2026-04-27 11:40:38 +01:00
# Route Group Splitting — Optimization for Org Landing Pages
## Problem
When a user opens an organization via its subdomain (e.g. `myorg.classroomio.com`), the entire dashboard application was loaded before the landing page could render. This includes heavy dependencies like `UpgradeModal`, `PageLoadProgress`, `appInitApi`, `authClient`, and all theme components — none of which are needed to display a public landing page.
The result: slow initial load for org landing pages, which should be lightweight and fast.
## Solution: SvelteKit Route Groups
Instead of deploying a separate app (which conflicts with the single-domain self-hosted architecture), we split routes into **route groups** with separate layout chains. This leverages SvelteKit's built-in code splitting — each group only loads the JS it needs.
### Route Group Structure
```
routes/
├── +layout.svelte # Ultra-thin: global CSS, MetaTags, ModeWatcher, Snackbar
├── +layout.server.ts # Root server layout: org detection (getOrgSiteInfo)
├── +page.svelte # Root page: branches between org landing page and dashboard entry
├── _dashboard-entry.svelte # Dynamically imported: auth + setupApp for non-org root visits
│
├── (org-site)/ # Public org pages — lightweight layout
│ ├── +layout.svelte # Org store setup + optional auth (no login redirect)
│ ├── +layout.ts # Passthrough load
│ ├── courses/ # /courses — public course listing
│ └── course/[slug]/ # /course/[slug] — individual course page + enrollment
│
├── (app)/ # Authenticated dashboard — full layout
│ ├── +layout.svelte # Auth session, appInitApi.setupApp(), UpgradeModal, etc.
│ ├── +layout.ts # Passthrough load
│ ├── courses/ # Course management
│ ├── org/ # Org settings
│ ├── lms/ # Student LMS
│ ├── programs/ # Programs
│ └── home/ # Dashboard home
│
├── (auth)/ # Auth pages (login, signup, etc.)
├── invite/ # Invite handling
└── api/ # API routes
```
## Key Architecture Decisions
### 1. Root Layout is Ultra-Thin
`+layout.svelte` only contains:
- Global CSS import
- `MetaTags` (with merge from page data)
- `ModeWatcher` (dark mode)
- `Snackbar`
No auth logic, no org-specific logic, no heavy component imports. This ensures the shared root bundle is minimal for all routes.
### 2. Root Page (`+page.svelte`) Branches by Context
The `/` route handles two completely different experiences:
- **Org site** (`data.isOrgSite === true`): SSR-fetches public courses in `+page.server.ts`, then dynamically imports the theme component via `importThemeComponent()`. Sets up org stores (`currentOrg`, `globalStore`).
- **Non-org site** (`data.isOrgSite === false`): Dynamically imports `_dashboard-entry.svelte`, which handles auth session checking and `appInitApi.setupApp()` for redirect logic.
The dynamic imports are critical — they prevent the dashboard's auth dependencies from being bundled with the org landing page, and vice versa.
### 3. `(org-site)` Layout — Lightweight with Optional Auth
`(org-site)/+layout.svelte` responsibilities:
- Set up org stores from server data (`currentOrg`, `globalStore.isOrgSite`, theme)
- Set user store from `data.locals` if available
- Check auth session via `authClient.useSession()`
- When session is ready, call `appInitApi.setupApp()` to load full account details
- **Does NOT redirect to `/login`** — these pages are public
The auth handling is needed so that logged-in users see the correct state (e.g. "Go to LMS" vs "Login" button in the nav). `setupApp` loads the user's organizations and roles, which populates `isOrgStudent` and `basePath`.
`routeUserToNextPage` inside `setupApp` already handles org-site public routes — it skips redirects when `isPublicRoute(path) && (path !== '/' || isOrgSite)`.
### 4. `(app)` Layout — Full Auth + App Initialization
`(app)/+layout.svelte` responsibilities:
- `authClient.useSession()` with redirect to `/login` for unauthenticated users
- `appInitApi.setupApp()` for full account loading
- `UpgradeModal`, `PageLoadProgress`, `PageRestricted`
- Org store setup from server data
This layout carries all the heavy dashboard dependencies, which are only loaded for authenticated routes.
### 5. Dynamic Theme Imports
Previously, all three landing page themes (bold, classic, minimal) were statically imported, meaning all theme code was bundled even when only one was used.
Now `importThemeComponent()` in `$features/org/utils/landing-page.ts` uses dynamic imports:
```typescript
export function importThemeComponent(theme: LandingPageThemeKey) {
switch (theme) {
case 'bold':
return import('@cio/ui/custom/org-landing-page/bold.svelte');
case 'classic':
return import('@cio/ui/custom/org-landing-page/classic.svelte');
default:
return import('@cio/ui/custom/org-landing-page/minimal.svelte');
}
}
```
Static imports of all themes are preserved in `$features/org/utils/landing-page-components.ts` — this file is only imported by the dashboard's landing page editor where all themes are needed for preview.
### 6. SSR for Public Course Data
`+page.server.ts` fetches public courses server-side for org sites, so the landing page renders with data immediately instead of showing a loading state while fetching client-side.
## Store Refactoring: `isOrgStudent`
### Before
- `isOrgStudent` lived in `$lib/utils/store/org.ts`
- `globalStore` had an `isStudent` field
- Three separate `$effect` blocks in `+page.svelte`, `(org-site)/+layout.svelte`, and `(app)/+layout.svelte` manually synced `isOrgStudent` → `globalStore.isStudent`
- `isStudentExperience` derived from `globalStore.isStudent`
This was fragile — if any sync `$effect` was removed (as happened during refactoring), `isStudentExperience` and `basePath` would break.
### After
- `isOrgStudent` moved to `$lib/utils/store/app.ts`, derived directly from `currentOrg.roleId`
- `globalStore.isStudent` removed entirely
- `isStudentExperience` now derives from `[globalStore, isOrgStudent]` directly — no manual sync needed
- All `$effect` sync blocks removed from layouts and root page
- All component reads of `$globalStore.isStudent` replaced with `$isOrgStudent`
One source of truth, no manual sync, no fragile `$effect` chains.
## Auth Flow — Centralized in Root Layout
Auth session checking and `appInitApi.setupApp()` live in the **root layout** (`+layout.svelte`). This is the single solution that handles auth for all routes — no per-group duplication, no route audits.
### Why centralized?
An earlier iteration put auth in each group layout (`(app)`, `(org-site)`, `_dashboard-entry`). This broke when new route groups (like `(auth)`) didn't have auth handling — authenticated users on `/login` or `/signup` would see infinite spinners instead of being redirected. Centralizing eliminates that class of bugs entirely.
### What the root layout does
1. `authClient.useSession()` — checks for an active session on every route
2. When session is ready, calls `appInitApi.setupApp()` — loads full account (profile, orgs, roles)
3. `setupApp` → `routeUserToNextPage()` handles redirects based on context:
- On `/login`, `/signup`, `/onboarding`, or `/`: redirects to `/lms` or `/org/[slug]` based on role
- On public org-site routes (`/courses`, `/course/[slug]`): skips redirect (user stays on page with correct login state)
- On other public routes: skips redirect
4. Sets up user store, org stores, and theme from server data
5. No redirect to `/login` — that's the `(app)` layout's job (protected routes only)
### What each group layout does
- **`(app)/+layout.svelte`**: Redirects to `/login` if unauthenticated (protected routes). Renders `UpgradeModal`, `PageLoadProgress`, `PageRestricted`.
- **`(org-site)/+layout.svelte`**: `PageRestricted` gating only. No auth logic — root handles it.
- **`(auth)` group**: No layout file needed. Root layout handles session → `setupApp` → redirect away if already authenticated.
### Flow: login on org site
1. User logs in at `/login` → redirected to `/`
2. Root layout detects session, calls `setupApp` → loads account details
3. `routeUserToNextPage` sees `isPublicRoute('/') && isOrgSite` → skips redirect
4. User sees landing page with correct login state ("Go to LMS" button)
### Flow: login on non-org site
1. User visits `/` → root layout calls `setupApp`
2. `routeUserToNextPage` redirects to `/lms`, `/org/[slug]`, or `/onboarding` based on role
3. `+page.svelte` shows spinner while redirect happens
### Flow: authenticated user hits /login
1. Root layout detects session, calls `setupApp`
2. `routeUserToNextPage` sees `shouldRedirectOnAuth('/login')` → true
3. Redirects to `/lms` or `/org/[slug]`
## Files Changed
### Store layer
- `apps/dashboard/src/lib/utils/store/app.ts` — added `isOrgStudent`, removed `globalStore.isStudent`, rewrote `isStudentExperience`
- `apps/dashboard/src/lib/utils/store/org.ts` — removed `isOrgStudent` export
- `apps/dashboard/src/lib/features/app/init.svelte.ts` — updated `isOrgStudent` import path
### Layout layer
- `apps/dashboard/src/routes/+layout.svelte` — stripped to minimal
- `apps/dashboard/src/routes/+page.svelte` — branching logic for org vs non-org
- `apps/dashboard/src/routes/_dashboard-entry.svelte` — new, handles non-org auth
- `apps/dashboard/src/routes/(org-site)/+layout.svelte` — org store setup + optional auth
- `apps/dashboard/src/routes/(app)/+layout.svelte` — full auth + app init
### Component layer (isOrgStudent migration)
- `features/course/pages/exercise.svelte`
- `features/course/pages/lesson.svelte`
- `features/course/pages/attendance.svelte`
- `features/course/components/exercise/view-mode.svelte`
- `features/course/components/lesson/content-list.svelte`
- `features/course/components/lesson/content-navigation-actions.svelte`
- `features/course/components/lesson/content-section-list.svelte`
- `features/course/components/lesson/lesson-page-edit-header.svelte`
- `features/course/components/sidebar/course-sidebar.svelte`
- `routes/(app)/courses/[id]/exercises/[exerciseId]/+page.svelte`
### Landing page optimization
- `features/org/utils/landing-page.ts` — dynamic `importThemeComponent()`
- `features/org/utils/landing-page-components.ts` — new, static imports for editor only
- `routes/+page.server.ts` — SSR course data for org sites
## Remaining Work / Known Issues
- **Compliance utils (`isSelfPacedLikeCourse`)**: Several components (`exercise.svelte`, `view-mode.svelte`, `content-list.svelte`, `content-section-list.svelte`) had `isSelfPacedLikeCourse` from `compliance-utils.ts` mixed into the `isOrgStudent` commit. These have been reverted to inline `=== 'SELF_PACED'` checks and should be re-introduced in a dedicated compliance commit.
- **`$inspect` and `console.log` cleanup**: `(app)/+layout.svelte` contains `$inspect('session', $session)` and env-logging `console.log` calls that should be removed before production.
- **Bundle size verification**: Run `pnpm --filter @cio/dashboard build` and compare chunk sizes for org-site vs app routes to confirm the code splitting is effective.
- **`(org-site)` course pages**: `courses/+page.svelte` and `course/[slug]/+page.svelte` still statically import nav/hero components from `@cio/ui/custom/org-landing-page`. These could be dynamically imported for further optimization.