Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions .github/instructions/development-standards.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,99 @@
- Frontend components organized by purpose: `components/ui` (primitives), `components/features` (domain), `components/layouts` (structure).
- Dashboard routes render inside `DashboardLayoutShell`; compose page sections with `AdaptiveLayout` and `AdaptiveSection` so base and minimum heights stay consistent across breakpoints.

## Bundle Optimization

### Core Principle: Know Your Critical Path

The **critical path** is code that runs before the user sees anything useful:
- `main.tsx` → `App.tsx` → `AuthProvider` → first route render

Everything on this path should be as small as possible. Everything else can load later.

### What Goes Where

| Category | Examples | Strategy |
|----------|----------|----------|
| **Critical** | Router, auth store, API client, i18n | Static import, keep lean |
| **Route-level** | Pages (`LibrariesPage`, `SettingsPage`) | Auto code-split by React Router |
| **Feature-heavy** | Upload form, playlist editor, offline-proxy | Lazy load on first use |
| **Heavy libs** | `music-metadata`, `@aws-crypto`, Zod schemas | Dynamic import or isolate to specific chunks |

### Lazy Loading Patterns

**Pattern 1: Route-based (automatic)**
```tsx
// React Router handles this - pages are auto-split
const LibrariesPage = lazy(() => import("@/pages/LibrariesPage"));
```

**Pattern 2: Feature-based (on user action)**
```tsx
// Load heavy module only when user clicks upload
const handleUpload = async () => {
const { processAudioFile } = await import("@/lib/audio/processor");
await processAudioFile(file);
};
```

**Pattern 3: Singleton lazy load (load once, reuse)**
```tsx
// In lib/offline-proxy/index.ts
let module: typeof import("./routes") | null = null;
export async function getOfflineProxy() {
if (!module) module = await import("./routes");
return module.default;
}
```

### When NOT to Lazy Load

❌ **Don't lazy load if already statically imported elsewhere**
```tsx
// BAD: prefetch.ts is already imported by playerStore
useEffect(() => {
import("@/lib/prefetch").then(...); // Useless, already in main bundle
}, []);

// GOOD: Just import directly
import { startIdlePrefetch } from "@/lib/prefetch";
useEffect(() => startIdlePrefetch(), []);
```

❌ **Don't lazy load tiny modules** (< 5KB)
- The chunk overhead + network request isn't worth it

❌ **Don't lazy load on the critical render path**
- If users see a loading spinner for basic UI, it's too much

### Shared Package Imports

`@m3w/shared` has subpath exports to avoid pulling Zod into main bundle:

```tsx
// In critical path modules (stores, lib/api, providers):
import { RepeatMode, isDefaultLibrary, type Song } from "@/lib/shared";

// In lazy-loaded modules (pages, offline-proxy):
import { anything } from "@m3w/shared"; // OK, already code-split
```

The `@/lib/shared.ts` file re-exports safe items from subpaths. See its comments for details.

### Checking Bundle Impact

```bash
# Generate bundle visualization
cd frontend && npx vite-bundle-visualizer

# Check for warnings during build
npm run build 2>&1 | grep -i warning
```

If you see "X is dynamically imported by Y but also statically imported by Z":
- The dynamic import is useless → convert to static import
- Or refactor to remove the static import from critical path

## API Response Patterns
- API routes return `ApiResponse<T>` with `{ success, data?, error?, details? }` structure.
- Export shared types from `@m3w/shared` (types are organized in `shared/src/types/` directory).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import {
Shuffle,
} from "lucide-react";
import { I18n } from "@/locales/i18n";
import { RepeatMode } from "@m3w/shared";
// Import from specific subpath to avoid pulling Zod into main bundle
import { RepeatMode } from "@m3w/shared/types";

