Перейти к основному содержимому

Orchestration of state across app shell components: sidebar, modals, theme, toasts, and user session.


State Overview

┌─────────────────────────────────────────────────────────────────────┐
│                         Shell State                                  │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  ┌──────────────┐   ┌──────────────┐   ┌──────────────┐            │
│  │   Sidebar    │   │    Theme     │   │   Session    │            │
│  │              │   │              │   │              │            │
│  │ • expanded   │   │ • mode       │   │ • user       │            │
│  │ • pinned     │   │ • resolved   │   │ • expiresAt  │            │
│  │ • activeNav  │   │              │   │              │            │
│  └──────────────┘   └──────────────┘   └──────────────┘            │
│         │                  │                  │                     │
│         │                  │                  │                     │
│  ┌──────────────┐   ┌──────────────┐   ┌──────────────┐            │
│  │    Modals    │   │    Toast     │   │  Preferences │            │
│  │              │   │              │   │              │            │
│  │ • quickSearch│   │ • queue      │   │ • locale     │            │
│  │ • shortcuts  │   │              │   │ • timezone   │            │
│  │ • sessionExp.│   │              │   │ • a11y       │            │
│  └──────────────┘   └──────────────┘   └──────────────┘            │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

State Categories

Category Scope Persistence Source
Sidebar Client localStorage User interaction
Theme Client + Cookie Cookie + DB User preference
Session Server → Client Cookie Better Auth
Modals Client None (ephemeral) User interaction
Notifications Server → Client None Polling/SSE
Preferences Server → Client DB User settings

State Definition

SSR Safety: Never export state at module level. Module-level state is shared across all SSR requests in Node.js. Always use factory functions + context.

// src/lib/state/sidebar.svelte.ts
import { browser } from '$app/environment';
import { getContext, setContext } from 'svelte';

interface SidebarState {
  expanded: boolean;     // Rail vs full sidebar (desktop)
  pinned: boolean;       // Stay expanded vs collapse on blur
  mobileOpen: boolean;   // Drawer open (mobile)
}

const STORAGE_KEY = 'sidebar-state';
const SIDEBAR_CTX = Symbol('sidebar');

export function createSidebarState() {
  // Load from localStorage
  const stored = browser ? localStorage.getItem(STORAGE_KEY) : null;
  const initial: SidebarState = stored
    ? JSON.parse(stored)
    : { expanded: false, pinned: false, mobileOpen: false };

  let state = $state<SidebarState>(initial);

  // Persist on change
  $effect(() => {
    if (browser) {
      localStorage.setItem(STORAGE_KEY, JSON.stringify({
        expanded: state.expanded,
        pinned: state.pinned,
        // Don't persist mobileOpen
      }));
    }
  });

  return {
    get expanded() { return state.expanded; },
    get pinned() { return state.pinned; },
    get mobileOpen() { return state.mobileOpen; },

    expand() { state.expanded = true; },
    collapse() { if (!state.pinned) state.expanded = false; },
    togglePin() { state.pinned = !state.pinned; },

    openMobile() { state.mobileOpen = true; },
    closeMobile() { state.mobileOpen = false; },
    toggleMobile() { state.mobileOpen = !state.mobileOpen; },
  };
}

// Context helpers for SSR-safe access
export function setSidebarContext() {
  const sidebar = createSidebarState();
  setContext(SIDEBAR_CTX, sidebar);
  return sidebar;
}

export function getSidebar() {
  return getContext<ReturnType<typeof createSidebarState>>(SIDEBAR_CTX);
}

Integration with Breakpoints

<script lang="ts">
  import { getSidebar } from '$lib/state/sidebar.svelte';
  import { MediaQuery } from 'svelte/reactivity';

  const sidebar = getSidebar(); // Get from context (SSR-safe)
  const isDesktop = new MediaQuery('(min-width: 1024px)', true);

  // Auto-close mobile drawer on resize to desktop
  $effect(() => {
    if (isDesktop.current && sidebar.mobileOpen) {
      sidebar.closeMobile();
    }
  });
</script>

Theme State

State Definition

SSR Safety: Theme uses context pattern to avoid module-level state sharing across requests.

// src/lib/state/theme.svelte.ts
import { getContext, setContext } from 'svelte';
import { browser } from '$app/environment';
import { apiFetch } from '$lib/api';
import type { Theme } from '$lib/types/db-enums';
import { setCookie } from '$lib/utils/cookies';

type ThemeMode = Theme;
interface ThemeState {
  mode: ThemeMode;
  resolvedMode: 'light' | 'dark'; // Computed from mode + system preference
}

const THEME_CTX = Symbol('theme');

/**
 * Create theme state instance.
 * @param initial - Initial theme settings from server
 */
