Files
OSIT-AE-App-Svelte/documentation/AE__Performance_Guidelines.md
Scott Idem 04a8edc6d1 [Perf] Fix liveQuery reactivity, silence debug logs, add performance guidelines
- launcher/+layout.svelte: convert lq__event_session_obj from $derived to
  $derived.by() so Svelte tracks event_session_id as a dependency; the old
  pattern read the store inside the Dexie async callback where Svelte's
  tracking is off, so the liveQuery never updated on session change
- ae_events__event_file.ts: fix hardcoded log_lvl: 2 in SWR fire-and-forget
  background refresh (always-on debug logging on every cache hit) → 0
- e_app_sign_in_out.svelte: lower 6 call-site log levels (1×log_lvl:2,
  5×log_lvl:1) to 0; sign-in runs on every page load
- element_manage_hosted_file_li.svelte: log_lvl:2 → 0 in refresh call;
  remove log_lvl=1 assignment + debug block inside click handler; log_lvl:1
  → 0 in delete call
- AE__Performance_Guidelines.md: add 5 Svelte 5 runes rules covering
  $derived.by() for reactive liveQuery, liveQuery purity, cheap equality
  guards ($id+updated_on, ID-join, shallow_equal), untrack() requirement,
  and log_lvl discipline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 15:01:42 -04:00

8.7 KiB
Raw Blame History

Performance Guidelines: Non-Blocking Load Pattern (SvelteKit + Dexie)

Overview

To ensure instant page transitions and a high-performance feel, the Aether platform utilizes a Non-Blocking Load Pattern (also known as Stale-While-Revalidate or SWR). This pattern leverages Dexie's liveQuery for reactive UI and SvelteKit's load functions for background data synchronization.

🚀 The Core Principle

Never block the load function with API calls if the data is already being observed by a liveQuery.

The page should render instantly using cached data from IndexedDB. Fresh data from the API should settle in the background and update the UI automatically via reactivity.


Anti-Pattern (Blocking)

This pattern causes a "white screen" or "frozen UI" while the browser waits for the API response.

// +page.ts
export async function load({ params, parent }) {
    const data = await parent();
    const event_id = params.event_id;

    // BAD: This blocks the navigation until the API responds.
    const fresh_data = await events_func.load_ae_obj_id__event({
        event_id: event_id,
        try_cache: true
    });

    return { ...data, event_obj: fresh_data };
}

Best Practice (Non-Blocking / SWR)

This pattern completes the navigation immediately.

// +page.ts
export async function load({ params, parent }) {
    const data = await parent();
    const event_id = params.event_id;

    if (browser) {
        // GOOD: Fire and forget.
        // This function updates IndexedDB in the background.
        events_func.load_ae_obj_id__event({
            event_id: event_id,
            try_cache: true
        });
    }

    return data; // Navigation completes instantly
}
<!-- +page.svelte -->
<script lang="ts">
    import { liveQuery } from 'dexie';
    import { db_events } from '$lib/ae_events/db_events';

    // UI reacts automatically when the background task finishes.
    let lq__event_obj = $derived(
        liveQuery(() => db_events.event.get(event_id))
    );
</script>