interface PlaybackControlsProps {
isPlaying: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import { usePlaylistStore } from "@/stores/playlistStore";
import { useToast } from "@/components/ui/use-toast";
import { I18n } from "@/locales/i18n";
import { getPlaylistDisplayName } from "@/lib/utils/defaults";
import { isFavoritesPlaylist } from "@m3w/shared";
// Import from specific subpath to avoid pulling Zod into main bundle
import { isFavoritesPlaylist } from "@m3w/shared/constants";
import { Check, Plus, Music, Heart } from "lucide-react";
import { cn } from "@/lib/utils";
import { CoverImage, CoverType, CoverSize } from "@/components/ui/cover-image";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ import { processAudioFileStream } from "@/lib/utils/stream-processor";
import { api } from "@/services";
import type { LibraryOption } from "@/types/models";
import { LibraryBig } from "lucide-react";
import { isDefaultLibrary } from "@m3w/shared";
// Import from specific subpath to avoid pulling Zod into main bundle
import { isDefaultLibrary } from "@m3w/shared/constants";

import { UploadStatus, type FileUploadItem, type UploadSongFormProps } from "./types";
import { FileInputSection } from "./file-input-section";
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/layouts/mobile-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,6 @@ export function MobileLayout({ children }: MobileLayoutProps) {
</div>
);
}

// Default export for lazy loading in main.tsx
export default MobileLayout;
31 changes: 21 additions & 10 deletions frontend/src/components/providers/auth-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
/**
* Auth Provider Component
* Wraps app with automatic token refresh, background metadata sync, and auto-download
*
* Note: These modules are already statically imported elsewhere in the app
* (router.ts, playerStore, LibraryDetailPage, OfflineSettings), so dynamic imports
* here would not reduce bundle size. We use static imports for cleaner code.
*/

import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { useAuthRefresh } from "@/hooks/useAuthRefresh";
import { useAuthStore } from "@/stores/authStore";
import {
isMultiRegionEnabled,
initializeEndpoint,
} from "@/lib/api/multi-region";
import { startIdlePrefetch, scheduleNormalPriorityTask, scheduleLowPriorityTask } from "@/lib/prefetch";
import { startAutoSync, stopAutoSync } from "@/lib/sync/metadata-sync";
import { triggerAutoDownload } from "@/lib/storage/download-manager";
import { useAuthStore } from "@/stores/authStore";
import { initializeEndpoint, isMultiRegionEnabled } from "@/lib/api/multi-region";
import { startIdlePrefetch, scheduleLowPriorityTask, scheduleNormalPriorityTask } from "@/lib/prefetch";

