perf(web): idle-defer non-critical (app) init + lazy-load modals

Homepage/workbench TTI was dominated by the layout synchronously booting
event-bridge, streak tracker, LLM queue, memoro watcher, dashboard store,
shared-uload and reminder scheduler before first paint, plus statically
importing 7 modals/toasts/banners that are rarely-to-never visible on
initial render.

- Keep critical path inline: local-store init (manaStore/tag/link),
  unified sync engine + billing, guest-mode setup.
- Move side-effect streams, projection workers and telemetry to
  requestIdleCallback (with setTimeout fallback).
- Dynamically import KeyboardShortcutsModal, OnboardingWizard,
  GuestWelcomeModal, SessionWarning, EncryptionIntroBanner,
  SuggestionToast, NudgeToast. Modals fetch on first demand; toasts
  mount after idle so their transitive deps (automationsStore,
  day-snapshot projection, streaks, crypto gate) don't land in the
  initial chunk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 14:41:27 +02:00
parent c357a1cd1d
commit b196e7782e

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import type { Snippet } from 'svelte';
import type { Component, Snippet } from 'svelte';
import { onDestroy, setContext } from 'svelte';
import { createReminderScheduler } from '@mana/shared-stores';
import { todoReminderSource } from '$lib/modules/todo/reminder-source';
@ -9,12 +9,7 @@
import { initTools } from '$lib/data/tools/init';
import { startEventBridge, stopEventBridge } from '$lib/triggers/event-bridge';
import { startStreakTracker, stopStreakTracker } from '$lib/data/projections/streaks';
import KeyboardShortcutsModal from '$lib/components/KeyboardShortcutsModal.svelte';
import SessionWarning from '$lib/components/SessionWarning.svelte';
import EncryptionIntroBanner from '$lib/components/EncryptionIntroBanner.svelte';
import { bottomBarStore } from '$lib/stores/bottom-bar.svelte';
import SuggestionToast from '$lib/components/SuggestionToast.svelte';
import NudgeToast from '$lib/components/NudgeToast.svelte';
import { locale, _ } from 'svelte-i18n';
import {
PillNavigation,
@ -37,7 +32,7 @@
import type { InputBarAdapter } from '$lib/quick-input/types';
import { getAdapterLoader } from '$lib/quick-input/registry';
import { createFallbackAdapter } from '$lib/quick-input/fallback-adapter';
import { AuthGate, GuestWelcomeModal } from '@mana/shared-auth-ui';
import { AuthGate } from '@mana/shared-auth-ui';
import { MANA_APPS, hasAppAccess, ACCESS_TIER_LABELS } from '@mana/shared-branding';
import type { AccessTier } from '@mana/shared-branding';
import { createGuestMode, type GuestMode } from '$lib/stores/guest-mode.svelte';
@ -78,7 +73,6 @@
import { isNavCollapsed as collapsedStore } from '$lib/stores/navigation';
import { getPillAppItems } from '@mana/shared-branding';
import { onboardingStore } from '$lib/stores/onboarding.svelte';
import { OnboardingWizard } from '$lib/components/onboarding';
import { STORAGE_KEYS } from '$lib/config/storage-keys';
import { SearchRegistry } from '$lib/search/registry';
import { registerAllProviders } from '$lib/search/providers';
@ -89,6 +83,22 @@
let { children }: { children: Snippet } = $props();
// ── Idle-defer helper ───────────────────────────────────
// Runs work when the browser is idle so first interaction isn't
// blocked by non-critical init (telemetry, schedulers, side-effect
// streams). Falls back to setTimeout on browsers without
// requestIdleCallback.
function idle(cb: () => void, timeout = 2000) {
if (typeof window === 'undefined') return;
const ric = (
window as unknown as {
requestIdleCallback?: (cb: () => void, opts?: { timeout?: number }) => void;
}
).requestIdleCallback;
if (ric) ric(cb, { timeout });
else setTimeout(cb, 0);
}
// ── App switcher ────────────────────────────────────────
let appItems = $derived(getPillAppItems('mana', undefined, undefined, authStore.user?.tier));
@ -391,6 +401,63 @@
// ── Guest Mode ──────────────────────────────────────────
let guestMode = $state<GuestMode | null>(null);
// ── Lazy-loaded UI (modals, toasts, banners) ────────────
// Static imports for these were adding weight to the initial layout
// bundle for components that are rarely-to-never visible on first
// paint. Each is fetched either on first demand (modals) or shortly
// after idle (always-mounted toasts/banners that self-gate).
// Permissive prop typing — props are validated at the call site
// where {@const} narrows the component back to its concrete type.
type AnyComponent = Component<any>;
let KeyboardShortcutsModalC = $state<AnyComponent | null>(null);
let OnboardingWizardC = $state<AnyComponent | null>(null);
let GuestWelcomeModalC = $state<AnyComponent | null>(null);
let SessionWarningC = $state<AnyComponent | null>(null);
let EncryptionIntroBannerC = $state<AnyComponent | null>(null);
let SuggestionToastC = $state<AnyComponent | null>(null);
let NudgeToastC = $state<AnyComponent | null>(null);
// On-demand: only fetch when the user actually opens them.
$effect(() => {
if (showShortcuts && !KeyboardShortcutsModalC) {
import('$lib/components/KeyboardShortcutsModal.svelte').then((m) => {
KeyboardShortcutsModalC = m.default;
});
}
});
$effect(() => {
if (showOnboarding && !OnboardingWizardC) {
import('$lib/components/onboarding').then((m) => {
OnboardingWizardC = m.OnboardingWizard;
});
}
});
$effect(() => {
if (guestMode?.showWelcome && !GuestWelcomeModalC) {
import('@mana/shared-auth-ui').then((m) => {
GuestWelcomeModalC = m.GuestWelcomeModal;
});
}
});
// Idle-mount: background toasts/banners that self-gate internally.
// Deferring the import also defers their transitive deps
// (automationsStore, day-snapshot projection, streaks, crypto gate).
idle(() => {
void import('$lib/components/SuggestionToast.svelte').then((m) => {
SuggestionToastC = m.default;
});
void import('$lib/components/NudgeToast.svelte').then((m) => {
NudgeToastC = m.default;
});
void import('$lib/components/EncryptionIntroBanner.svelte').then((m) => {
EncryptionIntroBannerC = m.default;
});
void import('$lib/components/SessionWarning.svelte').then((m) => {
SessionWarningC = m.default;
});
});
// ── Onboarding ──────────────────────────────────────────
function handleOnboardingComplete() {
onboardingStore.complete();
@ -415,32 +482,34 @@
setGuestPromptNavigator((href) => goto(href));
if (authStore.isAuthenticated) guestPrompt.clear();
// Phase A: Auth-independent — guests + authenticated
// Phase A (critical): the local-store inits are required before
// liveQueries anywhere downstream (TagStrip, module list views)
// can return non-empty results. Keep these awaited.
await Promise.all([
manaStore.initialize(),
tagLocalStore.initialize(),
linkLocalStore.initialize(),
]);
initSharedUload();
startEventStore();
initTools();
startEventBridge();
startStreakTracker();
await dashboardStore.initialize();
// Start the persistent LLM task queue. Idempotent — safe to call
// repeatedly. The queue picks up any tasks left in 'pending' state
// from previous sessions (and reclaims orphaned 'running' rows
// from a crashed session) before going idle. See $lib/llm-queue.ts.
startLlmQueue();
// Phase A-idle: side-effect streams, telemetry, projection workers.
// All idempotent and self-gated; deferring to the next idle frame
// lets the first paint + interaction land without waiting on
// event-bridge wiring or LLM-queue reclaim work.
idle(() => {
initSharedUload();
startEventStore();
initTools();
startEventBridge();
startStreakTracker();
startLlmQueue();
startMemoroLlmWatcher();
// dashboardStore only drives /dashboard — safe to defer; other
// routes don't read from it on first paint.
void dashboardStore.initialize();
reminderScheduler.start();
});
// Module-side LLM result watchers. Each subscribes via Dexie
// liveQuery to completed task rows tagged for its module and
// writes the results back to the module's own collection
// (e.g. memoro auto-titles → memo.title). Idempotent.
startMemoroLlmWatcher();
// Restore nav collapsed state
// Restore nav collapsed state (cheap, keep inline)
if (typeof localStorage !== 'undefined') {
const savedCollapsed = localStorage.getItem(STORAGE_KEYS.NAV_COLLAPSED);
if (savedCollapsed === 'true') {
@ -449,10 +518,11 @@
}
}
// Phase B: Auth-dependent — sync, settings, onboarding
// Phase B (critical): sync for authenticated users. Data delivery
// is user-visible via the pending-count badge, so we keep the
// sync engine boot on the critical path.
if (authStore.isAuthenticated) {
setErrorTrackingUser({ id: authStore.user?.id ?? 'unknown', email: authStore.user?.email });
trackReturnVisit();
await syncBilling.load();
const getToken = () => authStore.getValidToken();
unifiedSync = createUnifiedSync(SYNC_SERVER_URL, getToken, syncBilling.active);
@ -486,18 +556,21 @@
// value (0 on a fresh tab) until a sync actually runs.
refreshPendingCount();
userSettings.load().catch(() => {});
onboardingStore.load();
if (onboardingStore.shouldShow) {
onboardingStore.start();
ManaEvents.onboardingStarted();
showOnboarding = true;
}
// Phase B-idle: settings, onboarding gating and return-visit
// telemetry. None of this gates rendering — onboarding shows
// via showOnboarding after the store resolves, which is fine
// on a delay.
idle(async () => {
trackReturnVisit();
userSettings.load().catch(() => {});
await onboardingStore.load();
if (onboardingStore.shouldShow) {
onboardingStore.start();
ManaEvents.onboardingStarted();
showOnboarding = true;
}
});
}
// Phase B2: Start reminder scheduler
reminderScheduler.start();
// IMPORTANT: do NOT call notificationService.requestPermission() here.
// Browsers (Chrome/Firefox) require permission requests to come from
// a user gesture. Calling it at mount time queues the prompt until
@ -628,8 +701,9 @@
appName="Mana"
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
>
<!-- Onboarding Wizard (auth only) -->
{#if showOnboarding && authStore.isAuthenticated}
<!-- Onboarding Wizard (auth only) — loaded on demand -->
{#if showOnboarding && authStore.isAuthenticated && OnboardingWizardC}
{@const OnboardingWizard = OnboardingWizardC}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-background/95 backdrop-blur-sm"
>
@ -654,10 +728,14 @@
<!-- One-time encryption intro — sits at the top of the stack so
it can't be obscured by the QuickInputBar / TagStrip / PillNav.
Self-gates on isVaultUnlocked() so guests never see it. -->
<div class="bottom-stack-notification">
<EncryptionIntroBanner />
</div>
Self-gates on isVaultUnlocked() so guests never see it.
Lazy-loaded after idle (see $effects above). -->
{#if EncryptionIntroBannerC}
{@const EncryptionIntroBanner = EncryptionIntroBannerC}
<div class="bottom-stack-notification">
<EncryptionIntroBanner />
</div>
{/if}
<!-- Sync pause banner — shown when sync was paused due to insufficient credits -->
{#if syncBilling.paused}
@ -694,8 +772,10 @@
<!-- Session expiry warning (auth only). Self-gates on the
secondsLeft countdown and only renders inside the stack
when actually warning, so the wrapper is no-op otherwise. -->
{#if authStore.isAuthenticated}
when actually warning, so the wrapper is no-op otherwise.
Lazy-loaded after idle. -->
{#if authStore.isAuthenticated && SessionWarningC}
{@const SessionWarning = SessionWarningC}
<div class="bottom-stack-notification">
<SessionWarning />
</div>
@ -704,16 +784,23 @@
<!-- Cross-module automation suggestions. Lives in the (app)
stack because automationsStore is an (app)-only module
and the toast doesn't make sense on auth/landing pages
anyway. Self-gates on visible state. -->
<div class="bottom-stack-notification">
<SuggestionToast />
</div>
anyway. Self-gates on visible state. Lazy-loaded after idle. -->
{#if SuggestionToastC}
{@const SuggestionToast = SuggestionToastC}
<div class="bottom-stack-notification">
<SuggestionToast />
</div>
{/if}
<!-- Companion Brain pulse nudges — water reminders, streak
warnings, morning summary etc. Self-gates on active nudges. -->
<div class="bottom-stack-notification">
<NudgeToast />
</div>
warnings, morning summary etc. Self-gates on active nudges.
Lazy-loaded after idle. -->
{#if NudgeToastC}
{@const NudgeToast = NudgeToastC}
<div class="bottom-stack-notification">
<NudgeToast />
</div>
{/if}
<!-- QuickInputBar with inline nav toggle — gated by the "search" pill -->
{#if isQuickInputVisible}
@ -882,8 +969,11 @@
so it doesn't end up obscured by the QuickInputBar like
EncryptionIntroBanner used to be. -->
<!-- Keyboard shortcuts modal -->
<KeyboardShortcutsModal open={showShortcuts} onclose={() => (showShortcuts = false)} />
<!-- Keyboard shortcuts modal — loaded on first `?` press -->
{#if KeyboardShortcutsModalC}
{@const KeyboardShortcutsModal = KeyboardShortcutsModalC}
<KeyboardShortcutsModal open={showShortcuts} onclose={() => (showShortcuts = false)} />
{/if}
</div>
<!-- Navigation Context Menu -->
@ -895,8 +985,9 @@
onClose={() => navCtxMenu.close()}
/>
<!-- Guest Welcome Modal -->
{#if guestMode}
<!-- Guest Welcome Modal — loaded when guest mode activates -->
{#if guestMode && GuestWelcomeModalC}
{@const GuestWelcomeModal = GuestWelcomeModalC}
<GuestWelcomeModal
appId="mana"
visible={guestMode.showWelcome}