{#if $lq__event_obj}
    <h1>{$lq__event_obj.name}</h1>
{:else}
    <p>Loading...</p>
{/if}

🛠️ When to use Await

Use await in load functions ONLY for:

  1. Critical Auth Checks: If you must verify a session before even showing a layout.
  2. Parent Data: const data = await parent(); is necessary to build the context.
  3. Server-Side Rendering (SSR): If the data must be present in the initial HTML for SEO (rare for Aether feature modules).

📈 Performance Gains

By adopting this pattern across the Events module, we achieved:

  • ~200-500ms reduction in perceived page load time.
  • Elimination of waterfalls (sequential API calls).
  • Better offline support, as the UI is always ready to show what's in the local cache.

Svelte 5 Runes + liveQuery: Critical Patterns

These rules apply to all Svelte 5 runes-mode components (the entire Aether frontend). Violations here are a common source of subtle reactivity bugs and unnecessary re-renders.

Rule 1: Use $derived.by() when liveQuery depends on a reactive value

The problem: $derived(liveQuery(callback)) looks like it should re-run when a store value inside callback changes. It does NOT. Svelte tracks reactive dependencies synchronously during the expression evaluation. The liveQuery callback is called later inside Dexie's async context — Svelte's tracking is already finished. The dependency is never registered.

<!-- ❌ WRONG: $events_slct.event_session_id is read inside the async callback.
     Svelte never tracks it. The liveQuery is created once and never recreates
     when event_session_id changes. -->
let lq__session = $derived(
    liveQuery(() => db_events.session.get($events_slct.event_session_id))
);
<!-- ✅ CORRECT: $derived.by() captures the ID in the outer synchronous closure.
     Svelte tracks it. When event_session_id changes, $derived.by() re-runs,
     creating a new liveQuery with the updated ID. -->
let lq__session = $derived.by(() => {
    const id = $events_slct.event_session_id;  // tracked here, synchronously
    return liveQuery(() => db_events.session.get(id));
});

Rule of thumb: If the liveQuery result changes based on a reactive value (store property, $state, $props), always use $derived.by(). Reserve $derived(liveQuery(...)) only for liveQueries that watch a table broadly and don't filter by a reactive value.


Rule 2: Keep liveQuery closures pure (data-only)

The problem: Writing to a Svelte store inside a liveQuery callback runs inside Dexie's async transaction context. Svelte's reactive tracking is undefined there. The write may fire at unpredictable times and create hard-to-debug reactivity loops.

<!-- ❌ WRONG: Store side-effect inside liveQuery async callback. -->
let lq__event_obj = liveQuery(async () => {
    const obj = await db_events.event.get($events_slct.event_id);
    if (obj) $events_slct.event_obj = obj;  // BAD: side-effect in async context
    return obj;
});
<!-- ✅ CORRECT: liveQuery is pure data-only. Store sync happens in a $effect. -->
let lq__event_obj = liveQuery(async () => {
    const id = $events_slct.event_id;
    if (!id) return null;
    return await db_events.event.get(id);
});

$effect(() => {
    const result = $lq__event_obj;
    if (result) {
        untrack(() => {
            // Cheap equality guard — only write if something actually changed.
            if (result.updated_on !== $events_slct.event_obj?.updated_on ||
                result.id !== $events_slct.event_obj?.id) {
                $events_slct.event_obj = { ...result };
            }
        });
    }
});

Rule 3: Use cheap equality guards in $effect before writing to stores

Every store write in a $effect triggers downstream reactivity. Always guard with a comparison before writing. The cost of the comparison is always less than the cost of spurious re-renders.

For single objects — compare id + updated_on (O(1)):

if (result.id !== $store.obj?.id || result.updated_on !== $store.obj?.updated_on) {
    $store.obj = { ...result };
}

For arrays — join IDs into a string (O(n)), not JSON.stringify (O(n × field_count)):

const new_ids = results.map(r => r.id).join(',');
const cur_ids = ($store.list ?? []).map(r => r.id).join(',');
if (new_ids !== cur_ids) {
    $store.list = [...results];
}

For flat objects (e.g., merged config) — shallow key-by-key comparison (O(n keys)):

function shallow_equal(a, b) {
    const keys_a = Object.keys(a);
    const keys_b = Object.keys(b);
    if (keys_a.length !== keys_b.length) return false;
    for (const k of keys_a) { if (a[k] !== b[k]) return false; }
    return true;
}
if (!shallow_equal(current, new_val)) { $store = new_val; }

Never use JSON.stringify for equality. It serializes the full object tree on every reactive cycle and is O(total serialized bytes).


Rule 4: Always use untrack() when writing to stores inside $effect

Without untrack(), reading a store to check its current value inside $effect registers it as a dependency — the effect re-runs whenever it writes, creating an infinite loop.

<!-- ❌ WRONG: Reading $store.obj inside $effect creates a dependency loop. -->
$effect(() => {
    const result = $lq__obj;
    if (result.id !== $store.obj?.id) {  // Reading $store.obj here is a dependency!
        $store.obj = result;             // This write re-triggers the effect.
    }
});
<!-- ✅ CORRECT: untrack() reads current store values without registering them
     as reactive dependencies of the $effect. -->
$effect(() => {
    const result = $lq__obj;  // Tracked: effect re-runs when liveQuery emits
    if (result) {
        untrack(() => {
            // Not tracked: reading $store.obj here won't cause a re-run.
            if (result.id !== $store.obj?.id) {
                $store.obj = result;
            }
        });
    }
});

Rule 5: Guard console.log calls with log_lvl

Raw console.log(obj) eagerly serializes objects (even large ones) on every call, blocking the main thread. All debug logging must be guarded.

let log_lvl: number = $state(0);  // Set to 0 in production; raise locally to debug.

// ❌ WRONG: Always runs, always serializes.
console.log('Result:', result_obj);

// ✅ CORRECT: Zero-cost when log_lvl is 0.
if (log_lvl) console.log('Result:', result_obj);
if (log_lvl > 1) console.log('Verbose:', result_obj);  // Extra-verbose tier

Never hardcode log_lvl: 2 in a call-site or override log_lvl inside a function body. The parameter default exists so callers can control verbosity. Overriding it forces debug logging regardless of what the caller passed.