export function createThemeState(initial: { mode: ThemeMode }) {
  const state = $state<ThemeState>({
    mode: initial.mode,
    resolvedMode: 'light',
  });

  $effect(() => {
    if (!browser) return;

    if (state.mode === 'system') {
      const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
      state.resolvedMode = mediaQuery.matches ? 'dark' : 'light';

      const handler = (e: MediaQueryListEvent) => {
        state.resolvedMode = e.matches ? 'dark' : 'light';
      };
      mediaQuery.addEventListener('change', handler);
      return () => mediaQuery.removeEventListener('change', handler);
    } else {
      state.resolvedMode = state.mode;
    }
  });

  $effect(() => {
    if (!browser) return;
    document.documentElement.classList.toggle('dark', state.resolvedMode === 'dark');
  });

  return {
    get mode() {
      return state.mode;
    },
    get resolvedMode() {
      return state.resolvedMode;
    },
    get isDark() {
      return state.resolvedMode === 'dark';
    },

    setMode(mode: ThemeMode) {
      state.mode = mode;
      if (browser) {
        setCookie('theme', mode, { maxAge: 31536000 });
        // Fire-and-forget DB persistence for authenticated users
        apiFetch('/api/preferences', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ theme: mode }),
        }).catch(() => {});
      }
    },
  };
}

/**
 * Set theme context in component tree.
 * Call this in root layout with initial values from load function.
 */
export function setThemeContext(initial: { mode: ThemeMode }) {
  const theme = createThemeState(initial);
  setContext(THEME_CTX, theme);
  return theme;
}

/**
 * Get theme state from context.
 * Use this in child components.
 */
export function getTheme() {
  return getContext<ReturnType<typeof createThemeState>>(THEME_CTX);
}

SSR Hydration (No Flash)

<!-- app.html - Inline script runs before body renders -->
<script>
  (function() {
    const theme = document.cookie.match(/theme=(\w+)/)?.[1] ?? 'system';
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    const isDark = theme === 'dark' || (theme === 'system' && prefersDark);
    if (isDark) document.documentElement.classList.add('dark');
  })();
</script>

Mutual Exclusion

Only one modal can be open at a time. Opening one closes others.

SSR Safety: Modals are client-only state but still use context pattern for consistency and testability.

// src/lib/state/modals.svelte.ts
import { getContext, setContext } from 'svelte';

type ModalId = 'quickSearch' | 'shortcuts' | 'sessionExpiry' | null;

const MODALS_CTX = Symbol('modals');

export function createModalState() {
  let activeModal = $state<ModalId>(null);
  let modalData = $state<Record<string, unknown>>({});

  return {
    get active() { return activeModal; },

    isOpen(id: ModalId) {
      return activeModal === id;
    },

    open(id: ModalId, data?: Record<string, unknown>) {
      activeModal = id;
      if (data) modalData = data;
    },

    close() {
      activeModal = null;
      modalData = {};
    },

    getData<T>(key: string): T | undefined {
      return modalData[key] as T;
    },
  };
}

// Context helpers for SSR-safe access
export function setModalsContext() {
  const modals = createModalState();
  setContext(MODALS_CTX, modals);
  return modals;
}

export function getModals() {
  return getContext<ReturnType<typeof createModalState>>(MODALS_CTX);
}

Focus Restoration

<script lang="ts">
  import { getModals } from '$lib/state/modals.svelte';

  const modals = getModals(); // Get from context (SSR-safe)

  let triggerRef: HTMLButtonElement;
  let previousFocus: HTMLElement | null = null;

  function openPalette() {
    previousFocus = document.activeElement as HTMLElement;
    modals.open('quickSearch'); // ModalId 'quickSearch' — real shipped identifier
  }

  $effect(() => {
    if (!modals.isOpen('quickSearch') && previousFocus) {
      // Restore focus when modal closes
      previousFocus.focus();
      previousFocus = null;
    }
  });
</script>

<button bind:this={triggerRef} onclick={openPalette}>
  Search
</button>

Chatbot Session State

The Vely chatbot is not a modal and not a context. Its live thread is owned by a deliberate module singleton, src/lib/state/chatbot-session.svelte.ts (chatbotSession), so it survives the chat panel unmounting — on minimize, on navigation, and on the cross-group AppShell remount. ESM module caching gives one instance per tab.

Aspect Detail
Why a singleton, not context/modals The @ai-sdk/svelte Chat + its in-flight stream must outlive the component. It is kept out of the mutually-exclusive modals store so a minimized chat coexists with an open quick-search instead of being evicted.
State phase: 'closed' | 'open' | 'minimized', chat (null until first open), conversationId, answerReady (unread-reply flag) — all $state.
SSR safety Top-level holds only inert primitives; @ai-sdk/svelte + ai load via await import() inside ensureChat() behind a browser gate; every mutator is browser-gated. No leak, no hydration mismatch.
Teardown Named, not lifecycle-bound: SessionMonitor calls chatbotSession.reset() on logout/expiry (aborts the stream, clears the per-tab resume pointer).
Resume A per-tab sessionStorage pointer {conversationId, userId} → owner-scoped GET /api/ai/conversations/[id] rehydrates messages with zero model calls.

Full lifecycle and spatial spec: ../ai/persistent-chatbot.md. App-shell view: ./ai-assistant.md.