interface AuthProviderProps {
children: React.ReactNode;
Expand All @@ -21,6 +28,9 @@ export function AuthProvider({ children }: AuthProviderProps) {

// Get auth state
const { isAuthenticated, isGuest } = useAuthStore();

// Track if sync was started for cleanup
const syncStartedRef = useRef(false);

// Start idle-time prefetch of heavy modules after initial render
useEffect(() => {
Expand All @@ -42,21 +52,22 @@ export function AuthProvider({ children }: AuthProviderProps) {
useEffect(() => {
if (isAuthenticated && !isGuest) {
// Schedule metadata sync as normal priority task
// Uses unified idle scheduler - executes 8s+ after load
scheduleNormalPriorityTask("metadata-sync", startAutoSync);

// Schedule auto-download as low priority task
// Uses unified idle scheduler - executes 15s+ after load to avoid Lighthouse impact
scheduleLowPriorityTask("auto-download", triggerAutoDownload);

syncStartedRef.current = true;

return () => {
stopAutoSync();
if (syncStartedRef.current) {
stopAutoSync();
syncStartedRef.current = false;
}
};
}

return () => {
stopAutoSync();
};
return undefined;
}, [isAuthenticated, isGuest]);

return <>{children}</>;
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/api/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* Also handles automatic caching of GET responses to IndexedDB for offline access.
*/

import { isOfflineCapable } from "@m3w/shared";
import { isOfflineCapable } from "@/lib/shared";
import { logger } from "../logger-client";
import { API_BASE_URL } from "./config";
import { isGuestUser } from "../offline-proxy/utils";
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/audio/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { Track } from "./player";
import { RepeatMode } from "@m3w/shared";
import { RepeatMode } from "@/lib/shared";

// Re-export RepeatMode for convenience
export { RepeatMode };
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/cache/response-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Uses cache config from api-contracts.ts to determine how to cache each response.
*/

import { getCacheConfig } from "@m3w/shared";
import { getCacheConfig } from "@/lib/shared";
import { logger } from "../logger-client";
import {
cacheLibraries,
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
*/

import Dexie, { type EntityTable, type Table } from "dexie";
import { RepeatMode, type Library, type Playlist, type Song, type PlaylistSong } from "@m3w/shared";
import { RepeatMode } from "@/lib/shared";
import type { Library, Playlist, Song, PlaylistSong } from "@m3w/shared";

// ============================================================
// Core Entities (extended from @m3w/shared)
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/lib/offline-proxy/routes/libraries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import { Hono } from "hono";
import type { Context } from "hono";
import { db } from "../../db/schema";
import type { OfflineLibrary, OfflineSong } from "../../db/schema";
import { createLibrarySchema, updateLibrarySchema, toLibraryResponse } from "@m3w/shared";
// Import schemas from subpath (these require Zod, but this module is lazy-loaded anyway)
import { createLibrarySchema, updateLibrarySchema } from "@m3w/shared/schemas";
import { toLibraryResponse } from "@m3w/shared/transformers";
import { getUserId, isGuestUser, sortSongsOffline } from "../utils";
import { parseBlob } from "music-metadata";
import { calculateFileHash } from "../../utils/hash";
Expand Down
11 changes: 6 additions & 5 deletions frontend/src/lib/offline-proxy/routes/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@
import { Hono } from "hono";
import type { Context } from "hono";
import { db } from "../../db/schema";
import {
RepeatMode,
type ApiResponse,
type ProgressSyncResult,
type PreferencesUpdateResult,
// Import from specific subpaths for better tree-shaking
import { RepeatMode } from "@m3w/shared/types";
import type {
ApiResponse,
ProgressSyncResult,
PreferencesUpdateResult,
} from "@m3w/shared";
import { getUserId } from "../utils";

Expand Down
4 changes: 3 additions & 1 deletion frontend/src/lib/offline-proxy/routes/playlists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@

import { Hono } from "hono";
import type { Context } from "hono";
import { createPlaylistSchema, updatePlaylistSchema, toPlaylistResponse } from "@m3w/shared";
// Import schemas from subpath (these require Zod, but this module is lazy-loaded anyway)
import { createPlaylistSchema, updatePlaylistSchema } from "@m3w/shared/schemas";
import { toPlaylistResponse } from "@m3w/shared/transformers";
import type { ApiResponse, PlaylistReorderResult } from "@m3w/shared";
import { getUserId } from "../utils";
import { logger } from "../../logger-client";
Expand Down
51 changes: 51 additions & 0 deletions frontend/src/lib/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Safe re-exports from @m3w/shared
*
* WHY THIS FILE EXISTS:
* The main @m3w/shared entry pulls in Zod (~30KB) even when only importing
* types or constants. This file re-exports commonly used items from their
* specific subpaths to enable tree-shaking.
*
* USAGE RULE:
* - In core modules (stores, lib/api, providers): import from "@/lib/shared"
* - In lazy-loaded modules (pages, offline-proxy): import from "@m3w/shared" directly
*
* This keeps the main bundle small while maintaining readable imports.
*/

// Types (runtime values that are type-safe enums)
export { RepeatMode } from "@m3w/shared/types";

// Constants (pure functions, no dependencies)
export { isDefaultLibrary, isFavoritesPlaylist } from "@m3w/shared/constants";

// API contracts (route definitions, no Zod)
export { isOfflineCapable, getCacheConfig } from "@m3w/shared/api-contracts";

// Re-export all types (these are compile-time only, zero runtime cost)
export type {
// Core entities
Library,
Playlist,
Song,
User,
PlaylistSong,
// API types
ApiResponse,
AuthTokens,
// Input types
CreateLibraryInput,
UpdateLibraryInput,
CreatePlaylistInput,
UpdatePlaylistInput,
UpdateSongInput,
// Other types
SongSortOption,
UserPreferences,
StorageUsageInfo,
ProgressSyncResult,
PreferencesUpdateResult,
PlaylistReorderResult,
SongSearchParams,
SongPlaylistCount,
} from "@m3w/shared";
2 changes: 1 addition & 1 deletion frontend/src/lib/utils/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/

import { I18n } from "@/locales/i18n";
import { isDefaultLibrary, isFavoritesPlaylist } from "@m3w/shared";
import { isDefaultLibrary, isFavoritesPlaylist } from "@/lib/shared";
import type { Library, Playlist } from "@m3w/shared";

/**
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/lib/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ export { cn } from "./cn";
// Duration formatting
export { formatDuration } from "./format-duration";

// Hash utilities
export { calculateFileHash, calculateBufferHash } from "./hash";
// NOTE: Hash utilities (calculateFileHash, calculateBufferHash) are NOT exported here
// to avoid pulling @aws-crypto/sha256-browser (~15KB) into main bundle.
// Import directly from "@/lib/utils/hash" when needed.

// UUID utilities (with fallback for non-secure contexts)
export { generateUUID } from "./uuid";
Expand Down
11 changes: 8 additions & 3 deletions frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,18 @@ const PlaylistDetailPage = lazy(() => import("./pages/PlaylistDetailPage"));
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
const NotFoundPage = lazy(() => import("./pages/NotFoundPage"));

// Lazy load MobileLayout - only needed for authenticated pages
// This removes playerStore + audio system from the critical path
const MobileLayout = lazy(() => import("./components/layouts/mobile-layout"));

// Lazy load PWA prompts - only shown when service worker updates or install available
const ReloadPrompt = lazy(() => import("./components/features/pwa/reload-prompt").then(m => ({ default: m.ReloadPrompt })));
const InstallPrompt = lazy(() => import("./components/features/pwa/install-prompt").then(m => ({ default: m.InstallPrompt })));

// Import UI components (keep these eager loaded as they're used globally)
import { Toaster } from "./components/ui/toaster";
import { PageLoader } from "./components/ui/page-loader";
import { ProtectedRoute } from "./components/providers/protected-route";
import { ReloadPrompt } from "./components/features/pwa/reload-prompt";
import { InstallPrompt } from "./components/features/pwa/install-prompt";
import { MobileLayout } from "./components/layouts/mobile-layout";
import { AuthProvider } from "./components/providers/auth-provider";
import { LocaleProvider } from "./components/providers/locale-provider";

Expand Down
3 changes: 2 additions & 1 deletion frontend/src/pages/LibrariesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { Library, Plus, Trash2 } from "lucide-react";
import { CoverImage, CoverType, CoverSize } from "@/components/ui/cover-image";
import { useToast } from "@/components/ui/use-toast";
import { getLibraryDisplayName, getLibraryBadge } from "@/lib/utils/defaults";
import { isDefaultLibrary } from "@m3w/shared";
// Import from specific subpath to avoid pulling Zod into main bundle
import { isDefaultLibrary } from "@m3w/shared/constants";
import { I18n } from "@/locales/i18n";
import { useCanWrite } from "@/hooks/useCanWrite";
import {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/pages/LibraryDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import { I18n } from "@/locales/i18n";
import { api } from "@/services";
import { eventBus, EVENTS, type SongCachedPayload } from "@/lib/events";
import { getLibraryDisplayName } from "@/lib/utils/defaults";
import { isDefaultLibrary } from "@m3w/shared";
// Import from specific subpath to avoid pulling Zod into main bundle
import { isDefaultLibrary } from "@m3w/shared/constants";
import type { Song, SongSortOption } from "@m3w/shared";
import { logger } from "@/lib/logger-client";
import { getLibraryCacheStats, queueLibraryDownload } from "@/lib/storage/download-manager";
Expand Down
Loading