220 lines
12 KiB
Markdown
220 lines
12 KiB
Markdown
# 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.
|