Session State

Session is server-authoritative. Client receives session data from load functions.

// src/routes/[[locale=locale]]/account/+layout.server.ts
export const load: LayoutServerLoad = async ({ locals, url }) => {
  const { user } = requireAuth(locals, url.pathname + url.search);
  return { user };
};
<!-- src/lib/components/shell/AppShell.svelte -->
<script lang="ts">
  import { setSessionContext } from '$lib/state/session.svelte';

  let { session, children } = $props();

  // Make the session available to every shell child via getSession()
  setSessionContext(session);
</script>

Important: Never store session in module-level state. Always use event.locals on server and context/props on client.


State Initialization Order

Shell initialization happens in a specific order to prevent flashes and ensure dependencies:

<!-- src/routes/[[locale=locale]]/+layout.svelte -->
<script lang="ts">
  import { onMount } from 'svelte';
  import { setSidebarContext } from '$lib/state/sidebar.svelte';
  import { setThemeContext } from '$lib/state/theme.svelte';
  import { setModalsContext } from '$lib/state/modals.svelte';
  import { setToastContext } from '$lib/state/toast.svelte';
  import { initKeyboardHandler } from '$lib/shortcuts';

  let { data, children } = $props();

  // Initialize all contexts (SSR-safe, request-scoped)
  // 1. Theme (already applied in app.html, just sync state)
  const theme = setThemeContext({ mode: data.themeMode });

  // 2. Sidebar (server-persisted width, expanded/pinned from localStorage on client)
  const sidebar = setSidebarContext(data.sidebarWidth);

  // 3. Modals + toasts (ephemeral client state)
  const modals = setModalsContext();
  const toast = setToastContext();

  // 4. Keyboard shortcuts (register handlers)
  onMount(() => {
    return initKeyboardHandler();
  });
</script>

{@render children()}

Cross-Component Communication

Event-Based Updates

// Option 1: Svelte 5 reactive state (preferred)
// Components import and react to shared state

// Option 2: SvelteKit invalidation for server-owned data
// The notifications page marks a row read, then re-runs its own load:
async function markAsRead(id: string) {
  await fetch(`/api/notifications/${id}/read`, { method: 'POST', headers: { 'X-Requested-With': 'fetch' } });
  await invalidate('app:notifications'); // the page load declares depends('app:notifications')
}

Data Flow Diagram

Server (load functions)
         │
         ▼
┌─────────────────────────────────────┐
│   +layout.svelte (root)             │
│   • Receives: themeMode, sidebarWidth│
│     style, session                  │
│   • Initializes: theme, sidebar,    │
│     modals, toast                   │
│   • Provides: context               │
└─────────────────────────────────────┘
         │
         ├───────────────┬──────────────┐
         ▼               ▼              ▼
    ┌─────────┐    ┌─────────┐    ┌─────────┐
    │ Sidebar │    │ Content │    │ Modals  │
    │         │    │         │    │         │
    │ Reads:  │    │ Reads:  │    │ Reads:  │
    │ • user  │    │ • page  │    │ • modals│
    │ • notif │    │   data  │    │ • theme │
    │ • theme │    │         │    │         │
    └─────────┘    └─────────┘    └─────────┘
         │
         │ (user action)
         ▼
    localStorage / API

Debugging State

Add a debug panel in development:

<!-- Illustrative — a dev-only panel; not shipped -->
<script lang="ts">
  import { dev } from '$app/environment';
  import { getSidebar } from '$lib/state/sidebar.svelte';
  import { getTheme } from '$lib/state/theme.svelte';
  import { getModals } from '$lib/state/modals.svelte';

  // Get all state from context (SSR-safe)
  const sidebar = getSidebar();
  const theme = getTheme();
  const modals = getModals();

  let expanded = $state(false);
</script>

{#if dev}
  <div class="fixed bottom-4 right-4 z-debug">
    <button onclick={() => expanded = !expanded} class="btn btn-sm">
      State
    </button>

    {#if expanded}
      <div class="bg-surface border rounded-lg p-4 mt-2 text-xs font-mono">
        <pre>{JSON.stringify({
          sidebar: {
            expanded: sidebar.expanded,
            pinned: sidebar.pinned,
            mobileOpen: sidebar.mobileOpen,
          },
          theme: {
            mode: theme.mode,
            resolved: theme.resolvedMode,
          },
          modals: {
            active: modals.active,
          },
        }, null, 2)}</pre>
      </div>
    {/if}
  </div>
{/if}

Component Location

src/lib/state/
├── sidebar.svelte.ts            # Sidebar expanded/pinned/mobile state
├── theme.svelte.ts              # Theme mode
├── modals.svelte.ts             # Active modal tracking (quickSearch | shortcuts | sessionExpiry)
├── chatbot-session.svelte.ts    # Vely chatbot live thread (module singleton, NOT a context)
└── index.ts                     # Exports

← Back to Blueprint

Думаете, этот паттерн можно сделать лучше? Расскажите как.

Оставить отзыв